Skip to main content

threecrate_algorithms/
normals.rs

1//! Normal estimation algorithms
2
3use crate::nearest_neighbor::KdTree;
4use nalgebra::Matrix3;
5use rayon::prelude::*;
6use threecrate_core::{
7    Error, NearestNeighborSearch, NormalPoint3f, Point3f, PointCloud, Result, Vector3f,
8};
9
10#[cfg(test)]
11use std::cmp::Ordering;
12#[cfg(test)]
13use std::collections::BinaryHeap;
14
15/// Configuration for normal estimation
16#[derive(Debug, Clone)]
17pub struct NormalEstimationConfig {
18    /// Number of nearest neighbors to use (k-NN)
19    pub k_neighbors: usize,
20    /// Optional radius for radius-based neighbor search
21    pub radius: Option<f32>,
22    /// Whether to enforce orientation consistency
23    pub consistent_orientation: bool,
24    /// Viewpoint for orientation consistency (if None, uses positive Z direction)
25    pub viewpoint: Option<Point3f>,
26}
27
28impl Default for NormalEstimationConfig {
29    fn default() -> Self {
30        Self {
31            k_neighbors: 10,
32            radius: None,
33            consistent_orientation: true,
34            viewpoint: None,
35        }
36    }
37}
38
39/// A simple distance-based neighbor for priority queue
40#[derive(Debug, Clone)]
41#[cfg(test)]
42struct Neighbor {
43    index: usize,
44    distance: f32,
45}
46
47#[cfg(test)]
48impl PartialEq for Neighbor {
49    fn eq(&self, other: &Self) -> bool {
50        self.distance == other.distance
51    }
52}
53
54#[cfg(test)]
55impl Eq for Neighbor {}
56
57#[cfg(test)]
58impl PartialOrd for Neighbor {
59    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
60        Some(self.cmp(other))
61    }
62}
63
64#[cfg(test)]
65impl Ord for Neighbor {
66    fn cmp(&self, other: &Self) -> Ordering {
67        // Reverse ordering for min-heap behavior
68        other
69            .distance
70            .partial_cmp(&self.distance)
71            .unwrap_or(Ordering::Equal)
72    }
73}
74
75/// Find k-nearest neighbors using brute force search
76#[cfg(test)]
77fn find_k_nearest_neighbors(points: &[Point3f], query_idx: usize, k: usize) -> Vec<usize> {
78    let query = &points[query_idx];
79    let mut heap = BinaryHeap::with_capacity(k + 1);
80
81    for (i, point) in points.iter().enumerate() {
82        if i == query_idx {
83            continue; // Skip the query point itself
84        }
85
86        let distance = (point - query).magnitude_squared();
87        let neighbor = Neighbor { index: i, distance };
88
89        if heap.len() < k {
90            heap.push(neighbor);
91        } else if let Some(farthest) = heap.peek() {
92            if neighbor.distance < farthest.distance {
93                heap.pop();
94                heap.push(neighbor);
95            }
96        }
97    }
98
99    heap.into_iter().map(|n| n.index).collect()
100}
101
102/// Find neighbors within a radius
103#[cfg(test)]
104fn find_radius_neighbors(points: &[Point3f], query_idx: usize, radius: f32) -> Vec<usize> {
105    let query = &points[query_idx];
106    let radius_squared = radius * radius;
107
108    points
109        .iter()
110        .enumerate()
111        .filter(|(i, point)| {
112            *i != query_idx && (**point - query).magnitude_squared() <= radius_squared
113        })
114        .map(|(i, _)| i)
115        .collect()
116}
117
118/// Find neighbors using either k-NN or radius-based search
119#[cfg(test)]
120fn find_neighbors(
121    points: &[Point3f],
122    query_idx: usize,
123    config: &NormalEstimationConfig,
124) -> Vec<usize> {
125    if let Some(radius) = config.radius {
126        // Use radius-based search
127        find_radius_neighbors(points, query_idx, radius)
128    } else {
129        // Use k-NN search
130        find_k_nearest_neighbors(points, query_idx, config.k_neighbors)
131    }
132}
133
134/// Find neighbors using a prebuilt KD-tree.
135/// Fill `neighborhood` with the `k` nearest neighbors of point `index`, not
136/// counting the point itself. `knn` is scratch space, reused across calls so the
137/// search does not allocate per point.
138fn nearest_excluding(
139    tree: &KdTree,
140    index: usize,
141    query: &Point3f,
142    k: usize,
143    knn: &mut Vec<(usize, f32)>,
144    neighborhood: &mut Vec<usize>,
145) {
146    tree.find_k_nearest_into(query, k + 1, knn);
147    neighborhood.clear();
148    neighborhood.extend(
149        knn.iter()
150            .map(|&(idx, _)| idx)
151            .filter(|&idx| idx != index)
152            .take(k),
153    );
154}
155
156/// Compute normal using PCA on the neighborhood points
157fn compute_normal_pca(points: &[Point3f], indices: &[usize]) -> Vector3f {
158    if indices.len() < 3 {
159        // Default normal if not enough points
160        return Vector3f::new(0.0, 0.0, 1.0);
161    }
162
163    // Compute centroid
164    let mut centroid = Point3f::origin();
165    for &idx in indices {
166        centroid += points[idx].coords;
167    }
168    centroid /= indices.len() as f32;
169
170    // Build covariance matrix
171    let mut covariance = Matrix3::zeros();
172    for &idx in indices {
173        let diff = points[idx] - centroid;
174        covariance += diff * diff.transpose();
175    }
176    covariance /= indices.len() as f32;
177
178    // Find eigenvector corresponding to smallest eigenvalue
179    // This is the normal direction
180    let eigen = covariance.symmetric_eigen();
181    let eigenvalues = eigen.eigenvalues;
182    let eigenvectors = eigen.eigenvectors;
183
184    // Find index of smallest eigenvalue
185    let mut min_idx = 0;
186    for i in 1..3 {
187        if eigenvalues[i] < eigenvalues[min_idx] {
188            min_idx = i;
189        }
190    }
191
192    // Return the corresponding eigenvector as normal
193    let mut normal: Vector3f = eigenvectors.column(min_idx).into();
194
195    // Ensure the normal is normalized
196    let magnitude = normal.magnitude();
197    if magnitude > 1e-6 {
198        normal /= magnitude;
199    } else {
200        normal = Vector3f::new(0.0, 0.0, 1.0);
201    }
202
203    normal
204}
205
206/// Orient normal towards viewpoint for consistency
207fn orient_normal_towards_viewpoint(
208    normal: Vector3f,
209    point: Point3f,
210    viewpoint: Point3f,
211) -> Vector3f {
212    let to_viewpoint = (viewpoint - point).normalize();
213    let dot_product = normal.dot(&to_viewpoint);
214
215    // If the angle between normal and viewpoint direction is > 90 degrees, flip the normal
216    if dot_product < 0.0 {
217        -normal
218    } else {
219        normal
220    }
221}
222
223/// Estimate normals for a point cloud using k-nearest neighbors
224///
225/// This function computes surface normals for each point in the cloud by:
226/// 1. Finding k nearest neighbors for each point (or neighbors within radius)
227/// 2. Computing the normal using Principal Component Analysis (PCA)
228/// 3. The normal is the eigenvector corresponding to the smallest eigenvalue
229/// 4. Optionally enforcing orientation consistency towards a viewpoint
230///
231/// # Arguments
232/// * `cloud` - Reference to the point cloud
233/// * `k` - Number of nearest neighbors to use (typically 10-30)
234///
235/// # Returns
236/// * `Result<PointCloud<NormalPoint3f>>` - A new point cloud with normals
237pub fn estimate_normals(
238    cloud: &PointCloud<Point3f>,
239    k: usize,
240) -> Result<PointCloud<NormalPoint3f>> {
241    let config = NormalEstimationConfig {
242        k_neighbors: k,
243        ..Default::default()
244    };
245    estimate_normals_with_config(cloud, &config)
246}
247
248/// Estimate normals with advanced configuration
249///
250/// # Arguments
251/// * `cloud` - Reference to the point cloud
252/// * `config` - Configuration for normal estimation
253///
254/// # Returns
255/// * `Result<PointCloud<NormalPoint3f>>` - A new point cloud with normals
256pub fn estimate_normals_with_config(
257    cloud: &PointCloud<Point3f>,
258    config: &NormalEstimationConfig,
259) -> Result<PointCloud<NormalPoint3f>> {
260    if cloud.is_empty() {
261        return Ok(PointCloud::new());
262    }
263
264    if config.k_neighbors < 3 {
265        return Err(Error::InvalidData(
266            "k_neighbors must be at least 3".to_string(),
267        ));
268    }
269
270    let points = &cloud.points;
271    let search_tree = KdTree::new(points)?;
272
273    // Determine viewpoint for orientation consistency
274    let viewpoint = config.viewpoint.unwrap_or_else(|| {
275        // Default viewpoint: compute a good viewpoint based on the point cloud bounds
276        let mut min_x = points[0].x;
277        let mut min_y = points[0].y;
278        let mut min_z = points[0].z;
279        let mut max_x = points[0].x;
280        let mut max_y = points[0].y;
281        let mut max_z = points[0].z;
282
283        for point in points {
284            min_x = min_x.min(point.x);
285            min_y = min_y.min(point.y);
286            min_z = min_z.min(point.z);
287            max_x = max_x.max(point.x);
288            max_y = max_y.max(point.y);
289            max_z = max_z.max(point.z);
290        }
291
292        let center = Point3f::new(
293            (min_x + max_x) / 2.0,
294            (min_y + max_y) / 2.0,
295            (min_z + max_z) / 2.0,
296        );
297        let extent =
298            ((max_x - min_x).powi(2) + (max_y - min_y).powi(2) + (max_z - min_z).powi(2)).sqrt();
299
300        // Viewpoint is above the center of the point cloud
301        center + Vector3f::new(0.0, 0.0, extent)
302    });
303
304    // Compute normals in parallel. Each rayon thread keeps its own scratch
305    // buffers, so the per-point neighbor search does not allocate.
306    let normals: Vec<NormalPoint3f> = (0..points.len())
307        .into_par_iter()
308        .map_init(
309            || (Vec::new(), Vec::new()),
310            |(knn, neighborhood): &mut (Vec<(usize, f32)>, Vec<usize>), i| {
311                neighborhood.clear();
312                if let Some(radius) = config.radius {
313                    neighborhood.extend(
314                        search_tree
315                            .find_radius_neighbors(&points[i], radius)
316                            .into_iter()
317                            .map(|(idx, _)| idx)
318                            .filter(|&idx| idx != i),
319                    );
320                }
321
322                // k-NN: the default, and the fallback when the radius finds too few
323                if config.radius.is_none() || neighborhood.len() < config.k_neighbors {
324                    nearest_excluding(
325                        &search_tree,
326                        i,
327                        &points[i],
328                        config.k_neighbors,
329                        knn,
330                        neighborhood,
331                    );
332                }
333
334                // Ensure we have enough neighbors for PCA
335                if neighborhood.len() < 3 {
336                    let fallback_k = config.k_neighbors.max(5);
337                    nearest_excluding(&search_tree, i, &points[i], fallback_k, knn, neighborhood);
338                }
339
340                // Include the point itself in its neighborhood
341                neighborhood.push(i);
342
343                let mut normal = compute_normal_pca(points, neighborhood);
344
345                // Apply orientation consistency if requested
346                if config.consistent_orientation {
347                    normal = orient_normal_towards_viewpoint(normal, points[i], viewpoint);
348                }
349
350                NormalPoint3f {
351                    position: points[i],
352                    normal,
353                }
354            },
355        )
356        .collect();
357
358    Ok(PointCloud::from_points(normals))
359}
360
361/// Estimate normals using radius-based neighbor search
362///
363/// # Arguments
364/// * `cloud` - Reference to the point cloud
365/// * `radius` - Search radius for neighbors
366/// * `consistent_orientation` - Whether to enforce orientation consistency
367///
368/// # Returns
369/// * `Result<PointCloud<NormalPoint3f>>` - A new point cloud with normals
370pub fn estimate_normals_radius(
371    cloud: &PointCloud<Point3f>,
372    radius: f32,
373    consistent_orientation: bool,
374) -> Result<PointCloud<NormalPoint3f>> {
375    let config = NormalEstimationConfig {
376        k_neighbors: 10, // Fallback value
377        radius: Some(radius),
378        consistent_orientation,
379        viewpoint: None,
380    };
381    estimate_normals_with_config(cloud, &config)
382}
383
384/// Estimate normals and modify the input cloud in-place (legacy API)
385/// This function is deprecated in favor of the version that returns a new cloud
386#[deprecated(note = "Use estimate_normals instead which returns a new point cloud")]
387pub fn estimate_normals_inplace(_cloud: &mut PointCloud<Point3f>, k: usize) -> Result<()> {
388    // This would require converting the point cloud type, which isn't straightforward
389    // with the current type system. The new API is cleaner.
390    let _ = k;
391    Err(Error::Unsupported(
392        "Use estimate_normals instead".to_string(),
393    ))
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_estimate_normals_simple() {
402        // Create a simple planar point cloud (XY plane)
403        let mut cloud = PointCloud::new();
404        cloud.push(Point3f::new(0.0, 0.0, 0.0));
405        cloud.push(Point3f::new(1.0, 0.0, 0.0));
406        cloud.push(Point3f::new(0.0, 1.0, 0.0));
407        cloud.push(Point3f::new(1.0, 1.0, 0.0));
408        cloud.push(Point3f::new(0.5, 0.5, 0.0));
409
410        let result = estimate_normals(&cloud, 3).unwrap();
411
412        assert_eq!(result.len(), 5);
413
414        // For a planar surface in XY plane, normals should point along Z axis
415        for point in result.iter() {
416            let normal = point.normal;
417            // Normal should be close to (0, 0, 1) or (0, 0, -1)
418            assert!(
419                normal.z.abs() > 0.8,
420                "Normal should be primarily in Z direction: {:?}",
421                normal
422            );
423        }
424    }
425
426    #[test]
427    fn test_estimate_normals_empty() {
428        let cloud = PointCloud::<Point3f>::new();
429        let result = estimate_normals(&cloud, 5).unwrap();
430        assert!(result.is_empty());
431    }
432
433    #[test]
434    fn test_estimate_normals_insufficient_k() {
435        let mut cloud = PointCloud::new();
436        cloud.push(Point3f::new(0.0, 0.0, 0.0));
437
438        let result = estimate_normals(&cloud, 2);
439        assert!(result.is_err());
440    }
441
442    #[test]
443    fn test_estimate_normals_radius() {
444        // Create a simple planar point cloud for testing radius-based search
445        let mut cloud = PointCloud::new();
446        for i in 0..20 {
447            for j in 0..20 {
448                let x = (i as f32) * 0.1;
449                let y = (j as f32) * 0.1;
450                let z = 0.0;
451                cloud.push(Point3f::new(x, y, z));
452            }
453        }
454
455        let result = estimate_normals_radius(&cloud, 0.2, true).unwrap();
456        assert_eq!(result.len(), 400);
457
458        // Check that normals are computed and have reasonable values
459        let mut z_direction_count = 0;
460        for point in result.iter() {
461            let normal_magnitude = point.normal.magnitude();
462            // Normals should be unit vectors
463            assert!(
464                (normal_magnitude - 1.0).abs() < 0.1,
465                "Normal should be unit vector: magnitude={}",
466                normal_magnitude
467            );
468
469            // For a planar surface, normals should be primarily in Z direction
470            if point.normal.z.abs() > 0.8 {
471                z_direction_count += 1;
472            }
473        }
474
475        // At least 80% of normals should be in Z direction for a planar surface
476        let percentage = (z_direction_count as f32 / result.len() as f32) * 100.0;
477        assert!(
478            percentage > 80.0,
479            "Only {:.1}% of normals are in Z direction",
480            percentage
481        );
482    }
483
484    #[test]
485    fn test_estimate_normals_cylinder() {
486        // Create a true cylindrical point cloud (points on a cylinder surface)
487        let mut cloud = PointCloud::new();
488        for i in 0..10 {
489            for j in 0..10 {
490                let angle = (i as f32) * 0.6;
491                let height = (j as f32) * 0.2 - 1.0;
492                let x = angle.cos();
493                let y = angle.sin();
494                let z = height;
495                cloud.push(Point3f::new(x, y, z));
496            }
497        }
498
499        let config = NormalEstimationConfig {
500            k_neighbors: 8, // Increase k for better results
501            radius: None,
502            consistent_orientation: true,
503            viewpoint: Some(Point3f::new(0.0, 0.0, 2.0)), // View from above
504        };
505
506        let result = estimate_normals_with_config(&cloud, &config).unwrap();
507        assert_eq!(result.len(), 100);
508
509        // Check that normals are computed and have reasonable values
510        let mut perpendicular_count = 0;
511        let mut outward_count = 0;
512        for point in result.iter() {
513            let normal_magnitude = point.normal.magnitude();
514            // Normals should be unit vectors
515            assert!(
516                (normal_magnitude - 1.0).abs() < 0.1,
517                "Normal should be unit vector: magnitude={}",
518                normal_magnitude
519            );
520
521            // For a cylinder, normals should be roughly perpendicular to the cylinder axis (Z-axis)
522            let dot_with_z = point.normal.z.abs();
523            if dot_with_z < 0.8 {
524                perpendicular_count += 1;
525            }
526
527            // Check if normal points outward from center
528            let to_center = Vector3f::new(-point.position.x, -point.position.y, 0.0).normalize();
529            let dot_outward = point.normal.dot(&to_center);
530            if dot_outward > 0.5 {
531                outward_count += 1;
532            }
533        }
534
535        // At least 60% of normals should be perpendicular to Z-axis for a cylinder
536        let percentage_perpendicular = (perpendicular_count as f32 / result.len() as f32) * 100.0;
537        let percentage_outward = (outward_count as f32 / result.len() as f32) * 100.0;
538
539        println!(
540            "Cylinder test: {:.1}% perpendicular to Z, {:.1}% pointing outward",
541            percentage_perpendicular, percentage_outward
542        );
543
544        // For a cylinder, normals should be perpendicular to Z-axis
545        assert!(
546            percentage_perpendicular > 60.0,
547            "Only {:.1}% of normals are perpendicular to Z-axis",
548            percentage_perpendicular
549        );
550    }
551
552    #[test]
553    fn test_estimate_normals_orientation_consistency() {
554        // Create a simple planar point cloud
555        let mut cloud = PointCloud::new();
556        cloud.push(Point3f::new(0.0, 0.0, 0.0));
557        cloud.push(Point3f::new(1.0, 0.0, 0.0));
558        cloud.push(Point3f::new(0.0, 1.0, 0.0));
559        cloud.push(Point3f::new(1.0, 1.0, 0.0));
560
561        // Test with orientation consistency enabled
562        let config_consistent = NormalEstimationConfig {
563            k_neighbors: 3,
564            radius: None,
565            consistent_orientation: true,
566            viewpoint: Some(Point3f::new(0.0, 0.0, 1.0)), // View from positive Z
567        };
568
569        let result_consistent = estimate_normals_with_config(&cloud, &config_consistent).unwrap();
570
571        // Test with orientation consistency disabled
572        let config_inconsistent = NormalEstimationConfig {
573            k_neighbors: 3,
574            radius: None,
575            consistent_orientation: false,
576            viewpoint: None,
577        };
578
579        let _result_inconsistent =
580            estimate_normals_with_config(&cloud, &config_inconsistent).unwrap();
581
582        // With consistent orientation, all normals should point in the same direction (positive Z)
583        let first_normal_consistent = result_consistent.points[0].normal.z;
584        for point in result_consistent.iter() {
585            assert!(
586                (point.normal.z * first_normal_consistent) > 0.0,
587                "Normals should have consistent orientation"
588            );
589        }
590
591        // Without consistent orientation, normals might point in different directions
592        // (This test is less strict since the algorithm might still produce consistent results)
593        println!("Consistent orientation test completed");
594    }
595
596    #[test]
597    fn test_find_neighbors() {
598        let points = vec![
599            Point3f::new(0.0, 0.0, 0.0),
600            Point3f::new(1.0, 0.0, 0.0),
601            Point3f::new(0.0, 1.0, 0.0),
602            Point3f::new(2.0, 0.0, 0.0),
603        ];
604
605        // Test k-NN
606        let config_knn = NormalEstimationConfig {
607            k_neighbors: 2,
608            radius: None,
609            consistent_orientation: false,
610            viewpoint: None,
611        };
612
613        let neighbors_knn = find_neighbors(&points, 0, &config_knn);
614        assert_eq!(neighbors_knn.len(), 2);
615
616        // Test radius-based
617        let config_radius = NormalEstimationConfig {
618            k_neighbors: 10,
619            radius: Some(1.5),
620            consistent_orientation: false,
621            viewpoint: None,
622        };
623
624        let neighbors_radius = find_neighbors(&points, 0, &config_radius);
625        assert_eq!(neighbors_radius.len(), 2); // Points at (1,0,0) and (0,1,0) are within radius 1.5
626    }
627}