1use 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#[derive(Debug, Clone)]
46pub struct CloudPoint {
47 pub position: Point3<f64>,
49
50 pub normal: Option<Vector3<f64>>,
52
53 pub color: Option<VertexColor>,
55
56 pub intensity: Option<f32>,
58
59 pub tag: Option<u32>,
61}
62
63impl CloudPoint {
64 #[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 #[inline]
78 pub fn from_coords(x: f64, y: f64, z: f64) -> Self {
79 Self::new(Point3::new(x, y, z))
80 }
81
82 #[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 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#[derive(Debug, Clone)]
106pub struct PointCloud {
107 pub points: Vec<CloudPoint>,
109}
110
111impl PointCloud {
112 pub fn new() -> Self {
114 Self { points: Vec::new() }
115 }
116
117 pub fn with_capacity(capacity: usize) -> Self {
119 Self {
120 points: Vec::with_capacity(capacity),
121 }
122 }
123
124 pub fn from_positions(positions: &[Point3<f64>]) -> Self {
126 Self {
127 points: positions.iter().map(|&p| CloudPoint::new(p)).collect(),
128 }
129 }
130
131 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 #[inline]
149 pub fn len(&self) -> usize {
150 self.points.len()
151 }
152
153 #[inline]
155 pub fn is_empty(&self) -> bool {
156 self.points.is_empty()
157 }
158
159 pub fn has_normals(&self) -> bool {
161 !self.points.is_empty() && self.points.iter().all(|p| p.normal.is_some())
162 }
163
164 pub fn has_colors(&self) -> bool {
166 self.points.iter().any(|p| p.color.is_some())
167 }
168
169 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 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 #[inline]
207 pub fn push(&mut self, point: CloudPoint) {
208 self.points.push(point);
209 }
210
211 #[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 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 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 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 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 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 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 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 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 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 pub fn remove_outliers(&self, k: usize, std_ratio: f64) -> Self {
401 if self.len() <= k {
402 return self.clone();
403 }
404
405 let kdtree = build_kdtree(self);
407
408 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, );
415
416 let sum: f64 = neighbors
417 .iter()
418 .skip(1) .map(|n| n.distance.sqrt())
420 .sum();
421 mean_distances.push(sum / k as f64);
422 }
423
424 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 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 pub fn translate(&mut self, offset: Vector3<f64>) {
451 for point in &mut self.points {
452 point.position += offset;
453 }
454 }
455
456 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum PointCloudFormat {
483 Ply,
485 Xyz,
487 Pcd,
489}
490
491impl PointCloudFormat {
492 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
506fn 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 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 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 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
607fn 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 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 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
709fn 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 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 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 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
788fn 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
819fn 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 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 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 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 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 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 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 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 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
961fn 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 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 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 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
1073fn 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
1089fn 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 let neighbor_points: Vec<Point3<f64>> = neighbors
1102 .iter()
1103 .map(|n| cloud.points[n.item as usize].position)
1104 .collect();
1105
1106 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1149pub enum ReconstructionAlgorithm {
1150 BallPivoting,
1152 SdfBased,
1154}
1155
1156#[derive(Debug, Clone)]
1158pub struct ReconstructionParams {
1159 pub algorithm: ReconstructionAlgorithm,
1161
1162 pub ball_radius: Option<f64>,
1164
1165 pub voxel_size: Option<f64>,
1167
1168 pub normal_neighbors: usize,
1170
1171 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 pub fn ball_pivoting() -> Self {
1190 Self {
1191 algorithm: ReconstructionAlgorithm::BallPivoting,
1192 ..Default::default()
1193 }
1194 }
1195
1196 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 pub fn sdf_based() -> Self {
1207 Self {
1208 algorithm: ReconstructionAlgorithm::SdfBased,
1209 ..Default::default()
1210 }
1211 }
1212
1213 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#[derive(Debug)]
1225pub struct ReconstructionResult {
1226 pub mesh: Mesh,
1228
1229 pub face_count: usize,
1231
1232 pub vertex_count: usize,
1234
1235 pub ball_radius: Option<f64>,
1237
1238 pub voxel_size: Option<f64>,
1240
1241 pub normals_estimated: bool,
1243}
1244
1245fn reconstruct_ball_pivoting(
1247 cloud: &PointCloud,
1248 params: &ReconstructionParams,
1249) -> MeshResult<ReconstructionResult> {
1250 let start = std::time::Instant::now();
1251
1252 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 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 let kdtree = build_kdtree(&cloud);
1281
1282 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 for point in &cloud.points {
1289 mesh.vertices.push(point.to_vertex());
1290 }
1291
1292 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 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 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 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 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
1352fn 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
1385fn 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 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 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
1428fn 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 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 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; }
1453
1454 let face_normal = face_normal / face_normal_norm;
1455
1456 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
1471fn 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 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 if !is_valid_triangle(cloud, v1, v2, idx, ball_radius) {
1515 continue;
1516 }
1517
1518 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 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
1537fn reconstruct_sdf_based(
1539 cloud: &PointCloud,
1540 params: &ReconstructionParams,
1541) -> MeshResult<ReconstructionResult> {
1542 let start = std::time::Instant::now();
1543
1544 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 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 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 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 let kdtree = build_kdtree(&cloud);
1599
1600 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 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 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 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
1654fn 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 type SampleShape = ConstShape3u32<66, 66, 66>;
1667
1668 if dims[0] > 64 || dims[1] > 64 || dims[2] > 64 {
1669 return extract_isosurface_simple(sdf_values, dims, min_bound, voxel_size);
1671 }
1672
1673 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 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
1709fn extract_isosurface_simple(
1711 sdf_values: &[f64],
1712 dims: [usize; 3],
1713 min_bound: Point3<f64>,
1714 voxel_size: f64,
1715) -> MeshResult<Mesh> {
1716 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 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 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 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
1786fn 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
1794fn 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), (4, 5),
1802 (5, 6),
1803 (6, 7),
1804 (7, 4), (0, 4),
1806 (1, 5),
1807 (2, 6),
1808 (3, 7), ];
1810 EDGES[edge as usize]
1811}
1812
1813fn get_marching_cubes_triangles(cube_index: u8) -> Vec<[u8; 3]> {
1815 let mut triangles = Vec::new();
1818
1819 let count = cube_index.count_ones();
1821
1822 if count == 1 {
1823 let corner = cube_index.trailing_zeros() as u8;
1825 let edges = corner_edges(corner);
1826 triangles.push(edges);
1827 } else if count == 7 {
1828 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 triangles
1837}
1838
1839fn corner_edges(corner: u8) -> [u8; 3] {
1841 const CORNER_EDGES: [[u8; 3]; 8] = [
1842 [0, 3, 8], [0, 1, 9], [1, 2, 10], [2, 3, 11], [4, 7, 8], [4, 5, 9], [5, 6, 10], [6, 7, 11], ];
1851 CORNER_EDGES[corner as usize]
1852}
1853
1854#[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 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 let (min, max) = cloud.bounds().unwrap();
1996 let extent = max - min;
1997 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 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 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 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 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 p.normal = Some(Vector3::new(-x, -y, -z).normalize());
2044 cloud.push(p);
2045 }
2046 }
2047 }
2048
2049 cloud.orient_normals_outward();
2050
2051 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 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 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 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(¶ms);
2140
2141 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 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 let y = 1.0 - (i as f64 / (n_points - 1) as f64) * 2.0; 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(¶ms);
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}