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 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, radius: Some(radius),
378 consistent_orientation,
379 viewpoint: None,
380 };
381 estimate_normals_with_config(cloud, &config)
382}
383
384#[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 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 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 point in result.iter() {
416 let normal = point.normal;
417 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 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 let mut z_direction_count = 0;
460 for point in result.iter() {
461 let normal_magnitude = point.normal.magnitude();
462 assert!(
464 (normal_magnitude - 1.0).abs() < 0.1,
465 "Normal should be unit vector: magnitude={}",
466 normal_magnitude
467 );
468
469 if point.normal.z.abs() > 0.8 {
471 z_direction_count += 1;
472 }
473 }
474
475 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 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, radius: None,
502 consistent_orientation: true,
503 viewpoint: Some(Point3f::new(0.0, 0.0, 2.0)), };
505
506 let result = estimate_normals_with_config(&cloud, &config).unwrap();
507 assert_eq!(result.len(), 100);
508
509 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 assert!(
516 (normal_magnitude - 1.0).abs() < 0.1,
517 "Normal should be unit vector: magnitude={}",
518 normal_magnitude
519 );
520
521 let dot_with_z = point.normal.z.abs();
523 if dot_with_z < 0.8 {
524 perpendicular_count += 1;
525 }
526
527 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 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 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 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 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)), };
568
569 let result_consistent = estimate_normals_with_config(&cloud, &config_consistent).unwrap();
570
571 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 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 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 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 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); }
627}