Skip to main content

oxiphysics_io/
point_cloud_io.rs

1#![allow(clippy::needless_range_loop, clippy::type_complexity)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Point cloud file format I/O.
6//!
7//! Provides readers and writers for common point cloud formats:
8//!
9//! - **PLY** format (ASCII and binary little-endian)
10//! - **PCD** format (Point Cloud Data, PCL-compatible)
11//! - **XYZ** with optional normals and colors
12//! - **LAS/LAZ** header parsing (LiDAR point cloud metadata)
13//! - **E57** basic structure definition
14//!
15//! Also provides point cloud processing utilities:
16//!
17//! - Point cloud data structure (positions, normals, colors, intensity)
18//! - Voxel grid downsampling
19//! - Statistical outlier removal
20//! - Bounding box computation
21//! - Point cloud merging / concatenation
22//! - Normal estimation from k-nearest neighbors
23//! - KD-tree for nearest neighbor queries
24//! - Point cloud to mesh (ball pivoting concept)
25//! - Coordinate system transformations
26
27#![allow(dead_code)]
28#![allow(clippy::too_many_arguments)]
29
30use std::io::{self, Write};
31
32// ---------------------------------------------------------------------------
33// Core Point Cloud Data Structure
34// ---------------------------------------------------------------------------
35
36/// A single point with optional attributes.
37#[derive(Debug, Clone, Default)]
38pub struct PointCloudPoint {
39    /// Position `[x, y, z]`.
40    pub position: [f64; 3],
41    /// Normal vector `[nx, ny, nz]` (unit length, if present).
42    pub normal: Option<[f64; 3]>,
43    /// RGB color `[r, g, b]` in `[0, 255]`.
44    pub color: Option<[u8; 3]>,
45    /// Scalar intensity value.
46    pub intensity: Option<f64>,
47    /// Classification label.
48    pub classification: Option<u8>,
49}
50
51/// A point cloud: a collection of points with optional per-point attributes.
52#[derive(Debug, Clone)]
53pub struct PointCloud {
54    /// Positions of all points.
55    pub positions: Vec<[f64; 3]>,
56    /// Per-point normals (if present, same length as `positions`).
57    pub normals: Option<Vec<[f64; 3]>>,
58    /// Per-point RGB colors (if present).
59    pub colors: Option<Vec<[u8; 3]>>,
60    /// Per-point intensity values (if present).
61    pub intensities: Option<Vec<f64>>,
62    /// Per-point classification labels (if present).
63    pub classifications: Option<Vec<u8>>,
64}
65
66impl PointCloud {
67    /// Create an empty point cloud.
68    pub fn new() -> Self {
69        Self {
70            positions: Vec::new(),
71            normals: None,
72            colors: None,
73            intensities: None,
74            classifications: None,
75        }
76    }
77
78    /// Create a point cloud from positions only.
79    pub fn from_positions(positions: Vec<[f64; 3]>) -> Self {
80        Self {
81            positions,
82            normals: None,
83            colors: None,
84            intensities: None,
85            classifications: None,
86        }
87    }
88
89    /// Number of points.
90    pub fn len(&self) -> usize {
91        self.positions.len()
92    }
93
94    /// Whether the cloud is empty.
95    pub fn is_empty(&self) -> bool {
96        self.positions.is_empty()
97    }
98
99    /// Whether normals are present.
100    pub fn has_normals(&self) -> bool {
101        self.normals.is_some()
102    }
103
104    /// Whether colors are present.
105    pub fn has_colors(&self) -> bool {
106        self.colors.is_some()
107    }
108
109    /// Whether intensities are present.
110    pub fn has_intensities(&self) -> bool {
111        self.intensities.is_some()
112    }
113
114    /// Add a point with position only.
115    pub fn add_point(&mut self, pos: [f64; 3]) {
116        self.positions.push(pos);
117        if let Some(ref mut n) = self.normals {
118            n.push([0.0; 3]);
119        }
120        if let Some(ref mut c) = self.colors {
121            c.push([255, 255, 255]);
122        }
123        if let Some(ref mut i) = self.intensities {
124            i.push(0.0);
125        }
126        if let Some(ref mut cl) = self.classifications {
127            cl.push(0);
128        }
129    }
130
131    /// Add a point with all attributes.
132    pub fn add_full_point(&mut self, point: PointCloudPoint) {
133        self.positions.push(point.position);
134        if let Some(ref mut n) = self.normals {
135            n.push(point.normal.unwrap_or([0.0; 3]));
136        } else if let Some(n) = point.normal {
137            let mut norms = vec![[0.0_f64; 3]; self.positions.len() - 1];
138            norms.push(n);
139            self.normals = Some(norms);
140        }
141        if let Some(ref mut c) = self.colors {
142            c.push(point.color.unwrap_or([255, 255, 255]));
143        } else if let Some(c) = point.color {
144            let mut cols = vec![[255_u8; 3]; self.positions.len() - 1];
145            cols.push(c);
146            self.colors = Some(cols);
147        }
148        if let Some(ref mut i) = self.intensities {
149            i.push(point.intensity.unwrap_or(0.0));
150        } else if let Some(intensity) = point.intensity {
151            let mut ints = vec![0.0_f64; self.positions.len() - 1];
152            ints.push(intensity);
153            self.intensities = Some(ints);
154        }
155        if let Some(ref mut cl) = self.classifications {
156            cl.push(point.classification.unwrap_or(0));
157        }
158    }
159
160    /// Get a single point as a `PointCloudPoint`.
161    pub fn get_point(&self, idx: usize) -> Option<PointCloudPoint> {
162        if idx >= self.positions.len() {
163            return None;
164        }
165        Some(PointCloudPoint {
166            position: self.positions[idx],
167            normal: self.normals.as_ref().map(|n| n[idx]),
168            color: self.colors.as_ref().map(|c| c[idx]),
169            intensity: self.intensities.as_ref().map(|i| i[idx]),
170            classification: self.classifications.as_ref().map(|c| c[idx]),
171        })
172    }
173
174    /// Reserve capacity for `n` additional points.
175    pub fn reserve(&mut self, n: usize) {
176        self.positions.reserve(n);
177        if let Some(ref mut normals) = self.normals {
178            normals.reserve(n);
179        }
180        if let Some(ref mut colors) = self.colors {
181            colors.reserve(n);
182        }
183        if let Some(ref mut ints) = self.intensities {
184            ints.reserve(n);
185        }
186    }
187}
188
189impl Default for PointCloud {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195// ---------------------------------------------------------------------------
196// Bounding Box
197// ---------------------------------------------------------------------------
198
199/// Axis-aligned bounding box in 3D.
200#[derive(Debug, Clone, Copy)]
201pub struct BoundingBox {
202    /// Minimum corner.
203    pub min: [f64; 3],
204    /// Maximum corner.
205    pub max: [f64; 3],
206}
207
208impl BoundingBox {
209    /// Compute the bounding box of a point cloud.
210    pub fn from_point_cloud(cloud: &PointCloud) -> Option<Self> {
211        if cloud.is_empty() {
212            return None;
213        }
214        let mut min = [f64::MAX; 3];
215        let mut max = [f64::MIN; 3];
216        for p in &cloud.positions {
217            for d in 0..3 {
218                if p[d] < min[d] {
219                    min[d] = p[d];
220                }
221                if p[d] > max[d] {
222                    max[d] = p[d];
223                }
224            }
225        }
226        Some(Self { min, max })
227    }
228
229    /// Center of the bounding box.
230    pub fn center(&self) -> [f64; 3] {
231        [
232            0.5 * (self.min[0] + self.max[0]),
233            0.5 * (self.min[1] + self.max[1]),
234            0.5 * (self.min[2] + self.max[2]),
235        ]
236    }
237
238    /// Extents (half-widths) of the bounding box.
239    pub fn extents(&self) -> [f64; 3] {
240        [
241            0.5 * (self.max[0] - self.min[0]),
242            0.5 * (self.max[1] - self.min[1]),
243            0.5 * (self.max[2] - self.min[2]),
244        ]
245    }
246
247    /// Diagonal length.
248    pub fn diagonal(&self) -> f64 {
249        let dx = self.max[0] - self.min[0];
250        let dy = self.max[1] - self.min[1];
251        let dz = self.max[2] - self.min[2];
252        (dx * dx + dy * dy + dz * dz).sqrt()
253    }
254
255    /// Volume of the bounding box.
256    pub fn volume(&self) -> f64 {
257        let dx = self.max[0] - self.min[0];
258        let dy = self.max[1] - self.min[1];
259        let dz = self.max[2] - self.min[2];
260        dx * dy * dz
261    }
262
263    /// Check if a point is inside the bounding box.
264    pub fn contains(&self, p: [f64; 3]) -> bool {
265        p[0] >= self.min[0]
266            && p[0] <= self.max[0]
267            && p[1] >= self.min[1]
268            && p[1] <= self.max[1]
269            && p[2] >= self.min[2]
270            && p[2] <= self.max[2]
271    }
272
273    /// Merge two bounding boxes.
274    pub fn merge(&self, other: &BoundingBox) -> BoundingBox {
275        BoundingBox {
276            min: [
277                self.min[0].min(other.min[0]),
278                self.min[1].min(other.min[1]),
279                self.min[2].min(other.min[2]),
280            ],
281            max: [
282                self.max[0].max(other.max[0]),
283                self.max[1].max(other.max[1]),
284                self.max[2].max(other.max[2]),
285            ],
286        }
287    }
288}
289
290// ---------------------------------------------------------------------------
291// KD-Tree for Nearest Neighbor Queries
292// ---------------------------------------------------------------------------
293
294/// A node in a KD-tree for 3D points.
295#[derive(Debug)]
296enum KdNode {
297    /// Leaf node containing point indices.
298    Leaf {
299        /// Indices into the original point cloud.
300        indices: Vec<usize>,
301    },
302    /// Internal node splitting on a given axis.
303    Split {
304        /// Axis (0=x, 1=y, 2=z).
305        axis: usize,
306        /// Split value.
307        split_value: f64,
308        /// Left subtree (points with coord <= split_value).
309        left: Box<KdNode>,
310        /// Right subtree.
311        right: Box<KdNode>,
312    },
313}
314
315/// A KD-tree for efficient nearest neighbor queries in 3D.
316#[derive(Debug)]
317pub struct KdTree {
318    /// Root node.
319    root: Option<KdNode>,
320    /// Reference to positions (copied).
321    positions: Vec<[f64; 3]>,
322}
323
324/// Maximum leaf size for the KD-tree.
325const KD_LEAF_SIZE: usize = 16;
326
327impl KdTree {
328    /// Build a KD-tree from a point cloud.
329    pub fn build(cloud: &PointCloud) -> Self {
330        let positions = cloud.positions.clone();
331        let indices: Vec<usize> = (0..positions.len()).collect();
332        let root = if indices.is_empty() {
333            None
334        } else {
335            Some(Self::build_recursive(&positions, indices, 0))
336        };
337        Self { root, positions }
338    }
339
340    /// Build from raw positions.
341    pub fn from_positions(positions: &[[f64; 3]]) -> Self {
342        let pos = positions.to_vec();
343        let indices: Vec<usize> = (0..pos.len()).collect();
344        let root = if indices.is_empty() {
345            None
346        } else {
347            Some(Self::build_recursive(&pos, indices, 0))
348        };
349        Self {
350            root,
351            positions: pos,
352        }
353    }
354
355    fn build_recursive(positions: &[[f64; 3]], mut indices: Vec<usize>, depth: usize) -> KdNode {
356        if indices.len() <= KD_LEAF_SIZE {
357            return KdNode::Leaf { indices };
358        }
359
360        let axis = depth % 3;
361        indices.sort_by(|&a, &b| {
362            positions[a][axis]
363                .partial_cmp(&positions[b][axis])
364                .unwrap_or(std::cmp::Ordering::Equal)
365        });
366
367        let mid = indices.len() / 2;
368        let split_value = positions[indices[mid]][axis];
369
370        let left_indices = indices[..mid].to_vec();
371        let right_indices = indices[mid..].to_vec();
372
373        KdNode::Split {
374            axis,
375            split_value,
376            left: Box::new(Self::build_recursive(positions, left_indices, depth + 1)),
377            right: Box::new(Self::build_recursive(positions, right_indices, depth + 1)),
378        }
379    }
380
381    /// Find the nearest neighbor to a query point.
382    ///
383    /// Returns `(index, squared_distance)`.
384    pub fn nearest(&self, query: [f64; 3]) -> Option<(usize, f64)> {
385        let root = self.root.as_ref()?;
386        let mut best_idx = 0;
387        let mut best_dist_sq = f64::MAX;
388        Self::nearest_recursive(
389            root,
390            &self.positions,
391            query,
392            &mut best_idx,
393            &mut best_dist_sq,
394        );
395        Some((best_idx, best_dist_sq))
396    }
397
398    fn nearest_recursive(
399        node: &KdNode,
400        positions: &[[f64; 3]],
401        query: [f64; 3],
402        best_idx: &mut usize,
403        best_dist_sq: &mut f64,
404    ) {
405        match node {
406            KdNode::Leaf { indices } => {
407                for &i in indices {
408                    let d = dist_sq(positions[i], query);
409                    if d < *best_dist_sq {
410                        *best_dist_sq = d;
411                        *best_idx = i;
412                    }
413                }
414            }
415            KdNode::Split {
416                axis,
417                split_value,
418                left,
419                right,
420            } => {
421                let diff = query[*axis] - split_value;
422                let (first, second) = if diff <= 0.0 {
423                    (left.as_ref(), right.as_ref())
424                } else {
425                    (right.as_ref(), left.as_ref())
426                };
427                Self::nearest_recursive(first, positions, query, best_idx, best_dist_sq);
428                // Check if we need to search the other subtree
429                if diff * diff < *best_dist_sq {
430                    Self::nearest_recursive(second, positions, query, best_idx, best_dist_sq);
431                }
432            }
433        }
434    }
435
436    /// Find k nearest neighbors.
437    ///
438    /// Returns a vector of `(index, squared_distance)` sorted by distance.
439    pub fn k_nearest(&self, query: [f64; 3], k: usize) -> Vec<(usize, f64)> {
440        if self.root.is_none() || k == 0 {
441            return Vec::new();
442        }
443        let mut results: Vec<(usize, f64)> = Vec::with_capacity(k + 1);
444        Self::knn_recursive(
445            self.root.as_ref().expect("value should be Some"),
446            &self.positions,
447            query,
448            k,
449            &mut results,
450        );
451        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
452        results.truncate(k);
453        results
454    }
455
456    fn knn_recursive(
457        node: &KdNode,
458        positions: &[[f64; 3]],
459        query: [f64; 3],
460        k: usize,
461        results: &mut Vec<(usize, f64)>,
462    ) {
463        match node {
464            KdNode::Leaf { indices } => {
465                for &i in indices {
466                    let d = dist_sq(positions[i], query);
467                    if results.len() < k {
468                        results.push((i, d));
469                        results.sort_by(|a, b| {
470                            a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
471                        });
472                    } else if d < results[k - 1].1 {
473                        results[k - 1] = (i, d);
474                        results.sort_by(|a, b| {
475                            a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
476                        });
477                    }
478                }
479            }
480            KdNode::Split {
481                axis,
482                split_value,
483                left,
484                right,
485            } => {
486                let diff = query[*axis] - split_value;
487                let (first, second) = if diff <= 0.0 {
488                    (left.as_ref(), right.as_ref())
489                } else {
490                    (right.as_ref(), left.as_ref())
491                };
492                Self::knn_recursive(first, positions, query, k, results);
493                let worst_dist = if results.len() < k {
494                    f64::MAX
495                } else {
496                    results[results.len() - 1].1
497                };
498                if diff * diff < worst_dist {
499                    Self::knn_recursive(second, positions, query, k, results);
500                }
501            }
502        }
503    }
504
505    /// Find all points within a given radius (squared).
506    pub fn radius_search(&self, query: [f64; 3], radius_sq: f64) -> Vec<(usize, f64)> {
507        let mut results = Vec::new();
508        if let Some(ref root) = self.root {
509            Self::radius_recursive(root, &self.positions, query, radius_sq, &mut results);
510        }
511        results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
512        results
513    }
514
515    fn radius_recursive(
516        node: &KdNode,
517        positions: &[[f64; 3]],
518        query: [f64; 3],
519        radius_sq: f64,
520        results: &mut Vec<(usize, f64)>,
521    ) {
522        match node {
523            KdNode::Leaf { indices } => {
524                for &i in indices {
525                    let d = dist_sq(positions[i], query);
526                    if d <= radius_sq {
527                        results.push((i, d));
528                    }
529                }
530            }
531            KdNode::Split {
532                axis,
533                split_value,
534                left,
535                right,
536            } => {
537                let diff = query[*axis] - split_value;
538                let (first, second) = if diff <= 0.0 {
539                    (left.as_ref(), right.as_ref())
540                } else {
541                    (right.as_ref(), left.as_ref())
542                };
543                Self::radius_recursive(first, positions, query, radius_sq, results);
544                if diff * diff <= radius_sq {
545                    Self::radius_recursive(second, positions, query, radius_sq, results);
546                }
547            }
548        }
549    }
550
551    /// Number of points in the tree.
552    pub fn len(&self) -> usize {
553        self.positions.len()
554    }
555
556    /// Whether the tree is empty.
557    pub fn is_empty(&self) -> bool {
558        self.positions.is_empty()
559    }
560}
561
562/// Squared distance between two 3D points.
563fn dist_sq(a: [f64; 3], b: [f64; 3]) -> f64 {
564    let dx = a[0] - b[0];
565    let dy = a[1] - b[1];
566    let dz = a[2] - b[2];
567    dx * dx + dy * dy + dz * dz
568}
569
570/// Euclidean distance between two 3D points.
571fn dist(a: [f64; 3], b: [f64; 3]) -> f64 {
572    dist_sq(a, b).sqrt()
573}
574
575/// Normalize a 3D vector. Returns `[0; 3]` if length < eps.
576fn normalize3(v: [f64; 3]) -> [f64; 3] {
577    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
578    if len < 1e-15 {
579        [0.0; 3]
580    } else {
581        [v[0] / len, v[1] / len, v[2] / len]
582    }
583}
584
585/// Cross product of two 3D vectors.
586fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
587    [
588        a[1] * b[2] - a[2] * b[1],
589        a[2] * b[0] - a[0] * b[2],
590        a[0] * b[1] - a[1] * b[0],
591    ]
592}
593
594// ---------------------------------------------------------------------------
595// Voxel Grid Downsampling
596// ---------------------------------------------------------------------------
597
598/// Downsample a point cloud using a voxel grid filter.
599///
600/// Each voxel cell of side `voxel_size` keeps only the centroid of points
601/// falling within it.
602pub fn voxel_grid_downsample(cloud: &PointCloud, voxel_size: f64) -> PointCloud {
603    if cloud.is_empty() || voxel_size <= 0.0 {
604        return cloud.clone();
605    }
606
607    let inv_size = 1.0 / voxel_size;
608    let mut voxels: std::collections::HashMap<(i64, i64, i64), (Vec<usize>, [f64; 3], usize)> =
609        std::collections::HashMap::new();
610
611    for (i, p) in cloud.positions.iter().enumerate() {
612        let vx = (p[0] * inv_size).floor() as i64;
613        let vy = (p[1] * inv_size).floor() as i64;
614        let vz = (p[2] * inv_size).floor() as i64;
615        let key = (vx, vy, vz);
616        let entry = voxels
617            .entry(key)
618            .or_insert_with(|| (Vec::new(), [0.0; 3], 0));
619        entry.0.push(i);
620        entry.1[0] += p[0];
621        entry.1[1] += p[1];
622        entry.1[2] += p[2];
623        entry.2 += 1;
624    }
625
626    let mut result = PointCloud::new();
627    let has_normals = cloud.has_normals();
628    let has_colors = cloud.has_colors();
629    let has_intensities = cloud.has_intensities();
630
631    if has_normals {
632        result.normals = Some(Vec::new());
633    }
634    if has_colors {
635        result.colors = Some(Vec::new());
636    }
637    if has_intensities {
638        result.intensities = Some(Vec::new());
639    }
640
641    for (indices, sum, count) in voxels.values() {
642        let n = *count as f64;
643        let centroid = [sum[0] / n, sum[1] / n, sum[2] / n];
644        result.positions.push(centroid);
645
646        if has_normals {
647            let normals = cloud.normals.as_ref().expect("value should be Some");
648            let mut avg_n = [0.0; 3];
649            for &i in indices {
650                avg_n[0] += normals[i][0];
651                avg_n[1] += normals[i][1];
652                avg_n[2] += normals[i][2];
653            }
654            result
655                .normals
656                .as_mut()
657                .expect("value should be Some")
658                .push(normalize3(avg_n));
659        }
660
661        if has_colors {
662            let colors = cloud.colors.as_ref().expect("value should be Some");
663            let mut avg_c = [0.0_f64; 3];
664            for &i in indices {
665                avg_c[0] += colors[i][0] as f64;
666                avg_c[1] += colors[i][1] as f64;
667                avg_c[2] += colors[i][2] as f64;
668            }
669            result.colors.as_mut().expect("value should be Some").push([
670                (avg_c[0] / n).round().clamp(0.0, 255.0) as u8,
671                (avg_c[1] / n).round().clamp(0.0, 255.0) as u8,
672                (avg_c[2] / n).round().clamp(0.0, 255.0) as u8,
673            ]);
674        }
675
676        if has_intensities {
677            let ints = cloud.intensities.as_ref().expect("value should be Some");
678            let avg_i: f64 = indices.iter().map(|&i| ints[i]).sum::<f64>() / n;
679            result
680                .intensities
681                .as_mut()
682                .expect("value should be Some")
683                .push(avg_i);
684        }
685    }
686
687    result
688}
689
690// ---------------------------------------------------------------------------
691// Statistical Outlier Removal
692// ---------------------------------------------------------------------------
693
694/// Remove statistical outliers from a point cloud.
695///
696/// For each point, computes the mean distance to its `k` nearest neighbors.
697/// Points whose mean distance is more than `std_dev_multiplier` standard
698/// deviations above the global mean are removed.
699pub fn statistical_outlier_removal(
700    cloud: &PointCloud,
701    k: usize,
702    std_dev_multiplier: f64,
703) -> PointCloud {
704    if cloud.len() <= k {
705        return cloud.clone();
706    }
707
708    let tree = KdTree::build(cloud);
709    let mut mean_dists = Vec::with_capacity(cloud.len());
710
711    for (i, pos) in cloud.positions.iter().enumerate() {
712        let neighbors = tree.k_nearest(*pos, k + 1);
713        let sum: f64 = neighbors
714            .iter()
715            .filter(|(idx, _)| *idx != i)
716            .take(k)
717            .map(|(_, d_sq)| d_sq.sqrt())
718            .sum();
719        mean_dists.push(sum / k as f64);
720    }
721
722    let global_mean: f64 = mean_dists.iter().sum::<f64>() / mean_dists.len() as f64;
723    let variance: f64 = mean_dists
724        .iter()
725        .map(|d| (d - global_mean).powi(2))
726        .sum::<f64>()
727        / mean_dists.len() as f64;
728    let std_dev = variance.sqrt();
729    let threshold = global_mean + std_dev_multiplier * std_dev;
730
731    let mut result = PointCloud::new();
732    if cloud.has_normals() {
733        result.normals = Some(Vec::new());
734    }
735    if cloud.has_colors() {
736        result.colors = Some(Vec::new());
737    }
738    if cloud.has_intensities() {
739        result.intensities = Some(Vec::new());
740    }
741
742    for (i, &md) in mean_dists.iter().enumerate() {
743        if md <= threshold {
744            result.positions.push(cloud.positions[i]);
745            if let Some(ref normals) = cloud.normals {
746                result
747                    .normals
748                    .as_mut()
749                    .expect("value should be Some")
750                    .push(normals[i]);
751            }
752            if let Some(ref colors) = cloud.colors {
753                result
754                    .colors
755                    .as_mut()
756                    .expect("value should be Some")
757                    .push(colors[i]);
758            }
759            if let Some(ref ints) = cloud.intensities {
760                result
761                    .intensities
762                    .as_mut()
763                    .expect("value should be Some")
764                    .push(ints[i]);
765            }
766        }
767    }
768
769    result
770}
771
772// ---------------------------------------------------------------------------
773// Normal Estimation
774// ---------------------------------------------------------------------------
775
776/// Estimate normals for a point cloud using k-nearest neighbors.
777///
778/// For each point, finds `k` nearest neighbors, computes the covariance
779/// matrix, and takes the eigenvector with smallest eigenvalue as the normal.
780/// Normals are oriented consistently by checking alignment with the centroid
781/// direction.
782pub fn estimate_normals(cloud: &PointCloud, k: usize) -> Vec<[f64; 3]> {
783    if cloud.is_empty() {
784        return Vec::new();
785    }
786
787    let tree = KdTree::build(cloud);
788    let mut normals = Vec::with_capacity(cloud.len());
789
790    // Compute centroid for normal orientation
791    let centroid = compute_centroid(cloud);
792
793    for (i, pos) in cloud.positions.iter().enumerate() {
794        let neighbors = tree.k_nearest(*pos, k + 1);
795        let neighbor_positions: Vec<[f64; 3]> = neighbors
796            .iter()
797            .filter(|(idx, _)| *idx != i)
798            .take(k)
799            .map(|(idx, _)| cloud.positions[*idx])
800            .collect();
801
802        if neighbor_positions.len() < 3 {
803            normals.push([0.0, 0.0, 1.0]);
804            continue;
805        }
806
807        // Compute mean of neighbors
808        let n_neigh = neighbor_positions.len() as f64;
809        let mut mean = [0.0; 3];
810        for np in &neighbor_positions {
811            mean[0] += np[0];
812            mean[1] += np[1];
813            mean[2] += np[2];
814        }
815        mean[0] /= n_neigh;
816        mean[1] /= n_neigh;
817        mean[2] /= n_neigh;
818
819        // Compute covariance matrix (3x3, symmetric)
820        let mut cov = [[0.0_f64; 3]; 3];
821        for np in &neighbor_positions {
822            let d = [np[0] - mean[0], np[1] - mean[1], np[2] - mean[2]];
823            for r in 0..3 {
824                for c in 0..3 {
825                    cov[r][c] += d[r] * d[c];
826                }
827            }
828        }
829        for r in 0..3 {
830            for c in 0..3 {
831                cov[r][c] /= n_neigh;
832            }
833        }
834
835        // Find smallest eigenvector using power iteration on (max_eval*I - cov)
836        let normal = smallest_eigenvector_3x3(cov);
837
838        // Orient normal to point away from centroid
839        let to_centroid = [
840            centroid[0] - pos[0],
841            centroid[1] - pos[1],
842            centroid[2] - pos[2],
843        ];
844        let dot_val =
845            normal[0] * to_centroid[0] + normal[1] * to_centroid[1] + normal[2] * to_centroid[2];
846        if dot_val > 0.0 {
847            normals.push([-normal[0], -normal[1], -normal[2]]);
848        } else {
849            normals.push(normal);
850        }
851    }
852
853    normals
854}
855
856/// Compute the centroid of a point cloud.
857pub fn compute_centroid(cloud: &PointCloud) -> [f64; 3] {
858    if cloud.is_empty() {
859        return [0.0; 3];
860    }
861    let n = cloud.len() as f64;
862    let mut c = [0.0; 3];
863    for p in &cloud.positions {
864        c[0] += p[0];
865        c[1] += p[1];
866        c[2] += p[2];
867    }
868    [c[0] / n, c[1] / n, c[2] / n]
869}
870
871/// Approximate smallest eigenvector of a 3x3 symmetric matrix using
872/// inverse power iteration.
873fn smallest_eigenvector_3x3(m: [[f64; 3]; 3]) -> [f64; 3] {
874    // Estimate largest eigenvalue via power iteration
875    let mut v = [1.0, 0.0, 0.0];
876    for _ in 0..20 {
877        let mv = mat_vec_3x3(m, v);
878        let len = (mv[0] * mv[0] + mv[1] * mv[1] + mv[2] * mv[2]).sqrt();
879        if len < 1e-15 {
880            return v;
881        }
882        v = [mv[0] / len, mv[1] / len, mv[2] / len];
883    }
884    let max_eval =
885        v[0] * mat_vec_3x3(m, v)[0] + v[1] * mat_vec_3x3(m, v)[1] + v[2] * mat_vec_3x3(m, v)[2];
886
887    // Shifted matrix: B = max_eval * I - M
888    let shift = max_eval + 1.0; // ensure positive definite
889    let b = [
890        [shift - m[0][0], -m[0][1], -m[0][2]],
891        [-m[1][0], shift - m[1][1], -m[1][2]],
892        [-m[2][0], -m[2][1], shift - m[2][2]],
893    ];
894
895    // Power iteration on B gives largest eigenvector of B = smallest of M
896    let mut w = [0.577, 0.577, 0.577]; // ~(1,1,1)/sqrt(3)
897    for _ in 0..30 {
898        let bw = mat_vec_3x3(b, w);
899        let len = (bw[0] * bw[0] + bw[1] * bw[1] + bw[2] * bw[2]).sqrt();
900        if len < 1e-15 {
901            return w;
902        }
903        w = [bw[0] / len, bw[1] / len, bw[2] / len];
904    }
905    w
906}
907
908/// Multiply 3x3 matrix by 3-vector.
909fn mat_vec_3x3(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
910    [
911        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
912        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
913        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
914    ]
915}
916
917// ---------------------------------------------------------------------------
918// Point Cloud Merging
919// ---------------------------------------------------------------------------
920
921/// Merge two point clouds into one.
922pub fn merge_point_clouds(a: &PointCloud, b: &PointCloud) -> PointCloud {
923    let mut result = PointCloud::new();
924    result.positions = a
925        .positions
926        .iter()
927        .chain(b.positions.iter())
928        .copied()
929        .collect();
930
931    if a.has_normals() || b.has_normals() {
932        let mut normals = Vec::with_capacity(a.len() + b.len());
933        if let Some(ref na) = a.normals {
934            normals.extend_from_slice(na);
935        } else {
936            normals.extend(std::iter::repeat_n([0.0_f64; 3], a.len()));
937        }
938        if let Some(ref nb) = b.normals {
939            normals.extend_from_slice(nb);
940        } else {
941            normals.extend(std::iter::repeat_n([0.0_f64; 3], b.len()));
942        }
943        result.normals = Some(normals);
944    }
945
946    if a.has_colors() || b.has_colors() {
947        let mut colors = Vec::with_capacity(a.len() + b.len());
948        if let Some(ref ca) = a.colors {
949            colors.extend_from_slice(ca);
950        } else {
951            colors.extend(std::iter::repeat_n([255_u8; 3], a.len()));
952        }
953        if let Some(ref cb) = b.colors {
954            colors.extend_from_slice(cb);
955        } else {
956            colors.extend(std::iter::repeat_n([255_u8; 3], b.len()));
957        }
958        result.colors = Some(colors);
959    }
960
961    if a.has_intensities() || b.has_intensities() {
962        let mut ints = Vec::with_capacity(a.len() + b.len());
963        if let Some(ref ia) = a.intensities {
964            ints.extend_from_slice(ia);
965        } else {
966            ints.extend(std::iter::repeat_n(0.0_f64, a.len()));
967        }
968        if let Some(ref ib) = b.intensities {
969            ints.extend_from_slice(ib);
970        } else {
971            ints.extend(std::iter::repeat_n(0.0_f64, b.len()));
972        }
973        result.intensities = Some(ints);
974    }
975
976    result
977}
978
979// ---------------------------------------------------------------------------
980// Coordinate System Transformations
981// ---------------------------------------------------------------------------
982
983/// Apply a rigid transformation to a point cloud.
984///
985/// `rotation` is a 3x3 matrix (row-major), `translation` is `[tx, ty, tz]`.
986pub fn transform_point_cloud(
987    cloud: &PointCloud,
988    rotation: [[f64; 3]; 3],
989    translation: [f64; 3],
990) -> PointCloud {
991    let mut result = cloud.clone();
992    for pos in &mut result.positions {
993        let rotated = mat_vec_3x3(rotation, *pos);
994        *pos = [
995            rotated[0] + translation[0],
996            rotated[1] + translation[1],
997            rotated[2] + translation[2],
998        ];
999    }
1000    if let Some(ref mut normals) = result.normals {
1001        for n in normals.iter_mut() {
1002            *n = normalize3(mat_vec_3x3(rotation, *n));
1003        }
1004    }
1005    result
1006}
1007
1008/// Scale a point cloud uniformly.
1009pub fn scale_point_cloud(cloud: &PointCloud, scale: f64) -> PointCloud {
1010    let mut result = cloud.clone();
1011    for pos in &mut result.positions {
1012        pos[0] *= scale;
1013        pos[1] *= scale;
1014        pos[2] *= scale;
1015    }
1016    result
1017}
1018
1019/// Translate a point cloud.
1020pub fn translate_point_cloud(cloud: &PointCloud, offset: [f64; 3]) -> PointCloud {
1021    let mut result = cloud.clone();
1022    for pos in &mut result.positions {
1023        pos[0] += offset[0];
1024        pos[1] += offset[1];
1025        pos[2] += offset[2];
1026    }
1027    result
1028}
1029
1030/// Center a point cloud at the origin.
1031pub fn center_point_cloud(cloud: &PointCloud) -> PointCloud {
1032    let c = compute_centroid(cloud);
1033    translate_point_cloud(cloud, [-c[0], -c[1], -c[2]])
1034}
1035
1036// ---------------------------------------------------------------------------
1037// PLY Format I/O
1038// ---------------------------------------------------------------------------
1039
1040/// PLY file encoding type.
1041#[derive(Debug, Clone, Copy, PartialEq)]
1042pub enum PlyEncoding {
1043    /// ASCII text format.
1044    Ascii,
1045    /// Binary little-endian format.
1046    BinaryLittleEndian,
1047}
1048
1049/// Write a point cloud to PLY ASCII format.
1050pub fn write_ply_ascii(cloud: &PointCloud, writer: &mut dyn Write) -> io::Result<()> {
1051    writeln!(writer, "ply")?;
1052    writeln!(writer, "format ascii 1.0")?;
1053    writeln!(writer, "element vertex {}", cloud.len())?;
1054    writeln!(writer, "property float x")?;
1055    writeln!(writer, "property float y")?;
1056    writeln!(writer, "property float z")?;
1057    if cloud.has_normals() {
1058        writeln!(writer, "property float nx")?;
1059        writeln!(writer, "property float ny")?;
1060        writeln!(writer, "property float nz")?;
1061    }
1062    if cloud.has_colors() {
1063        writeln!(writer, "property uchar red")?;
1064        writeln!(writer, "property uchar green")?;
1065        writeln!(writer, "property uchar blue")?;
1066    }
1067    if cloud.has_intensities() {
1068        writeln!(writer, "property float intensity")?;
1069    }
1070    writeln!(writer, "end_header")?;
1071
1072    for i in 0..cloud.len() {
1073        let p = cloud.positions[i];
1074        write!(writer, "{:.6} {:.6} {:.6}", p[0], p[1], p[2])?;
1075        if let Some(ref normals) = cloud.normals {
1076            let n = normals[i];
1077            write!(writer, " {:.6} {:.6} {:.6}", n[0], n[1], n[2])?;
1078        }
1079        if let Some(ref colors) = cloud.colors {
1080            let c = colors[i];
1081            write!(writer, " {} {} {}", c[0], c[1], c[2])?;
1082        }
1083        if let Some(ref ints) = cloud.intensities {
1084            write!(writer, " {:.6}", ints[i])?;
1085        }
1086        writeln!(writer)?;
1087    }
1088    Ok(())
1089}
1090
1091/// Write a point cloud to PLY binary little-endian format (to a byte buffer).
1092pub fn write_ply_binary_le(cloud: &PointCloud) -> Vec<u8> {
1093    let mut buf = Vec::new();
1094
1095    let header = format!(
1096        "ply\nformat binary_little_endian 1.0\nelement vertex {}\nproperty float x\nproperty float y\nproperty float z\nend_header\n",
1097        cloud.len()
1098    );
1099    buf.extend_from_slice(header.as_bytes());
1100
1101    for p in &cloud.positions {
1102        buf.extend_from_slice(&(p[0] as f32).to_le_bytes());
1103        buf.extend_from_slice(&(p[1] as f32).to_le_bytes());
1104        buf.extend_from_slice(&(p[2] as f32).to_le_bytes());
1105    }
1106    buf
1107}
1108
1109/// Parse a PLY ASCII string into a point cloud.
1110pub fn parse_ply_ascii(data: &str) -> Option<PointCloud> {
1111    let mut lines = data.lines();
1112    let first = lines.next()?;
1113    if first.trim() != "ply" {
1114        return None;
1115    }
1116
1117    let mut vertex_count = 0usize;
1118    let mut has_normals = false;
1119    let mut has_colors = false;
1120    let mut _has_intensity = false;
1121    let mut in_header = true;
1122
1123    while in_header {
1124        let line = lines.next()?;
1125        let line = line.trim();
1126        if line.starts_with("element vertex") {
1127            vertex_count = line.split_whitespace().last()?.parse().ok()?;
1128        } else if line.starts_with("property") {
1129            if line.contains(" nx") || line.contains(" ny") || line.contains(" nz") {
1130                has_normals = true;
1131            }
1132            if line.contains(" red") || line.contains(" green") || line.contains(" blue") {
1133                has_colors = true;
1134            }
1135            if line.contains(" intensity") {
1136                _has_intensity = true;
1137            }
1138        } else if line == "end_header" {
1139            in_header = false;
1140        }
1141    }
1142
1143    let mut cloud = PointCloud::new();
1144    cloud.positions.reserve(vertex_count);
1145    if has_normals {
1146        cloud.normals = Some(Vec::with_capacity(vertex_count));
1147    }
1148    if has_colors {
1149        cloud.colors = Some(Vec::with_capacity(vertex_count));
1150    }
1151
1152    for _ in 0..vertex_count {
1153        let line = lines.next()?;
1154        let vals: Vec<&str> = line.split_whitespace().collect();
1155        if vals.len() < 3 {
1156            continue;
1157        }
1158        let x: f64 = vals[0].parse().ok()?;
1159        let y: f64 = vals[1].parse().ok()?;
1160        let z: f64 = vals[2].parse().ok()?;
1161        cloud.positions.push([x, y, z]);
1162
1163        let mut idx = 3;
1164        if has_normals && vals.len() >= idx + 3 {
1165            let nx: f64 = vals[idx].parse().ok()?;
1166            let ny: f64 = vals[idx + 1].parse().ok()?;
1167            let nz: f64 = vals[idx + 2].parse().ok()?;
1168            cloud
1169                .normals
1170                .as_mut()
1171                .expect("value should be Some")
1172                .push([nx, ny, nz]);
1173            idx += 3;
1174        }
1175        if has_colors && vals.len() >= idx + 3 {
1176            let r: u8 = vals[idx].parse().ok()?;
1177            let g: u8 = vals[idx + 1].parse().ok()?;
1178            let b: u8 = vals[idx + 2].parse().ok()?;
1179            cloud
1180                .colors
1181                .as_mut()
1182                .expect("value should be Some")
1183                .push([r, g, b]);
1184        }
1185    }
1186
1187    Some(cloud)
1188}
1189
1190// ---------------------------------------------------------------------------
1191// PCD Format I/O (PCL-compatible)
1192// ---------------------------------------------------------------------------
1193
1194/// Write a point cloud to PCD ASCII format.
1195pub fn write_pcd_ascii(cloud: &PointCloud, writer: &mut dyn Write) -> io::Result<()> {
1196    writeln!(writer, "# .PCD v0.7 - Point Cloud Data")?;
1197    writeln!(writer, "VERSION 0.7")?;
1198    writeln!(writer, "FIELDS x y z")?;
1199    writeln!(writer, "SIZE 4 4 4")?;
1200    writeln!(writer, "TYPE F F F")?;
1201    writeln!(writer, "COUNT 1 1 1")?;
1202    writeln!(writer, "WIDTH {}", cloud.len())?;
1203    writeln!(writer, "HEIGHT 1")?;
1204    writeln!(writer, "VIEWPOINT 0 0 0 1 0 0 0")?;
1205    writeln!(writer, "POINTS {}", cloud.len())?;
1206    writeln!(writer, "DATA ascii")?;
1207
1208    for p in &cloud.positions {
1209        writeln!(writer, "{:.6} {:.6} {:.6}", p[0], p[1], p[2])?;
1210    }
1211    Ok(())
1212}
1213
1214/// Parse a PCD ASCII string into a point cloud.
1215pub fn parse_pcd_ascii(data: &str) -> Option<PointCloud> {
1216    let mut lines = data.lines();
1217    let mut num_points = 0usize;
1218    let mut data_started = false;
1219
1220    let mut cloud = PointCloud::new();
1221
1222    for line in &mut lines {
1223        let line = line.trim();
1224        if line.starts_with("POINTS") {
1225            num_points = line.split_whitespace().last()?.parse().ok()?;
1226            cloud.positions.reserve(num_points);
1227        } else if line.starts_with("DATA") {
1228            data_started = true;
1229            break;
1230        }
1231    }
1232
1233    if !data_started {
1234        return None;
1235    }
1236
1237    for _ in 0..num_points {
1238        let line = lines.next()?;
1239        let vals: Vec<&str> = line.split_whitespace().collect();
1240        if vals.len() < 3 {
1241            continue;
1242        }
1243        let x: f64 = vals[0].parse().ok()?;
1244        let y: f64 = vals[1].parse().ok()?;
1245        let z: f64 = vals[2].parse().ok()?;
1246        cloud.positions.push([x, y, z]);
1247    }
1248
1249    Some(cloud)
1250}
1251
1252// ---------------------------------------------------------------------------
1253// XYZ Format I/O
1254// ---------------------------------------------------------------------------
1255
1256/// Write a point cloud to XYZ format (with optional normals and colors).
1257pub fn write_xyz(cloud: &PointCloud, writer: &mut dyn Write) -> io::Result<()> {
1258    for i in 0..cloud.len() {
1259        let p = cloud.positions[i];
1260        write!(writer, "{:.6} {:.6} {:.6}", p[0], p[1], p[2])?;
1261        if let Some(ref normals) = cloud.normals {
1262            let n = normals[i];
1263            write!(writer, " {:.6} {:.6} {:.6}", n[0], n[1], n[2])?;
1264        }
1265        if let Some(ref colors) = cloud.colors {
1266            let c = colors[i];
1267            write!(writer, " {} {} {}", c[0], c[1], c[2])?;
1268        }
1269        writeln!(writer)?;
1270    }
1271    Ok(())
1272}
1273
1274/// Parse an XYZ file (space-separated x y z \[nx ny nz\] \[r g b\]).
1275pub fn parse_xyz(data: &str) -> PointCloud {
1276    let mut cloud = PointCloud::new();
1277    let mut first_line_fields = 0;
1278
1279    for (line_idx, line) in data.lines().enumerate() {
1280        let line = line.trim();
1281        if line.is_empty() || line.starts_with('#') {
1282            continue;
1283        }
1284        let vals: Vec<&str> = line.split_whitespace().collect();
1285        if vals.len() < 3 {
1286            continue;
1287        }
1288
1289        if line_idx == 0 || first_line_fields == 0 {
1290            first_line_fields = vals.len();
1291            if first_line_fields >= 6 {
1292                cloud.normals = Some(Vec::new());
1293            }
1294            if first_line_fields >= 9 {
1295                cloud.colors = Some(Vec::new());
1296            }
1297        }
1298
1299        let x: f64 = vals[0].parse().unwrap_or(0.0);
1300        let y: f64 = vals[1].parse().unwrap_or(0.0);
1301        let z: f64 = vals[2].parse().unwrap_or(0.0);
1302        cloud.positions.push([x, y, z]);
1303
1304        if vals.len() >= 6
1305            && let Some(ref mut normals) = cloud.normals
1306        {
1307            let nx: f64 = vals[3].parse().unwrap_or(0.0);
1308            let ny: f64 = vals[4].parse().unwrap_or(0.0);
1309            let nz: f64 = vals[5].parse().unwrap_or(0.0);
1310            normals.push([nx, ny, nz]);
1311        }
1312
1313        if vals.len() >= 9
1314            && let Some(ref mut colors) = cloud.colors
1315        {
1316            let r: u8 = vals[6].parse().unwrap_or(0);
1317            let g: u8 = vals[7].parse().unwrap_or(0);
1318            let b: u8 = vals[8].parse().unwrap_or(0);
1319            colors.push([r, g, b]);
1320        }
1321    }
1322    cloud
1323}
1324
1325// ---------------------------------------------------------------------------
1326// LAS Header Parsing (LiDAR)
1327// ---------------------------------------------------------------------------
1328
1329/// LAS file header structure (subset of fields).
1330#[derive(Debug, Clone)]
1331pub struct LasHeader {
1332    /// File signature (should be "LASF").
1333    pub file_signature: String,
1334    /// File source ID.
1335    pub file_source_id: u16,
1336    /// Version major.
1337    pub version_major: u8,
1338    /// Version minor.
1339    pub version_minor: u8,
1340    /// Point data record format.
1341    pub point_format: u8,
1342    /// Point data record length.
1343    pub point_record_length: u16,
1344    /// Number of point records.
1345    pub num_points: u64,
1346    /// Scale factors `[sx, sy, sz]`.
1347    pub scale: [f64; 3],
1348    /// Offset `[ox, oy, oz]`.
1349    pub offset: [f64; 3],
1350    /// Min bounds `[min_x, min_y, min_z]`.
1351    pub min_bounds: [f64; 3],
1352    /// Max bounds `[max_x, max_y, max_z]`.
1353    pub max_bounds: [f64; 3],
1354}
1355
1356impl LasHeader {
1357    /// Create a mock LAS header for testing.
1358    pub fn mock(num_points: u64) -> Self {
1359        Self {
1360            file_signature: "LASF".to_string(),
1361            file_source_id: 0,
1362            version_major: 1,
1363            version_minor: 4,
1364            point_format: 0,
1365            point_record_length: 20,
1366            num_points,
1367            scale: [0.001, 0.001, 0.001],
1368            offset: [0.0, 0.0, 0.0],
1369            min_bounds: [0.0, 0.0, 0.0],
1370            max_bounds: [100.0, 100.0, 50.0],
1371        }
1372    }
1373
1374    /// Parse a LAS header from raw bytes (minimal parsing).
1375    pub fn from_bytes(data: &[u8]) -> Option<Self> {
1376        if data.len() < 227 {
1377            return None;
1378        }
1379        let sig = std::str::from_utf8(&data[0..4]).ok()?;
1380        if sig != "LASF" {
1381            return None;
1382        }
1383        let file_source_id = u16::from_le_bytes([data[4], data[5]]);
1384        let version_major = data[24];
1385        let version_minor = data[25];
1386        let point_format = data[104];
1387        let point_record_length = u16::from_le_bytes([data[105], data[106]]);
1388
1389        // Number of point records (legacy 32-bit field at offset 107)
1390        let num_points_legacy =
1391            u32::from_le_bytes([data[107], data[108], data[109], data[110]]) as u64;
1392
1393        let parse_f64 = |offset: usize| -> f64 {
1394            if offset + 8 > data.len() {
1395                return 0.0;
1396            }
1397            let bytes: [u8; 8] = data[offset..offset + 8].try_into().unwrap_or([0; 8]);
1398            f64::from_le_bytes(bytes)
1399        };
1400
1401        let scale = [parse_f64(131), parse_f64(139), parse_f64(147)];
1402        let offset = [parse_f64(155), parse_f64(163), parse_f64(171)];
1403        let max_x = parse_f64(179);
1404        let min_x = parse_f64(187);
1405        let max_y = parse_f64(195);
1406        let min_y = parse_f64(203);
1407        let max_z = parse_f64(211);
1408        let min_z = parse_f64(219);
1409
1410        Some(Self {
1411            file_signature: sig.to_string(),
1412            file_source_id,
1413            version_major,
1414            version_minor,
1415            point_format,
1416            point_record_length,
1417            num_points: num_points_legacy,
1418            scale,
1419            offset,
1420            min_bounds: [min_x, min_y, min_z],
1421            max_bounds: [max_x, max_y, max_z],
1422        })
1423    }
1424
1425    /// Bounding box volume.
1426    pub fn bounding_volume(&self) -> f64 {
1427        let dx = self.max_bounds[0] - self.min_bounds[0];
1428        let dy = self.max_bounds[1] - self.min_bounds[1];
1429        let dz = self.max_bounds[2] - self.min_bounds[2];
1430        dx.abs() * dy.abs() * dz.abs()
1431    }
1432}
1433
1434// ---------------------------------------------------------------------------
1435// E57 Basic Structure
1436// ---------------------------------------------------------------------------
1437
1438/// E57 scan header (simplified).
1439#[derive(Debug, Clone)]
1440pub struct E57ScanHeader {
1441    /// Scan name/description.
1442    pub name: String,
1443    /// Number of points in the scan.
1444    pub num_points: u64,
1445    /// Whether the scan has color data.
1446    pub has_color: bool,
1447    /// Whether the scan has intensity data.
1448    pub has_intensity: bool,
1449    /// Scanner position `[x, y, z]`.
1450    pub scanner_position: [f64; 3],
1451    /// Scanner orientation (quaternion `[x, y, z, w]`).
1452    pub scanner_orientation: [f64; 4],
1453}
1454
1455impl E57ScanHeader {
1456    /// Create a mock E57 scan header for testing.
1457    pub fn mock(name: &str, num_points: u64) -> Self {
1458        Self {
1459            name: name.to_string(),
1460            num_points,
1461            has_color: true,
1462            has_intensity: true,
1463            scanner_position: [0.0; 3],
1464            scanner_orientation: [0.0, 0.0, 0.0, 1.0],
1465        }
1466    }
1467}
1468
1469/// E57 file structure (simplified).
1470#[derive(Debug, Clone)]
1471pub struct E57File {
1472    /// Major version.
1473    pub version_major: u32,
1474    /// Minor version.
1475    pub version_minor: u32,
1476    /// Scans in the file.
1477    pub scans: Vec<E57ScanHeader>,
1478}
1479
1480impl E57File {
1481    /// Create a mock E57 file for testing.
1482    pub fn mock() -> Self {
1483        Self {
1484            version_major: 1,
1485            version_minor: 0,
1486            scans: vec![E57ScanHeader::mock("scan_001", 1000)],
1487        }
1488    }
1489
1490    /// Total number of points across all scans.
1491    pub fn total_points(&self) -> u64 {
1492        self.scans.iter().map(|s| s.num_points).sum()
1493    }
1494
1495    /// Number of scans.
1496    pub fn num_scans(&self) -> usize {
1497        self.scans.len()
1498    }
1499}
1500
1501// ---------------------------------------------------------------------------
1502// Ball Pivoting (Simplified Concept)
1503// ---------------------------------------------------------------------------
1504
1505/// A triangle in a mesh (indices into a point cloud).
1506#[derive(Debug, Clone, Copy)]
1507pub struct Triangle {
1508    /// Vertex indices.
1509    pub indices: [usize; 3],
1510}
1511
1512/// Simplified ball-pivoting mesh generation.
1513///
1514/// Given a point cloud with normals, attempts to create a triangle mesh by
1515/// pivoting a ball of radius `ball_radius` over the point cloud surface.
1516///
1517/// This is a conceptual implementation suitable for small point clouds.
1518pub fn ball_pivoting_mesh(cloud: &PointCloud, ball_radius: f64) -> Vec<Triangle> {
1519    if cloud.len() < 3 {
1520        return Vec::new();
1521    }
1522
1523    let tree = KdTree::build(cloud);
1524    let mut triangles = Vec::new();
1525    let mut used = vec![false; cloud.len()];
1526    let search_radius_sq = (2.0 * ball_radius) * (2.0 * ball_radius);
1527
1528    // Seed triangle: find 3 nearest points that form a valid triangle
1529    let neighbors = tree.k_nearest(cloud.positions[0], 20.min(cloud.len()));
1530    let mut found_seed = false;
1531    let mut seed = [0usize; 3];
1532
1533    'outer: for i in 0..neighbors.len() {
1534        for j in (i + 1)..neighbors.len() {
1535            for k in (j + 1)..neighbors.len() {
1536                let a = neighbors[i].0;
1537                let b = neighbors[j].0;
1538                let c = neighbors[k].0;
1539                let ab = dist(cloud.positions[a], cloud.positions[b]);
1540                let bc = dist(cloud.positions[b], cloud.positions[c]);
1541                let ca = dist(cloud.positions[c], cloud.positions[a]);
1542                if ab < 2.0 * ball_radius && bc < 2.0 * ball_radius && ca < 2.0 * ball_radius {
1543                    seed = [a, b, c];
1544                    found_seed = true;
1545                    break 'outer;
1546                }
1547            }
1548        }
1549    }
1550
1551    if !found_seed {
1552        return triangles;
1553    }
1554
1555    triangles.push(Triangle { indices: seed });
1556    used[seed[0]] = true;
1557    used[seed[1]] = true;
1558    used[seed[2]] = true;
1559
1560    // Simple expansion: for each edge of existing triangles, try to find a
1561    // third point that forms a valid triangle
1562    let max_triangles = cloud.len().min(5000);
1563    let mut edge_queue: Vec<(usize, usize)> =
1564        vec![(seed[0], seed[1]), (seed[1], seed[2]), (seed[2], seed[0])];
1565
1566    while let Some((a, b)) = edge_queue.pop() {
1567        if triangles.len() >= max_triangles {
1568            break;
1569        }
1570        let mid = [
1571            0.5 * (cloud.positions[a][0] + cloud.positions[b][0]),
1572            0.5 * (cloud.positions[a][1] + cloud.positions[b][1]),
1573            0.5 * (cloud.positions[a][2] + cloud.positions[b][2]),
1574        ];
1575        let nearby = tree.radius_search(mid, search_radius_sq);
1576        for (c, _) in nearby {
1577            if c == a || c == b || used[c] {
1578                continue;
1579            }
1580            let ac = dist(cloud.positions[a], cloud.positions[c]);
1581            let bc = dist(cloud.positions[b], cloud.positions[c]);
1582            if ac < 2.0 * ball_radius && bc < 2.0 * ball_radius {
1583                triangles.push(Triangle { indices: [a, b, c] });
1584                used[c] = true;
1585                edge_queue.push((a, c));
1586                edge_queue.push((c, b));
1587                break;
1588            }
1589        }
1590    }
1591
1592    triangles
1593}
1594
1595// ===========================================================================
1596// Tests
1597// ===========================================================================
1598
1599#[cfg(test)]
1600mod tests {
1601    use super::*;
1602
1603    fn make_cube_cloud(n_per_face: usize) -> PointCloud {
1604        let mut cloud = PointCloud::new();
1605        let step = 1.0 / n_per_face as f64;
1606        // Generate points on 6 faces of a unit cube
1607        for i in 0..n_per_face {
1608            for j in 0..n_per_face {
1609                let u = i as f64 * step;
1610                let v = j as f64 * step;
1611                cloud.positions.push([u, v, 0.0]); // bottom
1612                cloud.positions.push([u, v, 1.0]); // top
1613                cloud.positions.push([0.0, u, v]); // left
1614                cloud.positions.push([1.0, u, v]); // right
1615                cloud.positions.push([u, 0.0, v]); // front
1616                cloud.positions.push([u, 1.0, v]); // back
1617            }
1618        }
1619        cloud
1620    }
1621
1622    fn make_simple_cloud() -> PointCloud {
1623        PointCloud::from_positions(vec![
1624            [0.0, 0.0, 0.0],
1625            [1.0, 0.0, 0.0],
1626            [0.0, 1.0, 0.0],
1627            [0.0, 0.0, 1.0],
1628            [1.0, 1.0, 0.0],
1629            [1.0, 0.0, 1.0],
1630            [0.0, 1.0, 1.0],
1631            [1.0, 1.0, 1.0],
1632        ])
1633    }
1634
1635    #[test]
1636    fn test_point_cloud_new() {
1637        let cloud = PointCloud::new();
1638        assert!(cloud.is_empty());
1639        assert_eq!(cloud.len(), 0);
1640    }
1641
1642    #[test]
1643    fn test_point_cloud_from_positions() {
1644        let cloud = make_simple_cloud();
1645        assert_eq!(cloud.len(), 8);
1646        assert!(!cloud.is_empty());
1647        assert!(!cloud.has_normals());
1648        assert!(!cloud.has_colors());
1649    }
1650
1651    #[test]
1652    fn test_point_cloud_add_point() {
1653        let mut cloud = PointCloud::new();
1654        cloud.add_point([1.0, 2.0, 3.0]);
1655        assert_eq!(cloud.len(), 1);
1656        assert!((cloud.positions[0][0] - 1.0).abs() < 1e-10);
1657    }
1658
1659    #[test]
1660    fn test_point_cloud_get_point() {
1661        let cloud = make_simple_cloud();
1662        let pt = cloud.get_point(0).unwrap();
1663        assert!((pt.position[0]).abs() < 1e-10);
1664        assert!(cloud.get_point(100).is_none());
1665    }
1666
1667    #[test]
1668    fn test_bounding_box_basic() {
1669        let cloud = make_simple_cloud();
1670        let bb = BoundingBox::from_point_cloud(&cloud).unwrap();
1671        assert!((bb.min[0]).abs() < 1e-10);
1672        assert!((bb.max[0] - 1.0).abs() < 1e-10);
1673        assert!((bb.max[2] - 1.0).abs() < 1e-10);
1674    }
1675
1676    #[test]
1677    fn test_bounding_box_center() {
1678        let cloud = make_simple_cloud();
1679        let bb = BoundingBox::from_point_cloud(&cloud).unwrap();
1680        let c = bb.center();
1681        assert!((c[0] - 0.5).abs() < 1e-10);
1682        assert!((c[1] - 0.5).abs() < 1e-10);
1683        assert!((c[2] - 0.5).abs() < 1e-10);
1684    }
1685
1686    #[test]
1687    fn test_bounding_box_volume() {
1688        let cloud = make_simple_cloud();
1689        let bb = BoundingBox::from_point_cloud(&cloud).unwrap();
1690        assert!((bb.volume() - 1.0).abs() < 1e-10);
1691    }
1692
1693    #[test]
1694    fn test_bounding_box_diagonal() {
1695        let cloud = make_simple_cloud();
1696        let bb = BoundingBox::from_point_cloud(&cloud).unwrap();
1697        let expected = (3.0_f64).sqrt();
1698        assert!((bb.diagonal() - expected).abs() < 1e-10);
1699    }
1700
1701    #[test]
1702    fn test_bounding_box_contains() {
1703        let cloud = make_simple_cloud();
1704        let bb = BoundingBox::from_point_cloud(&cloud).unwrap();
1705        assert!(bb.contains([0.5, 0.5, 0.5]));
1706        assert!(!bb.contains([2.0, 0.0, 0.0]));
1707    }
1708
1709    #[test]
1710    fn test_bounding_box_merge() {
1711        let bb1 = BoundingBox {
1712            min: [0.0, 0.0, 0.0],
1713            max: [1.0, 1.0, 1.0],
1714        };
1715        let bb2 = BoundingBox {
1716            min: [-1.0, -1.0, -1.0],
1717            max: [0.5, 0.5, 0.5],
1718        };
1719        let merged = bb1.merge(&bb2);
1720        assert!((merged.min[0] + 1.0).abs() < 1e-10);
1721        assert!((merged.max[0] - 1.0).abs() < 1e-10);
1722    }
1723
1724    #[test]
1725    fn test_kdtree_nearest() {
1726        let cloud = make_simple_cloud();
1727        let tree = KdTree::build(&cloud);
1728        let (idx, d_sq) = tree.nearest([0.1, 0.1, 0.1]).unwrap();
1729        assert_eq!(idx, 0); // nearest to origin
1730        assert!(d_sq < 0.1);
1731    }
1732
1733    #[test]
1734    fn test_kdtree_k_nearest() {
1735        let cloud = make_simple_cloud();
1736        let tree = KdTree::build(&cloud);
1737        let results = tree.k_nearest([0.0, 0.0, 0.0], 3);
1738        assert_eq!(results.len(), 3);
1739        // First should be the origin itself
1740        assert_eq!(results[0].0, 0);
1741        assert!(results[0].1 < 1e-10);
1742    }
1743
1744    #[test]
1745    fn test_kdtree_radius_search() {
1746        let cloud = make_simple_cloud();
1747        let tree = KdTree::build(&cloud);
1748        let results = tree.radius_search([0.0, 0.0, 0.0], 1.01); // radius^2 ~1
1749        // Should find the origin and 3 neighbors at distance 1
1750        assert!(!results.is_empty());
1751        assert!(results.len() <= 4);
1752    }
1753
1754    #[test]
1755    fn test_kdtree_empty() {
1756        let cloud = PointCloud::new();
1757        let tree = KdTree::build(&cloud);
1758        assert!(tree.is_empty());
1759        assert!(tree.nearest([0.0, 0.0, 0.0]).is_none());
1760    }
1761
1762    #[test]
1763    fn test_voxel_grid_downsample() {
1764        let cloud = make_cube_cloud(10); // 600 points
1765        assert!(cloud.len() > 100);
1766        let downsampled = voxel_grid_downsample(&cloud, 0.5);
1767        assert!(downsampled.len() < cloud.len());
1768        assert!(!downsampled.is_empty());
1769    }
1770
1771    #[test]
1772    fn test_voxel_grid_preserves_bounds() {
1773        let cloud = make_simple_cloud();
1774        let bb_orig = BoundingBox::from_point_cloud(&cloud).unwrap();
1775        let downsampled = voxel_grid_downsample(&cloud, 0.5);
1776        let bb_down = BoundingBox::from_point_cloud(&downsampled).unwrap();
1777        // Downsampled bounds should be within original bounds (with some tolerance)
1778        assert!(bb_down.min[0] >= bb_orig.min[0] - 0.5);
1779        assert!(bb_down.max[0] <= bb_orig.max[0] + 0.5);
1780    }
1781
1782    #[test]
1783    fn test_statistical_outlier_removal() {
1784        let mut cloud = make_simple_cloud();
1785        // Add an outlier far away
1786        cloud.positions.push([100.0, 100.0, 100.0]);
1787        let filtered = statistical_outlier_removal(&cloud, 3, 1.0);
1788        // The outlier should be removed
1789        assert!(filtered.len() <= cloud.len());
1790        // Check that the outlier is gone
1791        for p in &filtered.positions {
1792            assert!(p[0] < 50.0, "Outlier not removed: {p:?}");
1793        }
1794    }
1795
1796    #[test]
1797    fn test_estimate_normals() {
1798        // Plane z=0
1799        let mut cloud = PointCloud::new();
1800        for i in 0..10 {
1801            for j in 0..10 {
1802                cloud.positions.push([i as f64, j as f64, 0.0]);
1803            }
1804        }
1805        let normals = estimate_normals(&cloud, 6);
1806        assert_eq!(normals.len(), 100);
1807        // Most normals should point roughly along z-axis
1808        let mut z_aligned = 0;
1809        for n in &normals {
1810            if n[2].abs() > 0.5 {
1811                z_aligned += 1;
1812            }
1813        }
1814        assert!(z_aligned > 50, "Only {z_aligned}/100 normals z-aligned");
1815    }
1816
1817    #[test]
1818    fn test_compute_centroid() {
1819        let cloud = make_simple_cloud();
1820        let c = compute_centroid(&cloud);
1821        assert!((c[0] - 0.5).abs() < 1e-10);
1822        assert!((c[1] - 0.5).abs() < 1e-10);
1823        assert!((c[2] - 0.5).abs() < 1e-10);
1824    }
1825
1826    #[test]
1827    fn test_merge_point_clouds() {
1828        let a = PointCloud::from_positions(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]);
1829        let b = PointCloud::from_positions(vec![[2.0, 0.0, 0.0], [3.0, 0.0, 0.0]]);
1830        let merged = merge_point_clouds(&a, &b);
1831        assert_eq!(merged.len(), 4);
1832        assert!((merged.positions[2][0] - 2.0).abs() < 1e-10);
1833    }
1834
1835    #[test]
1836    fn test_transform_point_cloud() {
1837        let cloud = PointCloud::from_positions(vec![[1.0, 0.0, 0.0]]);
1838        // 90-degree rotation around z-axis
1839        let rot = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]];
1840        let trans = [0.0, 0.0, 0.0];
1841        let result = transform_point_cloud(&cloud, rot, trans);
1842        assert!((result.positions[0][0]).abs() < 1e-10);
1843        assert!((result.positions[0][1] - 1.0).abs() < 1e-10);
1844    }
1845
1846    #[test]
1847    fn test_scale_point_cloud() {
1848        let cloud = PointCloud::from_positions(vec![[1.0, 2.0, 3.0]]);
1849        let scaled = scale_point_cloud(&cloud, 2.0);
1850        assert!((scaled.positions[0][0] - 2.0).abs() < 1e-10);
1851        assert!((scaled.positions[0][1] - 4.0).abs() < 1e-10);
1852    }
1853
1854    #[test]
1855    fn test_translate_point_cloud() {
1856        let cloud = PointCloud::from_positions(vec![[1.0, 2.0, 3.0]]);
1857        let translated = translate_point_cloud(&cloud, [10.0, 20.0, 30.0]);
1858        assert!((translated.positions[0][0] - 11.0).abs() < 1e-10);
1859    }
1860
1861    #[test]
1862    fn test_center_point_cloud() {
1863        let cloud = PointCloud::from_positions(vec![[2.0, 4.0, 6.0], [4.0, 6.0, 8.0]]);
1864        let centered = center_point_cloud(&cloud);
1865        let c = compute_centroid(&centered);
1866        assert!(c[0].abs() < 1e-10);
1867        assert!(c[1].abs() < 1e-10);
1868        assert!(c[2].abs() < 1e-10);
1869    }
1870
1871    #[test]
1872    fn test_write_parse_ply_ascii() {
1873        let mut cloud = PointCloud::from_positions(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]);
1874        cloud.normals = Some(vec![[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]]);
1875
1876        let mut buf = Vec::new();
1877        write_ply_ascii(&cloud, &mut buf).unwrap();
1878        let text = String::from_utf8(buf).unwrap();
1879
1880        let parsed = parse_ply_ascii(&text).unwrap();
1881        assert_eq!(parsed.len(), 2);
1882        assert!((parsed.positions[0][0] - 1.0).abs() < 0.001);
1883        assert!(parsed.has_normals());
1884    }
1885
1886    #[test]
1887    fn test_write_ply_binary_le() {
1888        let cloud = PointCloud::from_positions(vec![[1.0, 2.0, 3.0]]);
1889        let buf = write_ply_binary_le(&cloud);
1890        // Check header starts with "ply"
1891        assert!(buf.starts_with(b"ply"));
1892        assert!(buf.len() > 12); // header + 3 floats
1893    }
1894
1895    #[test]
1896    fn test_write_parse_pcd_ascii() {
1897        let cloud =
1898            PointCloud::from_positions(vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]);
1899        let mut buf = Vec::new();
1900        write_pcd_ascii(&cloud, &mut buf).unwrap();
1901        let text = String::from_utf8(buf).unwrap();
1902
1903        let parsed = parse_pcd_ascii(&text).unwrap();
1904        assert_eq!(parsed.len(), 3);
1905        assert!((parsed.positions[1][0] - 4.0).abs() < 0.001);
1906    }
1907
1908    #[test]
1909    fn test_write_parse_xyz() {
1910        let mut cloud = PointCloud::from_positions(vec![[1.0, 2.0, 3.0]]);
1911        cloud.normals = Some(vec![[0.0, 0.0, 1.0]]);
1912        cloud.colors = Some(vec![[255, 128, 0]]);
1913
1914        let mut buf = Vec::new();
1915        write_xyz(&cloud, &mut buf).unwrap();
1916        let text = String::from_utf8(buf).unwrap();
1917
1918        let parsed = parse_xyz(&text);
1919        assert_eq!(parsed.len(), 1);
1920        assert!((parsed.positions[0][0] - 1.0).abs() < 0.001);
1921    }
1922
1923    #[test]
1924    fn test_parse_xyz_basic() {
1925        let data = "1.0 2.0 3.0\n4.0 5.0 6.0\n";
1926        let cloud = parse_xyz(data);
1927        assert_eq!(cloud.len(), 2);
1928        assert!(!cloud.has_normals());
1929    }
1930
1931    #[test]
1932    fn test_las_header_mock() {
1933        let header = LasHeader::mock(50000);
1934        assert_eq!(header.num_points, 50000);
1935        assert_eq!(header.file_signature, "LASF");
1936        assert!(header.bounding_volume() > 0.0);
1937    }
1938
1939    #[test]
1940    fn test_e57_structure() {
1941        let e57 = E57File::mock();
1942        assert_eq!(e57.num_scans(), 1);
1943        assert_eq!(e57.total_points(), 1000);
1944    }
1945
1946    #[test]
1947    fn test_ball_pivoting_mesh() {
1948        // Small planar cloud
1949        let mut cloud = PointCloud::new();
1950        for i in 0..5 {
1951            for j in 0..5 {
1952                cloud.positions.push([i as f64 * 0.5, j as f64 * 0.5, 0.0]);
1953            }
1954        }
1955        let triangles = ball_pivoting_mesh(&cloud, 0.6);
1956        assert!(!triangles.is_empty(), "Should produce some triangles");
1957        for tri in &triangles {
1958            assert!(tri.indices[0] < cloud.len());
1959            assert!(tri.indices[1] < cloud.len());
1960            assert!(tri.indices[2] < cloud.len());
1961        }
1962    }
1963
1964    #[test]
1965    fn test_dist_sq_and_dist() {
1966        let a = [1.0, 0.0, 0.0];
1967        let b = [4.0, 0.0, 0.0];
1968        assert!((dist_sq(a, b) - 9.0).abs() < 1e-10);
1969        assert!((dist(a, b) - 3.0).abs() < 1e-10);
1970    }
1971
1972    #[test]
1973    fn test_normalize3() {
1974        let v = [3.0, 4.0, 0.0];
1975        let n = normalize3(v);
1976        assert!((n[0] - 0.6).abs() < 1e-10);
1977        assert!((n[1] - 0.8).abs() < 1e-10);
1978
1979        let z = normalize3([0.0, 0.0, 0.0]);
1980        assert!(z[0].abs() < 1e-10);
1981    }
1982
1983    #[test]
1984    fn test_cross3() {
1985        let a = [1.0, 0.0, 0.0];
1986        let b = [0.0, 1.0, 0.0];
1987        let c = cross3(a, b);
1988        assert!((c[2] - 1.0).abs() < 1e-10);
1989    }
1990
1991    #[test]
1992    fn test_kdtree_large() {
1993        let mut cloud = PointCloud::new();
1994        for i in 0..100 {
1995            cloud
1996                .positions
1997                .push([(i % 10) as f64, (i / 10) as f64, 0.0]);
1998        }
1999        let tree = KdTree::build(&cloud);
2000        assert_eq!(tree.len(), 100);
2001
2002        let (idx, _d) = tree.nearest([4.5, 4.5, 0.0]).unwrap();
2003        // Should be one of the points near (4.5, 4.5)
2004        let p = cloud.positions[idx];
2005        assert!((p[0] - 4.5).abs() <= 1.0);
2006        assert!((p[1] - 4.5).abs() <= 1.0);
2007    }
2008
2009    #[test]
2010    fn test_point_cloud_reserve() {
2011        let mut cloud = PointCloud::new();
2012        cloud.reserve(100);
2013        assert!(cloud.is_empty());
2014        cloud.add_point([1.0, 2.0, 3.0]);
2015        assert_eq!(cloud.len(), 1);
2016    }
2017
2018    #[test]
2019    fn test_bounding_box_empty() {
2020        let cloud = PointCloud::new();
2021        assert!(BoundingBox::from_point_cloud(&cloud).is_none());
2022    }
2023}