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/// Flip normals so neighbouring normals agree, all the way around the object.
362///
363/// `estimate_normals` points every normal toward one viewpoint, which is right
364/// for a single scan but leaves about half the normals of a closed object
365/// pointing inward. This walks a minimum spanning tree over the `k` nearest
366/// neighbours, preferring nearly parallel pairs, and flips each normal to agree
367/// with the one it was reached from (Hoppe et al. 1992). Each connected piece
368/// starts from its highest point, whose normal is turned upward, so closed
369/// objects end up with outward normals. Positions are not changed.
370pub fn orient_normals_consistent(cloud: &mut PointCloud<NormalPoint3f>, k: usize) -> Result<()> {
371    use std::cmp::Reverse;
372    use std::collections::BinaryHeap;
373
374    let n = cloud.points.len();
375    if n < 2 {
376        return Ok(());
377    }
378    if k < 1 {
379        return Err(Error::InvalidData("k must be at least 1".to_string()));
380    }
381    if n > u32::MAX as usize {
382        return Err(Error::InvalidData(
383            "too many points to orient (limit is about 4 billion)".to_string(),
384        ));
385    }
386    let positions: Vec<Point3f> = cloud.points.iter().map(|p| p.position).collect();
387    let tree = KdTree::new(&positions)?;
388    let k = k.min(n - 1);
389    // One flat row of `k` neighbours per point (u32 to halve memory on large
390    // clouds); NONE pads rows with fewer neighbours.
391    const NONE: u32 = u32::MAX;
392    let mut neighbours = vec![NONE; n * k];
393    neighbours
394        .par_chunks_mut(k)
395        .enumerate()
396        .for_each_init(Vec::new, |found, (i, row)| {
397            tree.find_k_nearest_into(&positions[i], k + 1, found);
398            let others = found.iter().map(|&(j, _)| j).filter(|&j| j != i);
399            for (slot, j) in row.iter_mut().zip(others) {
400                *slot = j as u32;
401            }
402        });
403
404    /// A candidate tree edge (cost, from, to); cheaper edges join more
405    /// parallel normals.
406    #[derive(PartialEq)]
407    struct Edge(f32, u32, u32);
408    impl Eq for Edge {}
409    impl PartialOrd for Edge {
410        fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
411            Some(self.cmp(other))
412        }
413    }
414    impl Ord for Edge {
415        fn cmp(&self, other: &Self) -> std::cmp::Ordering {
416            self.0.total_cmp(&other.0)
417        }
418    }
419
420    let points = &mut cloud.points;
421    let mut visited = vec![false; n];
422    let mut heap = BinaryHeap::new();
423    // Queue edges only towards points not yet in the tree, so the heap stays
424    // far smaller than n * k.
425    let push = |heap: &mut BinaryHeap<Reverse<Edge>>,
426                from: usize,
427                points: &[NormalPoint3f],
428                visited: &[bool]| {
429        for &to in &neighbours[from * k..(from + 1) * k] {
430            if to != NONE && !visited[to as usize] {
431                let cost = 1.0 - points[from].normal.dot(&points[to as usize].normal).abs();
432                heap.push(Reverse(Edge(cost, from as u32, to)));
433            }
434        }
435    };
436    // Seeds, highest first, so every piece starts from its top.
437    let mut seeds: Vec<usize> = (0..n).collect();
438    seeds.sort_unstable_by(|&a, &b| positions[b].z.total_cmp(&positions[a].z));
439
440    for seed in seeds {
441        if visited[seed] {
442            continue;
443        }
444        visited[seed] = true;
445        if points[seed].normal.z < 0.0 {
446            points[seed].normal = -points[seed].normal;
447        }
448        push(&mut heap, seed, points, &visited);
449        while let Some(Reverse(Edge(_, from, to))) = heap.pop() {
450            let (from, to) = (from as usize, to as usize);
451            if visited[to] {
452                continue;
453            }
454            visited[to] = true;
455            if points[from].normal.dot(&points[to].normal) < 0.0 {
456                points[to].normal = -points[to].normal;
457            }
458            push(&mut heap, to, points, &visited);
459        }
460    }
461    Ok(())
462}
463
464/// Estimate normals using radius-based neighbor search
465///
466/// # Arguments
467/// * `cloud` - Reference to the point cloud
468/// * `radius` - Search radius for neighbors
469/// * `consistent_orientation` - Whether to enforce orientation consistency
470///
471/// # Returns
472/// * `Result<PointCloud<NormalPoint3f>>` - A new point cloud with normals
473pub fn estimate_normals_radius(
474    cloud: &PointCloud<Point3f>,
475    radius: f32,
476    consistent_orientation: bool,
477) -> Result<PointCloud<NormalPoint3f>> {
478    let config = NormalEstimationConfig {
479        k_neighbors: 10, // Fallback value
480        radius: Some(radius),
481        consistent_orientation,
482        viewpoint: None,
483    };
484    estimate_normals_with_config(cloud, &config)
485}
486
487/// Estimate normals and modify the input cloud in-place (legacy API)
488/// This function is deprecated in favor of the version that returns a new cloud
489#[deprecated(note = "Use estimate_normals instead which returns a new point cloud")]
490pub fn estimate_normals_inplace(_cloud: &mut PointCloud<Point3f>, k: usize) -> Result<()> {
491    // This would require converting the point cloud type, which isn't straightforward
492    // with the current type system. The new API is cleaner.
493    let _ = k;
494    Err(Error::Unsupported(
495        "Use estimate_normals instead".to_string(),
496    ))
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_estimate_normals_simple() {
505        // Create a simple planar point cloud (XY plane)
506        let mut cloud = PointCloud::new();
507        cloud.push(Point3f::new(0.0, 0.0, 0.0));
508        cloud.push(Point3f::new(1.0, 0.0, 0.0));
509        cloud.push(Point3f::new(0.0, 1.0, 0.0));
510        cloud.push(Point3f::new(1.0, 1.0, 0.0));
511        cloud.push(Point3f::new(0.5, 0.5, 0.0));
512
513        let result = estimate_normals(&cloud, 3).unwrap();
514
515        assert_eq!(result.len(), 5);
516
517        // For a planar surface in XY plane, normals should point along Z axis
518        for point in result.iter() {
519            let normal = point.normal;
520            // Normal should be close to (0, 0, 1) or (0, 0, -1)
521            assert!(
522                normal.z.abs() > 0.8,
523                "Normal should be primarily in Z direction: {:?}",
524                normal
525            );
526        }
527    }
528
529    #[test]
530    fn test_estimate_normals_empty() {
531        let cloud = PointCloud::<Point3f>::new();
532        let result = estimate_normals(&cloud, 5).unwrap();
533        assert!(result.is_empty());
534    }
535
536    #[test]
537    fn test_estimate_normals_insufficient_k() {
538        let mut cloud = PointCloud::new();
539        cloud.push(Point3f::new(0.0, 0.0, 0.0));
540
541        let result = estimate_normals(&cloud, 2);
542        assert!(result.is_err());
543    }
544
545    #[test]
546    fn test_estimate_normals_radius() {
547        // Create a simple planar point cloud for testing radius-based search
548        let mut cloud = PointCloud::new();
549        for i in 0..20 {
550            for j in 0..20 {
551                let x = (i as f32) * 0.1;
552                let y = (j as f32) * 0.1;
553                let z = 0.0;
554                cloud.push(Point3f::new(x, y, z));
555            }
556        }
557
558        let result = estimate_normals_radius(&cloud, 0.2, true).unwrap();
559        assert_eq!(result.len(), 400);
560
561        // Check that normals are computed and have reasonable values
562        let mut z_direction_count = 0;
563        for point in result.iter() {
564            let normal_magnitude = point.normal.magnitude();
565            // Normals should be unit vectors
566            assert!(
567                (normal_magnitude - 1.0).abs() < 0.1,
568                "Normal should be unit vector: magnitude={}",
569                normal_magnitude
570            );
571
572            // For a planar surface, normals should be primarily in Z direction
573            if point.normal.z.abs() > 0.8 {
574                z_direction_count += 1;
575            }
576        }
577
578        // At least 80% of normals should be in Z direction for a planar surface
579        let percentage = (z_direction_count as f32 / result.len() as f32) * 100.0;
580        assert!(
581            percentage > 80.0,
582            "Only {:.1}% of normals are in Z direction",
583            percentage
584        );
585    }
586
587    #[test]
588    fn test_estimate_normals_cylinder() {
589        // Create a true cylindrical point cloud (points on a cylinder surface)
590        let mut cloud = PointCloud::new();
591        for i in 0..10 {
592            for j in 0..10 {
593                let angle = (i as f32) * 0.6;
594                let height = (j as f32) * 0.2 - 1.0;
595                let x = angle.cos();
596                let y = angle.sin();
597                let z = height;
598                cloud.push(Point3f::new(x, y, z));
599            }
600        }
601
602        let config = NormalEstimationConfig {
603            k_neighbors: 8, // Increase k for better results
604            radius: None,
605            consistent_orientation: true,
606            viewpoint: Some(Point3f::new(0.0, 0.0, 2.0)), // View from above
607        };
608
609        let result = estimate_normals_with_config(&cloud, &config).unwrap();
610        assert_eq!(result.len(), 100);
611
612        // Check that normals are computed and have reasonable values
613        let mut perpendicular_count = 0;
614        let mut outward_count = 0;
615        for point in result.iter() {
616            let normal_magnitude = point.normal.magnitude();
617            // Normals should be unit vectors
618            assert!(
619                (normal_magnitude - 1.0).abs() < 0.1,
620                "Normal should be unit vector: magnitude={}",
621                normal_magnitude
622            );
623
624            // For a cylinder, normals should be roughly perpendicular to the cylinder axis (Z-axis)
625            let dot_with_z = point.normal.z.abs();
626            if dot_with_z < 0.8 {
627                perpendicular_count += 1;
628            }
629
630            // Check if normal points outward from center
631            let to_center = Vector3f::new(-point.position.x, -point.position.y, 0.0).normalize();
632            let dot_outward = point.normal.dot(&to_center);
633            if dot_outward > 0.5 {
634                outward_count += 1;
635            }
636        }
637
638        // At least 60% of normals should be perpendicular to Z-axis for a cylinder
639        let percentage_perpendicular = (perpendicular_count as f32 / result.len() as f32) * 100.0;
640        let percentage_outward = (outward_count as f32 / result.len() as f32) * 100.0;
641
642        println!(
643            "Cylinder test: {:.1}% perpendicular to Z, {:.1}% pointing outward",
644            percentage_perpendicular, percentage_outward
645        );
646
647        // For a cylinder, normals should be perpendicular to Z-axis
648        assert!(
649            percentage_perpendicular > 60.0,
650            "Only {:.1}% of normals are perpendicular to Z-axis",
651            percentage_perpendicular
652        );
653    }
654
655    #[test]
656    fn test_estimate_normals_orientation_consistency() {
657        // Create a simple planar point cloud
658        let mut cloud = PointCloud::new();
659        cloud.push(Point3f::new(0.0, 0.0, 0.0));
660        cloud.push(Point3f::new(1.0, 0.0, 0.0));
661        cloud.push(Point3f::new(0.0, 1.0, 0.0));
662        cloud.push(Point3f::new(1.0, 1.0, 0.0));
663
664        // Test with orientation consistency enabled
665        let config_consistent = NormalEstimationConfig {
666            k_neighbors: 3,
667            radius: None,
668            consistent_orientation: true,
669            viewpoint: Some(Point3f::new(0.0, 0.0, 1.0)), // View from positive Z
670        };
671
672        let result_consistent = estimate_normals_with_config(&cloud, &config_consistent).unwrap();
673
674        // Test with orientation consistency disabled
675        let config_inconsistent = NormalEstimationConfig {
676            k_neighbors: 3,
677            radius: None,
678            consistent_orientation: false,
679            viewpoint: None,
680        };
681
682        let _result_inconsistent =
683            estimate_normals_with_config(&cloud, &config_inconsistent).unwrap();
684
685        // With consistent orientation, all normals should point in the same direction (positive Z)
686        let first_normal_consistent = result_consistent.points[0].normal.z;
687        for point in result_consistent.iter() {
688            assert!(
689                (point.normal.z * first_normal_consistent) > 0.0,
690                "Normals should have consistent orientation"
691            );
692        }
693
694        // Without consistent orientation, normals might point in different directions
695        // (This test is less strict since the algorithm might still produce consistent results)
696        println!("Consistent orientation test completed");
697    }
698
699    #[test]
700    fn test_find_neighbors() {
701        let points = vec![
702            Point3f::new(0.0, 0.0, 0.0),
703            Point3f::new(1.0, 0.0, 0.0),
704            Point3f::new(0.0, 1.0, 0.0),
705            Point3f::new(2.0, 0.0, 0.0),
706        ];
707
708        // Test k-NN
709        let config_knn = NormalEstimationConfig {
710            k_neighbors: 2,
711            radius: None,
712            consistent_orientation: false,
713            viewpoint: None,
714        };
715
716        let neighbors_knn = find_neighbors(&points, 0, &config_knn);
717        assert_eq!(neighbors_knn.len(), 2);
718
719        // Test radius-based
720        let config_radius = NormalEstimationConfig {
721            k_neighbors: 10,
722            radius: Some(1.5),
723            consistent_orientation: false,
724            viewpoint: None,
725        };
726
727        let neighbors_radius = find_neighbors(&points, 0, &config_radius);
728        assert_eq!(neighbors_radius.len(), 2); // Points at (1,0,0) and (0,1,0) are within radius 1.5
729    }
730
731    #[test]
732    fn test_orient_normals_consistent_points_sphere_normals_outward() {
733        let n = 2000;
734        let golden = std::f32::consts::PI * (1.0 + 5f32.sqrt());
735        let points: Vec<Point3f> = (0..n)
736            .map(|i| {
737                let t = i as f32 + 0.5;
738                let phi = (1.0 - 2.0 * t / n as f32).acos();
739                let theta = golden * t;
740                Point3f::new(theta.cos() * phi.sin(), theta.sin() * phi.sin(), phi.cos())
741            })
742            .collect();
743        let mut cloud = estimate_normals(&PointCloud::from_points(points), 10).unwrap();
744        let outward = |c: &PointCloud<NormalPoint3f>| {
745            c.points
746                .iter()
747                .filter(|p| p.normal.dot(&p.position.coords) > 0.0)
748                .count()
749        };
750        // The viewpoint rule gets the lower half of the sphere wrong.
751        assert!(outward(&cloud) < n * 9 / 10);
752        orient_normals_consistent(&mut cloud, 10).unwrap();
753        assert_eq!(outward(&cloud), n);
754    }
755
756    #[test]
757    fn test_orient_normals_consistent_handles_tiny_clouds() {
758        let mut empty = PointCloud::<NormalPoint3f>::new();
759        orient_normals_consistent(&mut empty, 10).unwrap();
760        let mut one = PointCloud::from_points(vec![NormalPoint3f {
761            position: Point3f::origin(),
762            normal: Vector3f::new(0.0, 0.0, -1.0),
763        }]);
764        orient_normals_consistent(&mut one, 10).unwrap();
765    }
766}