1use 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#[derive(Debug, Clone)]
17pub struct NormalEstimationConfig {
18 pub k_neighbors: usize,
20 pub radius: Option<f32>,
22 pub consistent_orientation: bool,
24 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#[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 other
69 .distance
70 .partial_cmp(&self.distance)
71 .unwrap_or(Ordering::Equal)
72 }
73}
74
75#[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; }
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#[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#[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 find_radius_neighbors(points, query_idx, radius)
128 } else {
129 find_k_nearest_neighbors(points, query_idx, config.k_neighbors)
131 }
132}
133
134fn 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
156fn compute_normal_pca(points: &[Point3f], indices: &[usize]) -> Vector3f {
158 if indices.len() < 3 {
159 return Vector3f::new(0.0, 0.0, 1.0);
161 }
162
163 let mut centroid = Point3f::origin();
165 for &idx in indices {
166 centroid += points[idx].coords;
167 }
168 centroid /= indices.len() as f32;
169
170 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 let eigen = covariance.symmetric_eigen();
181 let eigenvalues = eigen.eigenvalues;
182 let eigenvectors = eigen.eigenvectors;
183
184 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 let mut normal: Vector3f = eigenvectors.column(min_idx).into();
194
195 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
206fn 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 dot_product < 0.0 {
217 -normal
218 } else {
219 normal
220 }
221}
222
223pub 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
248pub 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 let viewpoint = config.viewpoint.unwrap_or_else(|| {
275 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 center + Vector3f::new(0.0, 0.0, extent)
302 });
303
304 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 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 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 neighborhood.push(i);
342
343 let mut normal = compute_normal_pca(points, neighborhood);
344
345 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
361pub 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 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 #[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 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 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
464pub 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, radius: Some(radius),
481 consistent_orientation,
482 viewpoint: None,
483 };
484 estimate_normals_with_config(cloud, &config)
485}
486
487#[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 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 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 point in result.iter() {
519 let normal = point.normal;
520 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 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 let mut z_direction_count = 0;
563 for point in result.iter() {
564 let normal_magnitude = point.normal.magnitude();
565 assert!(
567 (normal_magnitude - 1.0).abs() < 0.1,
568 "Normal should be unit vector: magnitude={}",
569 normal_magnitude
570 );
571
572 if point.normal.z.abs() > 0.8 {
574 z_direction_count += 1;
575 }
576 }
577
578 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 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, radius: None,
605 consistent_orientation: true,
606 viewpoint: Some(Point3f::new(0.0, 0.0, 2.0)), };
608
609 let result = estimate_normals_with_config(&cloud, &config).unwrap();
610 assert_eq!(result.len(), 100);
611
612 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 assert!(
619 (normal_magnitude - 1.0).abs() < 0.1,
620 "Normal should be unit vector: magnitude={}",
621 normal_magnitude
622 );
623
624 let dot_with_z = point.normal.z.abs();
626 if dot_with_z < 0.8 {
627 perpendicular_count += 1;
628 }
629
630 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 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 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 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 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)), };
671
672 let result_consistent = estimate_normals_with_config(&cloud, &config_consistent).unwrap();
673
674 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 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 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 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 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); }
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 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}