1use spatialrust_core::{
2 DType, DeviceKind, ExecutionOutput, ExecutionPolicy, ExecutionReceipt, FieldSemantic,
3 HasPositions3, PointBuffer, PointBufferSet, PointCloud, PointField, PointSchema, SpatialError,
4 SpatialResult, TransferDirection,
5};
6use spatialrust_math::{symmetric_eigen3, Mat3, Vec3};
7use spatialrust_search::{parallel_worker_count, KdTree, Neighbor, RadiusSearchIndex};
8
9use crate::estimator::FeatureEstimator;
10
11pub const DEFAULT_GPU_MIN_POINTS_NORMAL: usize = 10_000;
16
17#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct NormalEstimationConfig {
20 pub k_neighbors: usize,
22 pub search_radius: Option<f32>,
24 pub min_neighbors: usize,
26 pub viewpoint: Option<Vec3<f32>>,
28 pub gpu_min_points: Option<usize>,
32}
33
34impl Default for NormalEstimationConfig {
35 fn default() -> Self {
36 Self {
37 k_neighbors: 20,
38 search_radius: None,
39 min_neighbors: 3,
40 viewpoint: None,
41 gpu_min_points: Some(DEFAULT_GPU_MIN_POINTS_NORMAL),
42 }
43 }
44}
45
46impl NormalEstimationConfig {
47 #[must_use]
49 pub const fn k_neighbors(k_neighbors: usize) -> Self {
50 Self {
51 k_neighbors,
52 search_radius: None,
53 min_neighbors: 3,
54 viewpoint: None,
55 gpu_min_points: Some(DEFAULT_GPU_MIN_POINTS_NORMAL),
56 }
57 }
58
59 #[must_use]
61 pub const fn without_gpu_min_points(mut self) -> Self {
62 self.gpu_min_points = None;
63 self
64 }
65
66 #[must_use]
68 pub const fn effective_gpu_min_points(&self) -> Option<usize> {
69 self.gpu_min_points
70 }
71}
72
73#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct NormalEstimationResult {
76 pub valid_count: usize,
78 pub invalid_count: usize,
80}
81
82#[derive(Clone, Debug, PartialEq)]
84pub struct NormalEstimator {
85 config: NormalEstimationConfig,
86}
87
88impl NormalEstimator {
89 #[must_use]
91 pub const fn new(config: NormalEstimationConfig) -> Self {
92 Self { config }
93 }
94
95 #[must_use]
97 pub const fn config(&self) -> NormalEstimationConfig {
98 self.config
99 }
100
101 pub fn estimate_with_diagnostics(
103 &self,
104 input: &PointCloud,
105 ) -> SpatialResult<(PointCloud, NormalEstimationResult)> {
106 if input.is_empty() {
107 return Ok((input.clone(), NormalEstimationResult::default()));
108 }
109 if self.config.search_radius.is_some_and(|radius| radius < 0.0) {
110 return Err(SpatialError::InvalidArgument("search_radius must be non-negative".into()));
111 }
112
113 let (x, y, z) = input.positions3()?;
114 let tree = KdTree::from_slices(x, y, z);
115
116 let mut nx = vec![f32::NAN; input.len()];
117 let mut ny = vec![f32::NAN; input.len()];
118 let mut nz = vec![f32::NAN; input.len()];
119 let mut curvature = vec![0.0_f32; input.len()];
120 let mut valid_count = 0usize;
121 let mut invalid_count = 0usize;
122
123 let worker_count = parallel_worker_count(input.len());
124 if worker_count == 1 {
125 let chunk = estimate_normal_range(self.config, &tree, x, y, z, 0, input.len());
126 nx = chunk.nx;
127 ny = chunk.ny;
128 nz = chunk.nz;
129 curvature = chunk.curvature;
130 valid_count = chunk.valid_count;
131 invalid_count = chunk.invalid_count;
132 } else {
133 let chunks = std::thread::scope(|scope| {
134 use spatialrust_search::parallel_index_ranges;
135
136 let mut handles = Vec::new();
137 let config = self.config;
138 let tree_ref = &tree;
139 for range in parallel_index_ranges(input.len(), worker_count) {
140 handles.push(scope.spawn(move || {
141 estimate_normal_range(config, tree_ref, x, y, z, range.start, range.end)
142 }));
143 }
144
145 handles
146 .into_iter()
147 .map(|handle| handle.join().expect("normal estimation worker panicked"))
148 .collect::<Vec<_>>()
149 });
150
151 for chunk in chunks {
152 let end = chunk.start + chunk.nx.len();
153 nx[chunk.start..end].copy_from_slice(&chunk.nx);
154 ny[chunk.start..end].copy_from_slice(&chunk.ny);
155 nz[chunk.start..end].copy_from_slice(&chunk.nz);
156 curvature[chunk.start..end].copy_from_slice(&chunk.curvature);
157 valid_count += chunk.valid_count;
158 invalid_count += chunk.invalid_count;
159 }
160 }
161
162 let output = build_output_cloud(input, nx, ny, nz, curvature)?;
163 Ok((output, NormalEstimationResult { valid_count, invalid_count }))
164 }
165
166 pub fn estimate_with_policy(
173 &self,
174 input: &PointCloud,
175 policy: ExecutionPolicy,
176 ) -> SpatialResult<PointCloud> {
177 self.estimate_with_policy_and_receipt(input, policy).map(ExecutionOutput::into_output)
178 }
179
180 pub fn estimate_with_policy_and_receipt(
182 &self,
183 input: &PointCloud,
184 policy: ExecutionPolicy,
185 ) -> SpatialResult<ExecutionOutput<PointCloud>> {
186 policy.validate()?;
187 let resolved_policy = self.receipt_policy(input, policy)?;
188 let output = self.estimate_policy_output(input, policy)?;
189 let mut receipt = ExecutionReceipt::new(policy, resolved_policy);
190 receipt.record_stage("normal-estimation");
191 if matches!(resolved_policy, ExecutionPolicy::Gpu(DeviceKind::Wgpu)) {
192 let input_bytes = (input.len() * 3 * std::mem::size_of::<f32>()) as u64;
193 let neighbor_bytes = if self.config.search_radius.is_none() {
194 (input.len().saturating_mul(self.config.k_neighbors.max(1))
195 * std::mem::size_of::<u32>()) as u64
196 } else {
197 0
198 };
199 let output_bytes = (output.len() * 4 * std::mem::size_of::<f32>()) as u64;
200 receipt.record_transfer(TransferDirection::HostToDevice, input_bytes);
201 receipt.record_transfer(TransferDirection::HostToDevice, neighbor_bytes);
202 receipt.record_transfer(TransferDirection::DeviceToHost, output_bytes);
203 receipt.record_stage("gpu-readback");
204 }
205 Ok(ExecutionOutput::new(output, receipt))
206 }
207
208 fn estimate_policy_output(
209 &self,
210 input: &PointCloud,
211 policy: ExecutionPolicy,
212 ) -> SpatialResult<PointCloud> {
213 #[cfg(feature = "feature-normal-gpu")]
214 {
215 let resolved = self.resolve_policy(input, policy)?;
216 if matches!(resolved, ExecutionPolicy::Gpu(DeviceKind::Wgpu)) {
217 return crate::normal_gpu::GpuNormalEstimator::new(self.config).estimate(input);
218 }
219 }
220
221 #[cfg(not(feature = "feature-normal-gpu"))]
222 if policy.requests_gpu() {
223 return Err(SpatialError::InvalidArgument(
224 "GPU normal estimation requires the feature-normal-gpu feature".to_owned(),
225 ));
226 }
227
228 let _ = policy;
229 self.estimate(input)
230 }
231
232 fn receipt_policy(
233 &self,
234 input: &PointCloud,
235 policy: ExecutionPolicy,
236 ) -> SpatialResult<ExecutionPolicy> {
237 #[cfg(feature = "feature-normal-gpu")]
238 {
239 self.resolve_policy(input, policy)
240 }
241 #[cfg(not(feature = "feature-normal-gpu"))]
242 {
243 let _ = input;
244 Ok(match policy {
245 ExecutionPolicy::Auto => ExecutionPolicy::CpuSingle,
246 other => other,
247 })
248 }
249 }
250
251 #[cfg(feature = "feature-normal-gpu")]
253 pub fn selects_gpu_backend(&self, input: &PointCloud, policy: ExecutionPolicy) -> bool {
254 matches!(
255 self.resolve_policy(input, policy).ok(),
256 Some(ExecutionPolicy::Gpu(DeviceKind::Wgpu))
257 )
258 }
259
260 #[cfg(feature = "feature-normal-gpu")]
262 pub fn gpu_grid_fits(&self, input: &PointCloud, radius: f32) -> bool {
263 let Ok((x, y, z)) = input.positions3() else {
264 return false;
265 };
266 spatialrust_gpu::uniform_grid_fits(x, y, z, radius)
267 }
268
269 #[cfg(feature = "feature-normal-gpu")]
270 fn should_use_gpu(&self, input: &PointCloud) -> bool {
271 self.config.effective_gpu_min_points().map_or(true, |min_points| input.len() >= min_points)
272 && spatialrust_gpu::WgpuRuntime::shared().is_ok()
273 }
274
275 #[cfg(feature = "feature-normal-gpu")]
276 fn resolve_policy(
277 &self,
278 input: &PointCloud,
279 policy: ExecutionPolicy,
280 ) -> SpatialResult<ExecutionPolicy> {
281 match policy {
282 ExecutionPolicy::Auto => {
283 if self.should_use_gpu(input) {
284 Ok(ExecutionPolicy::Gpu(DeviceKind::Wgpu))
285 } else {
286 Ok(ExecutionPolicy::CpuSingle)
287 }
288 }
289 ExecutionPolicy::Gpu(DeviceKind::Cpu) => Err(SpatialError::InvalidArgument(
290 "GPU execution policy cannot target the CPU device".to_owned(),
291 )),
292 ExecutionPolicy::Gpu(DeviceKind::Cuda) => Err(SpatialError::InvalidArgument(
293 "CUDA normal estimation is not available".to_owned(),
294 )),
295 other => Ok(other),
296 }
297 }
298}
299
300impl FeatureEstimator for NormalEstimator {
301 fn name(&self) -> &'static str {
302 "NormalEstimator"
303 }
304
305 fn estimate(&self, input: &PointCloud) -> SpatialResult<PointCloud> {
306 self.estimate_with_diagnostics(input).map(|(cloud, _)| cloud)
307 }
308}
309
310#[derive(Debug)]
311struct NormalChunk {
312 start: usize,
313 nx: Vec<f32>,
314 ny: Vec<f32>,
315 nz: Vec<f32>,
316 curvature: Vec<f32>,
317 valid_count: usize,
318 invalid_count: usize,
319}
320
321fn estimate_normal_range(
322 config: NormalEstimationConfig,
323 tree: &KdTree,
324 x: &[f32],
325 y: &[f32],
326 z: &[f32],
327 start: usize,
328 end: usize,
329) -> NormalChunk {
330 let len = end - start;
331 let mut nx = vec![f32::NAN; len];
332 let mut ny = vec![f32::NAN; len];
333 let mut nz = vec![f32::NAN; len];
334 let mut curvature = vec![0.0_f32; len];
335 let mut valid_count = 0usize;
336 let mut invalid_count = 0usize;
337 let mut neighbor_buffer = Vec::with_capacity(config.k_neighbors.saturating_add(1));
338 let mut index_buffer = Vec::with_capacity(config.k_neighbors);
339
340 for index in start..end {
341 query_neighbors_into(config, tree, x, y, z, index, &mut neighbor_buffer, &mut index_buffer);
342 let local = index - start;
343 if index_buffer.len() < config.min_neighbors {
344 invalid_count += 1;
345 continue;
346 }
347
348 let Some((normal, curv)) = estimate_normal_from_neighbors(x, y, z, index, &index_buffer)
349 else {
350 invalid_count += 1;
351 continue;
352 };
353
354 let oriented = if let Some(viewpoint) = config.viewpoint {
355 orient_normal_towards_viewpoint(normal, point_xyz(x, y, z, index), viewpoint)
356 } else {
357 normal
358 };
359
360 nx[local] = oriented.x;
361 ny[local] = oriented.y;
362 nz[local] = oriented.z;
363 curvature[local] = curv;
364 valid_count += 1;
365 }
366
367 NormalChunk { start, nx, ny, nz, curvature, valid_count, invalid_count }
368}
369
370fn query_neighbors_into(
371 config: NormalEstimationConfig,
372 tree: &KdTree,
373 x: &[f32],
374 y: &[f32],
375 z: &[f32],
376 index: usize,
377 neighbor_buffer: &mut Vec<Neighbor>,
378 index_buffer: &mut Vec<usize>,
379) {
380 index_buffer.clear();
381 if let Some(radius) = config.search_radius {
382 for neighbor in tree.radius_search(x[index], y[index], z[index], radius) {
383 if neighbor.index != index {
384 index_buffer.push(neighbor.index);
385 }
386 }
387 } else {
388 tree.nearest_k_unsorted_into(
389 x[index],
390 y[index],
391 z[index],
392 config.k_neighbors.saturating_add(1),
393 neighbor_buffer,
394 );
395 for neighbor in neighbor_buffer.iter() {
396 if neighbor.index != index {
397 index_buffer.push(neighbor.index);
398 if index_buffer.len() == config.k_neighbors {
399 break;
400 }
401 }
402 }
403 }
404}
405
406#[must_use]
408pub fn orient_normal_towards_viewpoint(
409 mut normal: Vec3<f32>,
410 point: Vec3<f32>,
411 viewpoint: Vec3<f32>,
412) -> Vec3<f32> {
413 let view_direction =
414 Vec3::new(viewpoint.x - point.x, viewpoint.y - point.y, viewpoint.z - point.z);
415 if normal.dot(view_direction) < 0.0 {
416 normal.x = -normal.x;
417 normal.y = -normal.y;
418 normal.z = -normal.z;
419 }
420 normal.normalize()
421}
422
423fn point_xyz(x: &[f32], y: &[f32], z: &[f32], index: usize) -> Vec3<f32> {
424 Vec3::new(x[index], y[index], z[index])
425}
426
427fn estimate_normal_from_neighbors(
428 x: &[f32],
429 y: &[f32],
430 z: &[f32],
431 _center_index: usize,
432 neighbors: &[usize],
433) -> Option<(Vec3<f32>, f32)> {
434 let mut mean_x = 0.0_f32;
435 let mut mean_y = 0.0_f32;
436 let mut mean_z = 0.0_f32;
437 for &index in neighbors {
438 mean_x += x[index];
439 mean_y += y[index];
440 mean_z += z[index];
441 }
442 let count = neighbors.len() as f32;
443 mean_x /= count;
444 mean_y /= count;
445 mean_z /= count;
446
447 let mut c00 = 0.0_f32;
448 let mut c11 = 0.0_f32;
449 let mut c22 = 0.0_f32;
450 let mut c01 = 0.0_f32;
451 let mut c02 = 0.0_f32;
452 let mut c12 = 0.0_f32;
453 for &index in neighbors {
454 let dx = x[index] - mean_x;
455 let dy = y[index] - mean_y;
456 let dz = z[index] - mean_z;
457 c00 += dx * dx;
458 c11 += dy * dy;
459 c22 += dz * dz;
460 c01 += dx * dy;
461 c02 += dx * dz;
462 c12 += dy * dz;
463 }
464 let inv = 1.0 / count;
465 smallest_eigenpair_for_covariance(
466 c00 * inv,
467 c11 * inv,
468 c22 * inv,
469 c01 * inv,
470 c02 * inv,
471 c12 * inv,
472 )
473}
474
475fn smallest_eigenpair_for_covariance(
476 c00: f32,
477 c11: f32,
478 c22: f32,
479 c01: f32,
480 c02: f32,
481 c12: f32,
482) -> Option<(Vec3<f32>, f32)> {
483 let eigenvalues = symmetric_eigenvalues3(c00, c11, c22, c01, c02, c12);
484 let lambda = eigenvalues[0];
485 let normal =
486 eigenvector_for_eigenvalue(c00, c11, c22, c01, c02, c12, lambda).unwrap_or_else(|| {
487 let covariance = Mat3::<f64>::from_rows(
488 [c00 as f64, c01 as f64, c02 as f64],
489 [c01 as f64, c11 as f64, c12 as f64],
490 [c02 as f64, c12 as f64, c22 as f64],
491 );
492 let eigen = symmetric_eigen3(covariance);
493 Vec3::new(
494 eigen.eigenvectors.m[0][0] as f32,
495 eigen.eigenvectors.m[1][0] as f32,
496 eigen.eigenvectors.m[2][0] as f32,
497 )
498 .normalize()
499 });
500
501 let sum = eigenvalues[0] + eigenvalues[1] + eigenvalues[2];
502 let curvature = if sum > 0.0 { eigenvalues[0] / sum } else { 0.0 };
503 Some((normal.normalize(), curvature))
504}
505
506fn symmetric_eigenvalues3(c00: f32, c11: f32, c22: f32, c01: f32, c02: f32, c12: f32) -> [f32; 3] {
507 let p1 = c01 * c01 + c02 * c02 + c12 * c12;
508 if p1 <= f32::EPSILON {
509 let mut values = [c00, c11, c22];
510 values.sort_by(|a, b| a.partial_cmp(b).unwrap());
511 return values;
512 }
513
514 let q = (c00 + c11 + c22) / 3.0;
515 let b00 = c00 - q;
516 let b11 = c11 - q;
517 let b22 = c22 - q;
518 let p2 = b00 * b00 + b11 * b11 + b22 * b22 + 2.0 * p1;
519 let p = (p2 / 6.0).sqrt();
520 if p <= f32::EPSILON {
521 return [q, q, q];
522 }
523
524 let inv_p = 1.0 / p;
525 let n00 = b00 * inv_p;
526 let n11 = b11 * inv_p;
527 let n22 = b22 * inv_p;
528 let n01 = c01 * inv_p;
529 let n02 = c02 * inv_p;
530 let n12 = c12 * inv_p;
531 let det = n00 * (n11 * n22 - n12 * n12) - n01 * (n01 * n22 - n12 * n02)
532 + n02 * (n01 * n12 - n11 * n02);
533 let r = (det * 0.5).clamp(-1.0, 1.0);
534 let phi = r.acos() / 3.0;
535
536 let largest = q + 2.0 * p * phi.cos();
537 let smallest = q + 2.0 * p * (phi + 2.0 * std::f32::consts::PI / 3.0).cos();
538 let middle = 3.0 * q - largest - smallest;
539 let mut values = [smallest, middle, largest];
540 values.sort_by(|a, b| a.partial_cmp(b).unwrap());
541 values
542}
543
544fn eigenvector_for_eigenvalue(
545 c00: f32,
546 c11: f32,
547 c22: f32,
548 c01: f32,
549 c02: f32,
550 c12: f32,
551 lambda: f32,
552) -> Option<Vec3<f32>> {
553 let row0 = Vec3::new(c00 - lambda, c01, c02);
554 let row1 = Vec3::new(c01, c11 - lambda, c12);
555 let row2 = Vec3::new(c02, c12, c22 - lambda);
556
557 let candidates = [row0.cross(row1), row0.cross(row2), row1.cross(row2)];
558 let mut best = candidates[0];
559 let mut best_norm = best.length_squared();
560 for candidate in candidates.into_iter().skip(1) {
561 let norm = candidate.length_squared();
562 if norm > best_norm {
563 best = candidate;
564 best_norm = norm;
565 }
566 }
567
568 if best_norm <= 1e-24 {
569 None
570 } else {
571 Some(best.normalize())
572 }
573}
574
575pub(crate) fn build_output_cloud(
576 input: &PointCloud,
577 nx: Vec<f32>,
578 ny: Vec<f32>,
579 nz: Vec<f32>,
580 curvature: Vec<f32>,
581) -> SpatialResult<PointCloud> {
582 let mut schema = input.schema().clone();
583 ensure_field(&mut schema, "normal_x", FieldSemantic::NormalX, DType::F32);
584 ensure_field(&mut schema, "normal_y", FieldSemantic::NormalY, DType::F32);
585 ensure_field(&mut schema, "normal_z", FieldSemantic::NormalZ, DType::F32);
586 ensure_field(&mut schema, "curvature", FieldSemantic::Curvature, DType::F32);
587
588 let mut buffers = PointBufferSet::new();
589 for field in input.schema().fields() {
590 let source = input.field(&field.name)?;
591 buffers.insert(field.name.clone(), clone_buffer(source)?);
592 }
593 buffers.insert("normal_x".to_owned(), PointBuffer::from_f32(nx));
594 buffers.insert("normal_y".to_owned(), PointBuffer::from_f32(ny));
595 buffers.insert("normal_z".to_owned(), PointBuffer::from_f32(nz));
596 buffers.insert("curvature".to_owned(), PointBuffer::from_f32(curvature));
597
598 PointCloud::try_from_parts(schema, buffers, input.metadata().clone())
599}
600
601fn ensure_field(schema: &mut PointSchema, name: &str, semantic: FieldSemantic, dtype: DType) {
602 if schema.find_semantic(semantic).is_none() {
603 *schema = schema.clone().with_field(PointField::scalar(name, semantic, dtype));
604 }
605}
606
607fn clone_buffer(buffer: &PointBuffer) -> SpatialResult<PointBuffer> {
608 Ok(match buffer {
609 PointBuffer::F32(values) => PointBuffer::from_f32(values.clone()),
610 PointBuffer::F64(values) => PointBuffer::F64(values.clone()),
611 PointBuffer::U8(values) => PointBuffer::U8(values.clone()),
612 PointBuffer::U16(values) => PointBuffer::U16(values.clone()),
613 PointBuffer::U32(values) => PointBuffer::U32(values.clone()),
614 PointBuffer::I32(values) => PointBuffer::I32(values.clone()),
615 })
616}
617
618#[cfg(test)]
619mod tests {
620 use super::{orient_normal_towards_viewpoint, NormalEstimationConfig, NormalEstimator};
621 use crate::FeatureEstimator;
622 use spatialrust_core::{
623 DeviceKind, ExecutionPolicy, HasNormals3, PointCloudBuilder, StandardSchemas,
624 };
625 use spatialrust_math::Vec3;
626
627 fn plane_cloud() -> spatialrust_core::PointCloud {
628 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
629 for x in 0..5 {
630 for y in 0..5 {
631 builder.push_point([x as f32, y as f32, 0.0]).unwrap();
632 }
633 }
634 builder.build().unwrap()
635 }
636
637 fn tilted_plane_cloud() -> spatialrust_core::PointCloud {
638 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
639 for x in 0..7 {
640 for y in 0..7 {
641 let fx = x as f32 * 0.2;
642 let fy = y as f32 * 0.2;
643 let z = 0.2 * fx - 0.3 * fy + 0.1;
644 builder.push_point([fx, fy, z]).unwrap();
645 }
646 }
647 builder.build().unwrap()
648 }
649
650 #[test]
651 fn estimates_plane_normals_upwards() {
652 let input = plane_cloud();
653 let estimator = NormalEstimator::new(NormalEstimationConfig {
654 k_neighbors: 8,
655 min_neighbors: 3,
656 viewpoint: Some(Vec3::new(0.0, 0.0, 10.0)),
657 ..NormalEstimationConfig::default()
658 });
659 let (output, stats) = estimator.estimate_with_diagnostics(&input).unwrap();
660 assert_eq!(stats.valid_count, input.len());
661 assert_eq!(stats.invalid_count, 0);
662
663 let (_, _, nz) = output.normals3().unwrap();
664 for value in nz {
665 assert!((*value - 1.0).abs() < 0.1, "expected upward normal, got {value}");
666 }
667 }
668
669 #[test]
670 fn estimates_tilted_plane_normals() {
671 let input = tilted_plane_cloud();
672 let estimator = NormalEstimator::new(NormalEstimationConfig {
673 k_neighbors: 12,
674 min_neighbors: 3,
675 viewpoint: Some(Vec3::new(0.0, 0.0, 10.0)),
676 ..NormalEstimationConfig::default()
677 });
678 let output = estimator.estimate(&input).unwrap();
679 let (nx, ny, nz) = output.normals3().unwrap();
680 let expected = Vec3::new(-0.2, 0.3, 1.0).normalize();
681
682 for index in 0..input.len() {
683 let actual = Vec3::new(nx[index], ny[index], nz[index]).normalize();
684 assert!(actual.dot(expected) > 0.98, "tilted plane normal was {actual:?}");
685 }
686 }
687
688 #[test]
689 fn orient_normal_towards_viewpoint_works() {
690 let normal = Vec3::new(0.0, 0.0, -1.0);
691 let point = Vec3::new(0.0, 0.0, 0.0);
692 let viewpoint = Vec3::new(0.0, 0.0, 1.0);
693 let oriented = orient_normal_towards_viewpoint(normal, point, viewpoint);
694 assert!(oriented.z > 0.0);
695 }
696
697 #[test]
698 fn adds_curvature_field() {
699 let input = plane_cloud();
700 let estimator = NormalEstimator::new(NormalEstimationConfig::k_neighbors(10));
701 let output = estimator.estimate(&input).unwrap();
702 assert!(output.field("curvature").is_ok());
703 }
704
705 #[test]
706 fn rejects_unsupported_explicit_gpu_policy() {
707 let estimator = NormalEstimator::new(NormalEstimationConfig::default());
708 let error = estimator
709 .estimate_with_policy(&plane_cloud(), ExecutionPolicy::Gpu(DeviceKind::Cuda))
710 .unwrap_err();
711 assert!(matches!(error, spatialrust_core::SpatialError::InvalidArgument(_)));
712 }
713}