1use std::collections::HashMap;
2use std::hash::{BuildHasherDefault, Hasher};
3
4#[cfg(feature = "filter-voxel-gpu")]
5use spatialrust_core::TransferDirection;
6use spatialrust_core::{
7 DType, DeviceKind, ExecutionOutput, ExecutionPolicy, ExecutionReceipt, FieldSemantic,
8 HasPositions3, PointBuffer, PointBufferSet, PointCloud, PointField, PointSchema, SpatialError,
9 SpatialResult,
10};
11use spatialrust_math::Vec3;
12
13use crate::filter::PointCloudFilter;
14
15#[derive(Default)]
18struct VoxelKeyHasher {
19 hash: u64,
20}
21
22impl VoxelKeyHasher {
23 #[inline]
24 fn mix(&mut self, value: u64) {
25 const K: u64 = 0x517c_c1b7_2722_0a95;
26 self.hash = (self.hash.rotate_left(5) ^ value).wrapping_mul(K);
27 }
28}
29
30impl Hasher for VoxelKeyHasher {
31 #[inline]
32 fn finish(&self) -> u64 {
33 self.hash
34 }
35
36 #[inline]
37 fn write_i64(&mut self, i: i64) {
38 self.mix(i as u64);
39 }
40
41 #[inline]
42 fn write_u64(&mut self, i: u64) {
43 self.mix(i);
44 }
45
46 #[inline]
47 fn write_u32(&mut self, i: u32) {
48 self.mix(u64::from(i));
49 }
50
51 #[inline]
52 fn write_i32(&mut self, i: i32) {
53 self.mix(i as u64);
54 }
55
56 fn write(&mut self, bytes: &[u8]) {
57 for &b in bytes {
58 self.mix(u64::from(b));
59 }
60 }
61}
62
63type VoxelCellMap = HashMap<(i64, i64, i64), VoxelCell, BuildHasherDefault<VoxelKeyHasher>>;
65type XyzVoxelCellMap = HashMap<(i64, i64, i64), usize, BuildHasherDefault<VoxelKeyHasher>>;
66type XyzVoxelCellMapU32 = HashMap<(u32, u32, u32), usize, BuildHasherDefault<VoxelKeyHasher>>;
67
68#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum VoxelAggregationMode {
71 #[default]
73 Centroid,
74 ApproximateFirst,
76}
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum AttributeAggregation {
81 #[default]
83 Average,
84 First,
86}
87
88pub const DEFAULT_GPU_MIN_POINTS: usize = usize::MAX;
95
96pub const DEFAULT_GPU_MIN_POINTS_APPROXIMATE: usize = 2_000_000;
102
103pub const APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS: usize = 4;
108
109pub const DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY: usize = 1_000_000;
111
112#[derive(Clone, Copy, Debug, PartialEq)]
114pub struct VoxelGridDownsampleConfig {
115 pub leaf_size: f32,
117 pub origin: Option<Vec3<f32>>,
119 pub mode: VoxelAggregationMode,
121 pub attribute_policy: AttributeAggregation,
123 pub gpu_min_points: Option<usize>,
133}
134
135impl VoxelGridDownsampleConfig {
136 #[must_use]
138 pub fn centroid(leaf_size: f32) -> Self {
139 Self {
140 leaf_size,
141 origin: None,
142 mode: VoxelAggregationMode::Centroid,
143 attribute_policy: AttributeAggregation::Average,
144 gpu_min_points: Some(DEFAULT_GPU_MIN_POINTS),
145 }
146 }
147
148 #[must_use]
150 pub fn approximate(leaf_size: f32) -> Self {
151 Self {
152 leaf_size,
153 origin: None,
154 mode: VoxelAggregationMode::ApproximateFirst,
155 attribute_policy: AttributeAggregation::First,
156 gpu_min_points: Some(DEFAULT_GPU_MIN_POINTS_APPROXIMATE),
157 }
158 }
159
160 #[must_use]
162 pub const fn without_gpu_min_points(mut self) -> Self {
163 self.gpu_min_points = None;
164 self
165 }
166
167 #[must_use]
173 pub fn effective_gpu_min_points(&self, schema: &PointSchema) -> Option<usize> {
174 let base = self.gpu_min_points?;
175 if self.mode != VoxelAggregationMode::ApproximateFirst {
176 return Some(base);
177 }
178 if count_non_position_f32_fields(schema) >= APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS {
179 return Some(DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY);
180 }
181 Some(base)
182 }
183}
184
185#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct VoxelGridDownsample {
188 config: VoxelGridDownsampleConfig,
189}
190
191impl VoxelGridDownsample {
192 #[must_use]
194 pub const fn new(config: VoxelGridDownsampleConfig) -> Self {
195 Self { config }
196 }
197
198 #[must_use]
200 pub const fn config(&self) -> VoxelGridDownsampleConfig {
201 self.config
202 }
203
204 pub fn filter_with_policy(
214 &self,
215 input: &PointCloud,
216 policy: ExecutionPolicy,
217 ) -> SpatialResult<PointCloud> {
218 self.filter_with_policy_and_receipt(input, policy).map(ExecutionOutput::into_output)
219 }
220
221 pub fn filter_with_policy_and_receipt(
223 &self,
224 input: &PointCloud,
225 policy: ExecutionPolicy,
226 ) -> SpatialResult<ExecutionOutput<PointCloud>> {
227 policy.validate()?;
228 let resolved_policy = self.resolve_policy(input, policy)?;
229 let output = self.filter_internal(input, policy)?;
230 let mut receipt = ExecutionReceipt::new(policy, resolved_policy);
231 receipt.record_stage("voxel-downsample");
232 #[cfg(feature = "filter-voxel-gpu")]
233 if matches!(resolved_policy, ExecutionPolicy::Gpu(DeviceKind::Wgpu)) {
234 record_gpu_voxel_transfers(input, &output, &mut receipt);
235 }
236 Ok(ExecutionOutput::new(output, receipt))
237 }
238}
239
240impl PointCloudFilter for VoxelGridDownsample {
241 fn name(&self) -> &'static str {
242 "VoxelGridDownsample"
243 }
244
245 fn filter(&self, input: &PointCloud) -> SpatialResult<PointCloud> {
246 self.filter_internal(input, ExecutionPolicy::CpuSingle)
247 }
248}
249
250impl VoxelGridDownsample {
251 fn filter_internal(
252 &self,
253 input: &PointCloud,
254 policy: ExecutionPolicy,
255 ) -> SpatialResult<PointCloud> {
256 policy.validate()?;
257 if input.is_empty() {
258 return Ok(input.clone());
259 }
260 if self.config.leaf_size <= 0.0 {
261 return Err(SpatialError::InvalidArgument(
262 "leaf_size must be greater than zero".to_owned(),
263 ));
264 }
265
266 let (x, y, z) = input.positions3()?;
267 let inv_leaf = 1.0 / self.config.leaf_size;
268 let (origin, u32_voxel_keys) = match self.config.origin {
269 Some(origin) => (origin, false),
270 None => {
271 let (min, max) = compute_bounds(x, y, z);
272 (min, fits_u32_voxel_key(min, max, inv_leaf))
273 }
274 };
275 let origin_is_min = self.config.origin.is_none();
276 let policy = self.resolve_policy(input, policy)?;
277
278 if matches!(policy, ExecutionPolicy::Gpu(DeviceKind::Wgpu)) {
279 #[cfg(feature = "filter-voxel-gpu")]
280 {
281 return match self.config.mode {
282 VoxelAggregationMode::Centroid => filter_gpu_centroid(
283 input,
284 x,
285 y,
286 z,
287 origin,
288 inv_leaf,
289 self.config.attribute_policy,
290 ),
291 VoxelAggregationMode::ApproximateFirst => filter_gpu_approximate_first(
292 input,
293 x,
294 y,
295 z,
296 origin,
297 inv_leaf,
298 self.config.attribute_policy,
299 ),
300 };
301 }
302 #[cfg(not(feature = "filter-voxel-gpu"))]
303 {
304 return Err(SpatialError::InvalidArgument(
305 "GPU voxel downsampling requires the filter-voxel-gpu feature".to_owned(),
306 ));
307 }
308 }
309
310 if matches!(
315 policy,
316 ExecutionPolicy::Auto | ExecutionPolicy::CpuSingle | ExecutionPolicy::CpuParallel
317 ) && self.config.mode == VoxelAggregationMode::Centroid
318 && self.config.attribute_policy == AttributeAggregation::Average
319 {
320 return filter_cpu_centroid_fast(
321 input,
322 x,
323 y,
324 z,
325 origin,
326 inv_leaf,
327 origin_is_min,
328 u32_voxel_keys,
329 );
330 }
331
332 let cells = match policy {
333 ExecutionPolicy::Gpu(DeviceKind::Wgpu) => {
334 build_voxel_cells_gpu(x, y, z, origin, inv_leaf)?
335 }
336 ExecutionPolicy::Gpu(_) => {
337 return Err(SpatialError::InvalidArgument(
338 "unsupported GPU device kind for voxel downsampling".to_owned(),
339 ));
340 }
341 ExecutionPolicy::Auto | ExecutionPolicy::CpuSingle | ExecutionPolicy::CpuParallel => {
342 build_voxel_cells_cpu(x, y, z, origin, inv_leaf)
343 }
344 };
345
346 let schema = input.schema().clone();
347 let mut buffers = PointBufferSet::new();
348 for field in schema.fields() {
349 buffers
350 .insert(field.name.clone(), PointBuffer::with_capacity(field.dtype, cells.len()));
351 }
352
353 let mut ordered_cells: Vec<_> = cells.into_iter().collect();
354 ordered_cells.sort_by_key(|(key, _)| *key);
355
356 for (_, cell) in ordered_cells {
357 append_voxel_point(
358 input,
359 &mut buffers,
360 schema.fields(),
361 &cell,
362 self.config.mode,
363 self.config.attribute_policy,
364 )?;
365 }
366
367 PointCloud::try_from_parts(schema, buffers, input.metadata().clone())
368 }
369
370 fn resolve_policy(
371 &self,
372 input: &PointCloud,
373 policy: ExecutionPolicy,
374 ) -> SpatialResult<ExecutionPolicy> {
375 match policy {
376 ExecutionPolicy::Auto => {
377 if self.should_use_gpu(input) {
378 Ok(ExecutionPolicy::Gpu(DeviceKind::Wgpu))
379 } else {
380 Ok(ExecutionPolicy::CpuSingle)
381 }
382 }
383 ExecutionPolicy::Gpu(DeviceKind::Cpu) => Err(SpatialError::InvalidArgument(
384 "GPU execution policy cannot target the CPU device".to_owned(),
385 )),
386 other => Ok(other),
387 }
388 }
389
390 fn should_use_gpu(&self, input: &PointCloud) -> bool {
391 #[cfg(feature = "filter-voxel-gpu")]
392 {
393 match self.config.effective_gpu_min_points(input.schema()) {
394 Some(min_points) if input.len() < min_points => false,
395 _ => spatialrust_gpu::WgpuRuntime::shared().is_ok(),
396 }
397 }
398 #[cfg(not(feature = "filter-voxel-gpu"))]
399 {
400 let _ = input;
401 false
402 }
403 }
404}
405
406fn count_non_position_f32_fields(schema: &PointSchema) -> usize {
407 schema
408 .fields()
409 .iter()
410 .filter(|field| {
411 !matches!(
412 field.semantic,
413 FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ
414 ) && matches!(field.dtype, DType::F32 | DType::F16)
415 })
416 .count()
417}
418
419#[cfg(feature = "filter-voxel-gpu")]
420fn record_gpu_voxel_transfers(
421 input: &PointCloud,
422 output: &PointCloud,
423 receipt: &mut ExecutionReceipt,
424) {
425 let input_bytes = gpu_voxel_payload_bytes(input.schema(), input.len());
430 let output_bytes = gpu_voxel_payload_bytes(output.schema(), output.len());
431 receipt.record_transfer(TransferDirection::HostToDevice, input_bytes);
432 receipt.record_transfer(TransferDirection::DeviceToHost, output_bytes);
433 receipt.record_stage("gpu-readback");
434}
435
436#[cfg(feature = "filter-voxel-gpu")]
437fn gpu_voxel_payload_bytes(schema: &PointSchema, point_count: usize) -> u64 {
438 let scalar_bytes = schema
439 .fields()
440 .iter()
441 .filter(|field| {
442 !matches!(
443 field.semantic,
444 FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ
445 )
446 })
447 .map(|field| {
448 let lanes = point_count.saturating_mul(field.components);
449 lanes.saturating_mul(4)
450 })
451 .sum::<usize>();
452 point_count.saturating_mul(3 * std::mem::size_of::<f32>()).saturating_add(scalar_bytes) as u64
453}
454
455#[derive(Clone, Debug, Default)]
456struct VoxelCell {
457 indices: Vec<usize>,
458}
459
460fn filter_cpu_centroid_fast(
465 input: &PointCloud,
466 x: &[f32],
467 y: &[f32],
468 z: &[f32],
469 origin: Vec3<f32>,
470 inv_leaf: f32,
471 origin_is_min: bool,
472 u32_voxel_keys: bool,
473) -> SpatialResult<PointCloud> {
474 if let Some(output) = filter_cpu_xyz_centroid_fast(
475 input,
476 x,
477 y,
478 z,
479 origin,
480 inv_leaf,
481 origin_is_min,
482 u32_voxel_keys,
483 )? {
484 return Ok(output);
485 }
486
487 let schema = input.schema().clone();
488 let fields = schema.fields();
489 let n_fields = fields.len();
490
491 let field_buffers: Vec<&PointBuffer> =
493 fields.iter().map(|f| input.field(&f.name)).collect::<SpatialResult<_>>()?;
494
495 let mut key_to_id: HashMap<(i64, i64, i64), u32, BuildHasherDefault<VoxelKeyHasher>> =
496 HashMap::default();
497 let mut keys: Vec<(i64, i64, i64)> = Vec::new();
498 let mut counts: Vec<u32> = Vec::new();
499 let mut sums: Vec<f64> = Vec::new();
501
502 for i in 0..x.len() {
503 let key = if origin_is_min {
504 voxel_key_nonnegative(x[i], y[i], z[i], origin, inv_leaf)
505 } else {
506 voxel_key(x[i], y[i], z[i], origin, inv_leaf)
507 };
508 let id = *key_to_id.entry(key).or_insert_with(|| {
509 let id = counts.len() as u32;
510 counts.push(0);
511 keys.push(key);
512 sums.extend(std::iter::repeat(0.0).take(n_fields));
513 id
514 }) as usize;
515 counts[id] += 1;
516 let base = id * n_fields;
517 for (fi, buffer) in field_buffers.iter().enumerate() {
518 sums[base + fi] += f64::from(read_buffer_f32(buffer, i));
519 }
520 }
521
522 let mut order: Vec<u32> = (0..counts.len() as u32).collect();
524 order.sort_by_key(|&id| keys[id as usize]);
525
526 let mut buffers = PointBufferSet::new();
527 for field in fields {
528 buffers.insert(field.name.clone(), PointBuffer::with_capacity(field.dtype, counts.len()));
529 }
530 for &id in &order {
531 let id = id as usize;
532 let inv_count = 1.0 / f64::from(counts[id]);
533 let base = id * n_fields;
534 for (fi, field) in fields.iter().enumerate() {
535 push_field(&mut buffers, field, (sums[base + fi] * inv_count) as f32)?;
536 }
537 }
538
539 PointCloud::try_from_parts(schema, buffers, input.metadata().clone())
540}
541
542#[derive(Clone, Copy, Debug, Default)]
543struct XyzVoxelCell {
544 sum_x: f32,
545 sum_y: f32,
546 sum_z: f32,
547 count: u32,
548}
549
550fn filter_cpu_xyz_centroid_fast(
551 input: &PointCloud,
552 x: &[f32],
553 y: &[f32],
554 z: &[f32],
555 origin: Vec3<f32>,
556 inv_leaf: f32,
557 origin_is_min: bool,
558 u32_voxel_keys: bool,
559) -> SpatialResult<Option<PointCloud>> {
560 let schema = input.schema();
561 if !is_plain_xyz_f32_schema(schema) {
562 return Ok(None);
563 }
564
565 let expected_cells = (x.len() / 2).clamp(16, 1_048_576);
566 let mut cells = Vec::<XyzVoxelCell>::with_capacity(expected_cells);
567 if u32_voxel_keys {
568 let mut key_to_id = XyzVoxelCellMapU32::with_capacity_and_hasher(
569 expected_cells,
570 BuildHasherDefault::<VoxelKeyHasher>::default(),
571 );
572 for i in 0..x.len() {
573 let key = voxel_key_nonnegative_u32(x[i], y[i], z[i], origin, inv_leaf);
574 let id = xyz_cell_id_u32(&mut key_to_id, &mut cells, key);
575 let cell = &mut cells[id];
576 cell.sum_x += x[i];
577 cell.sum_y += y[i];
578 cell.sum_z += z[i];
579 cell.count += 1;
580 }
581 } else if origin_is_min {
582 let mut key_to_id = XyzVoxelCellMap::with_capacity_and_hasher(
583 expected_cells,
584 BuildHasherDefault::<VoxelKeyHasher>::default(),
585 );
586 for i in 0..x.len() {
587 let key = voxel_key_nonnegative(x[i], y[i], z[i], origin, inv_leaf);
588 let id = xyz_cell_id(&mut key_to_id, &mut cells, key);
589 let cell = &mut cells[id];
590 cell.sum_x += x[i];
591 cell.sum_y += y[i];
592 cell.sum_z += z[i];
593 cell.count += 1;
594 }
595 } else {
596 let mut key_to_id = XyzVoxelCellMap::with_capacity_and_hasher(
597 expected_cells,
598 BuildHasherDefault::<VoxelKeyHasher>::default(),
599 );
600 for i in 0..x.len() {
601 let key = voxel_key(x[i], y[i], z[i], origin, inv_leaf);
602 let id = xyz_cell_id(&mut key_to_id, &mut cells, key);
603 let cell = &mut cells[id];
604 cell.sum_x += x[i];
605 cell.sum_y += y[i];
606 cell.sum_z += z[i];
607 cell.count += 1;
608 }
609 }
610
611 let mut out_x = Vec::with_capacity(cells.len());
612 let mut out_y = Vec::with_capacity(cells.len());
613 let mut out_z = Vec::with_capacity(cells.len());
614 for cell in cells {
615 let inv_count = 1.0 / cell.count as f32;
616 out_x.push(cell.sum_x * inv_count);
617 out_y.push(cell.sum_y * inv_count);
618 out_z.push(cell.sum_z * inv_count);
619 }
620
621 let mut out_x = Some(out_x);
622 let mut out_y = Some(out_y);
623 let mut out_z = Some(out_z);
624 let mut buffers = PointBufferSet::new();
625 for field in schema.fields() {
626 let values = match field.semantic {
627 FieldSemantic::PositionX => out_x.take().expect("x emitted once"),
628 FieldSemantic::PositionY => out_y.take().expect("y emitted once"),
629 FieldSemantic::PositionZ => out_z.take().expect("z emitted once"),
630 _ => return Ok(None),
631 };
632 buffers.insert(field.name.clone(), PointBuffer::from_f32(values));
633 }
634
635 PointCloud::try_from_parts(schema.clone(), buffers, input.metadata().clone()).map(Some)
636}
637
638fn xyz_cell_id(
639 key_to_id: &mut XyzVoxelCellMap,
640 cells: &mut Vec<XyzVoxelCell>,
641 key: (i64, i64, i64),
642) -> usize {
643 match key_to_id.entry(key) {
644 std::collections::hash_map::Entry::Occupied(entry) => *entry.get(),
645 std::collections::hash_map::Entry::Vacant(entry) => {
646 let id = cells.len();
647 cells.push(XyzVoxelCell::default());
648 entry.insert(id);
649 id
650 }
651 }
652}
653
654fn xyz_cell_id_u32(
655 key_to_id: &mut XyzVoxelCellMapU32,
656 cells: &mut Vec<XyzVoxelCell>,
657 key: (u32, u32, u32),
658) -> usize {
659 match key_to_id.entry(key) {
660 std::collections::hash_map::Entry::Occupied(entry) => *entry.get(),
661 std::collections::hash_map::Entry::Vacant(entry) => {
662 let id = cells.len();
663 cells.push(XyzVoxelCell::default());
664 entry.insert(id);
665 id
666 }
667 }
668}
669
670fn is_plain_xyz_f32_schema(schema: &PointSchema) -> bool {
671 if schema.fields().len() != 3 {
672 return false;
673 }
674 let mut seen_x = false;
675 let mut seen_y = false;
676 let mut seen_z = false;
677 for field in schema.fields() {
678 if !matches!(field.dtype, DType::F32 | DType::F16) {
679 return false;
680 }
681 match field.semantic {
682 FieldSemantic::PositionX => seen_x = true,
683 FieldSemantic::PositionY => seen_y = true,
684 FieldSemantic::PositionZ => seen_z = true,
685 _ => return false,
686 }
687 }
688 seen_x && seen_y && seen_z
689}
690
691fn read_buffer_f32(buffer: &PointBuffer, index: usize) -> f32 {
693 match buffer {
694 PointBuffer::F32(v) => v[index],
695 PointBuffer::F64(v) => v[index] as f32,
696 PointBuffer::U8(v) => f32::from(v[index]),
697 PointBuffer::U16(v) => f32::from(v[index]),
698 PointBuffer::U32(v) => v[index] as f32,
699 PointBuffer::I32(v) => v[index] as f32,
700 }
701}
702
703fn build_voxel_cells_cpu(
704 x: &[f32],
705 y: &[f32],
706 z: &[f32],
707 origin: Vec3<f32>,
708 inv_leaf: f32,
709) -> VoxelCellMap {
710 let mut cells = VoxelCellMap::default();
711 for index in 0..x.len() {
712 let key = voxel_key(x[index], y[index], z[index], origin, inv_leaf);
713 cells.entry(key).or_default().indices.push(index);
714 }
715 cells
716}
717
718#[cfg(feature = "filter-voxel-gpu")]
719fn build_voxel_cells_gpu(
720 x: &[f32],
721 y: &[f32],
722 z: &[f32],
723 origin: Vec3<f32>,
724 inv_leaf: f32,
725) -> SpatialResult<VoxelCellMap> {
726 use spatialrust_gpu::{compute_voxel_keys, WgpuRuntime};
727
728 let runtime = WgpuRuntime::shared()?;
729 let keys = compute_voxel_keys(&runtime, x, y, z, [origin.x, origin.y, origin.z], inv_leaf)?;
730
731 let mut cells = VoxelCellMap::default();
732 for (index, key) in keys.into_iter().enumerate() {
733 cells.entry(key).or_default().indices.push(index);
734 }
735 Ok(cells)
736}
737
738#[cfg(feature = "filter-voxel-gpu")]
739fn gpu_non_position_fields(schema: &PointSchema) -> Vec<PointField> {
740 schema
741 .fields()
742 .iter()
743 .filter(|field| {
744 !matches!(
745 field.semantic,
746 FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ
747 )
748 })
749 .cloned()
750 .collect()
751}
752
753#[cfg(feature = "filter-voxel-gpu")]
754fn partition_gpu_attribute_fields(fields: &[PointField]) -> (Vec<PointField>, Vec<PointField>) {
755 let mut f32_fields = Vec::new();
756 let mut u8_fields = Vec::new();
757 for field in fields {
758 if field.dtype == DType::U8 {
759 u8_fields.push(field.clone());
760 } else {
761 f32_fields.push(field.clone());
762 }
763 }
764 (f32_fields, u8_fields)
765}
766
767#[cfg(feature = "filter-voxel-gpu")]
768fn collect_attribute_f32_sources(
769 input: &PointCloud,
770 fields: &[PointField],
771) -> SpatialResult<Vec<Vec<f32>>> {
772 let mut sources = Vec::with_capacity(fields.len());
773 for field in fields {
774 let mut values = Vec::with_capacity(input.len());
775 for index in 0..input.len() {
776 values.push(read_field_f32(input, field, index)?);
777 }
778 sources.push(values);
779 }
780 Ok(sources)
781}
782
783#[cfg(feature = "filter-voxel-gpu")]
784fn borrow_attribute_f32_channels<'a>(
785 input: &'a PointCloud,
786 fields: &[PointField],
787) -> SpatialResult<Option<Vec<&'a [f32]>>> {
788 let mut channels = Vec::with_capacity(fields.len());
789 for field in fields {
790 if !matches!(field.dtype, DType::F32 | DType::F16) {
791 return Ok(None);
792 }
793 channels.push(input.field(&field.name)?.as_f32()?);
794 }
795 Ok(Some(channels))
796}
797
798#[cfg(feature = "filter-voxel-gpu")]
799fn collect_attribute_u8_sources(
800 input: &PointCloud,
801 fields: &[PointField],
802) -> SpatialResult<Vec<Vec<u8>>> {
803 let mut sources = Vec::with_capacity(fields.len());
804 for field in fields {
805 let buffer = input.field(&field.name)?;
806 let PointBuffer::U8(values) = buffer else {
807 return Err(SpatialError::UnsupportedDType(field.dtype));
808 };
809 sources.push(values.to_vec());
810 }
811 Ok(sources)
812}
813
814#[cfg(feature = "filter-voxel-gpu")]
815fn assemble_gpu_voxel_output(
816 input: &PointCloud,
817 out_x: Vec<f32>,
818 out_y: Vec<f32>,
819 out_z: Vec<f32>,
820 f32_attribute_fields: &[PointField],
821 f32_attribute_values: Vec<Vec<f32>>,
822 u8_attribute_fields: &[PointField],
823 u8_attribute_values: Vec<Vec<u8>>,
824) -> SpatialResult<PointCloud> {
825 let schema = input.schema().clone();
826 let mut buffers = PointBufferSet::new();
827
828 let x_field = schema
829 .find_semantic(FieldSemantic::PositionX)
830 .ok_or_else(|| SpatialError::MissingField("x".to_owned()))?;
831 let y_field = schema
832 .find_semantic(FieldSemantic::PositionY)
833 .ok_or_else(|| SpatialError::MissingField("y".to_owned()))?;
834 let z_field = schema
835 .find_semantic(FieldSemantic::PositionZ)
836 .ok_or_else(|| SpatialError::MissingField("z".to_owned()))?;
837
838 set_field_from_f32(&mut buffers, x_field, out_x)?;
839 set_field_from_f32(&mut buffers, y_field, out_y)?;
840 set_field_from_f32(&mut buffers, z_field, out_z)?;
841
842 for (field, values) in f32_attribute_fields.iter().zip(f32_attribute_values) {
843 set_field_from_f32(&mut buffers, field, values)?;
844 }
845 for (field, values) in u8_attribute_fields.iter().zip(u8_attribute_values) {
846 set_field_from_u8(&mut buffers, field, values)?;
847 }
848
849 PointCloud::try_from_parts(schema, buffers, input.metadata().clone())
850}
851
852#[cfg(feature = "filter-voxel-gpu")]
853fn filter_gpu_centroid(
854 input: &PointCloud,
855 x: &[f32],
856 y: &[f32],
857 z: &[f32],
858 origin: Vec3<f32>,
859 inv_leaf: f32,
860 attribute_policy: AttributeAggregation,
861) -> SpatialResult<PointCloud> {
862 use spatialrust_gpu::{
863 build_voxel_segments_gpu_from_keys_buffer, compute_voxel_keys_gpu_buffers,
864 downsample_voxel_centroid_gpu, reduce_voxel_centroids_xyz_and_average_multi_gpu,
865 reduce_voxel_centroids_xyz_and_gather_first_multi_gpu, WgpuRuntime,
866 };
867
868 let attribute_fields = gpu_non_position_fields(input.schema());
869 if attribute_fields.is_empty() {
870 let runtime = WgpuRuntime::shared()?;
871 let pipeline = downsample_voxel_centroid_gpu(
872 &runtime,
873 x,
874 y,
875 z,
876 [origin.x, origin.y, origin.z],
877 inv_leaf,
878 )?;
879 return assemble_gpu_voxel_output(
880 input,
881 pipeline.out_x,
882 pipeline.out_y,
883 pipeline.out_z,
884 &[],
885 Vec::new(),
886 &[],
887 Vec::new(),
888 );
889 }
890
891 let (f32_fields, u8_fields) = partition_gpu_attribute_fields(&attribute_fields);
892 let runtime = WgpuRuntime::shared()?;
893 let positions = compute_voxel_keys_gpu_buffers(
894 &runtime,
895 x,
896 y,
897 z,
898 [origin.x, origin.y, origin.z],
899 inv_leaf,
900 )?;
901 let point_count = positions.point_count();
902 let segments = build_voxel_segments_gpu_from_keys_buffer(
903 &runtime,
904 positions.keys_buffer(),
905 point_count,
906 point_count.next_power_of_two(),
907 )?;
908 let owned_f32_sources;
909 let f32_refs: Vec<&[f32]> =
910 if let Some(borrowed) = borrow_attribute_f32_channels(input, &f32_fields)? {
911 borrowed
912 } else {
913 owned_f32_sources = collect_attribute_f32_sources(input, &f32_fields)?;
914 owned_f32_sources.iter().map(Vec::as_slice).collect()
915 };
916 let u8_sources = collect_attribute_u8_sources(input, &u8_fields)?;
917 let u8_refs: Vec<&[u8]> = u8_sources.iter().map(Vec::as_slice).collect();
918 let (out_x, out_y, out_z, f32_values, u8_values) = match attribute_policy {
919 AttributeAggregation::Average => reduce_voxel_centroids_xyz_and_average_multi_gpu(
920 &runtime,
921 positions.x_buffer(),
922 positions.y_buffer(),
923 positions.z_buffer(),
924 &f32_refs,
925 &u8_refs,
926 &segments,
927 )?,
928 AttributeAggregation::First => reduce_voxel_centroids_xyz_and_gather_first_multi_gpu(
929 &runtime,
930 positions.x_buffer(),
931 positions.y_buffer(),
932 positions.z_buffer(),
933 &f32_refs,
934 &u8_refs,
935 &segments,
936 )?,
937 };
938
939 positions.recycle(&runtime);
940
941 assemble_gpu_voxel_output(
942 input,
943 out_x,
944 out_y,
945 out_z,
946 &f32_fields,
947 f32_values,
948 &u8_fields,
949 u8_values,
950 )
951}
952
953#[cfg(feature = "filter-voxel-gpu")]
954fn filter_gpu_approximate_first(
955 input: &PointCloud,
956 x: &[f32],
957 y: &[f32],
958 z: &[f32],
959 origin: Vec3<f32>,
960 inv_leaf: f32,
961 attribute_policy: AttributeAggregation,
962) -> SpatialResult<PointCloud> {
963 use spatialrust_gpu::{
964 build_voxel_segments_gpu_from_keys_buffer, compute_voxel_keys_gpu_buffers,
965 downsample_voxel_approximate_first_gpu, gather_voxel_first_xyz_and_average_multi_gpu,
966 gather_voxel_first_xyz_and_multi_gpu, WgpuRuntime,
967 };
968
969 let attribute_fields = gpu_non_position_fields(input.schema());
970 if attribute_fields.is_empty() {
971 let runtime = WgpuRuntime::shared()?;
972 let pipeline = downsample_voxel_approximate_first_gpu(
973 &runtime,
974 x,
975 y,
976 z,
977 [origin.x, origin.y, origin.z],
978 inv_leaf,
979 )?;
980 return assemble_gpu_voxel_output(
981 input,
982 pipeline.out_x,
983 pipeline.out_y,
984 pipeline.out_z,
985 &[],
986 Vec::new(),
987 &[],
988 Vec::new(),
989 );
990 }
991
992 let (f32_fields, u8_fields) = partition_gpu_attribute_fields(&attribute_fields);
993 let runtime = WgpuRuntime::shared()?;
994 let positions = compute_voxel_keys_gpu_buffers(
995 &runtime,
996 x,
997 y,
998 z,
999 [origin.x, origin.y, origin.z],
1000 inv_leaf,
1001 )?;
1002 let point_count = positions.point_count();
1003 let segments = build_voxel_segments_gpu_from_keys_buffer(
1004 &runtime,
1005 positions.keys_buffer(),
1006 point_count,
1007 point_count.next_power_of_two(),
1008 )?;
1009 let owned_f32_sources;
1010 let f32_refs: Vec<&[f32]> =
1011 if let Some(borrowed) = borrow_attribute_f32_channels(input, &f32_fields)? {
1012 borrowed
1013 } else {
1014 owned_f32_sources = collect_attribute_f32_sources(input, &f32_fields)?;
1015 owned_f32_sources.iter().map(Vec::as_slice).collect()
1016 };
1017 let u8_sources = collect_attribute_u8_sources(input, &u8_fields)?;
1018 let u8_refs: Vec<&[u8]> = u8_sources.iter().map(Vec::as_slice).collect();
1019 let (out_x, out_y, out_z, f32_values, u8_values) = match attribute_policy {
1020 AttributeAggregation::Average => gather_voxel_first_xyz_and_average_multi_gpu(
1021 &runtime,
1022 positions.x_buffer(),
1023 positions.y_buffer(),
1024 positions.z_buffer(),
1025 &f32_refs,
1026 &u8_refs,
1027 &segments,
1028 )?,
1029 AttributeAggregation::First => gather_voxel_first_xyz_and_multi_gpu(
1030 &runtime,
1031 positions.x_buffer(),
1032 positions.y_buffer(),
1033 positions.z_buffer(),
1034 &f32_refs,
1035 &u8_refs,
1036 &segments,
1037 )?,
1038 };
1039
1040 positions.recycle(&runtime);
1041
1042 assemble_gpu_voxel_output(
1043 input,
1044 out_x,
1045 out_y,
1046 out_z,
1047 &f32_fields,
1048 f32_values,
1049 &u8_fields,
1050 u8_values,
1051 )
1052}
1053
1054#[cfg(not(feature = "filter-voxel-gpu"))]
1055fn build_voxel_cells_gpu(
1056 _x: &[f32],
1057 _y: &[f32],
1058 _z: &[f32],
1059 _origin: Vec3<f32>,
1060 _inv_leaf: f32,
1061) -> SpatialResult<VoxelCellMap> {
1062 Err(SpatialError::InvalidArgument(
1063 "GPU voxel downsampling requires the filter-voxel-gpu feature".to_owned(),
1064 ))
1065}
1066
1067fn compute_bounds(x: &[f32], y: &[f32], z: &[f32]) -> (Vec3<f32>, Vec3<f32>) {
1068 let mut min = Vec3::new(x[0], y[0], z[0]);
1069 let mut max = min;
1070 for index in 1..x.len() {
1071 min.x = min.x.min(x[index]);
1072 min.y = min.y.min(y[index]);
1073 min.z = min.z.min(z[index]);
1074 max.x = max.x.max(x[index]);
1075 max.y = max.y.max(y[index]);
1076 max.z = max.z.max(z[index]);
1077 }
1078 (min, max)
1079}
1080
1081fn fits_u32_voxel_key(min: Vec3<f32>, max: Vec3<f32>, inv_leaf: f32) -> bool {
1082 let limit = u32::MAX as f32;
1083 ((max.x - min.x) * inv_leaf) <= limit
1084 && ((max.y - min.y) * inv_leaf) <= limit
1085 && ((max.z - min.z) * inv_leaf) <= limit
1086}
1087
1088fn voxel_key(x: f32, y: f32, z: f32, origin: Vec3<f32>, inv_leaf: f32) -> (i64, i64, i64) {
1089 let ix = ((x - origin.x) * inv_leaf).floor() as i64;
1090 let iy = ((y - origin.y) * inv_leaf).floor() as i64;
1091 let iz = ((z - origin.z) * inv_leaf).floor() as i64;
1092 (ix, iy, iz)
1093}
1094
1095#[inline]
1096fn voxel_key_nonnegative(
1097 x: f32,
1098 y: f32,
1099 z: f32,
1100 origin: Vec3<f32>,
1101 inv_leaf: f32,
1102) -> (i64, i64, i64) {
1103 let ix = ((x - origin.x) * inv_leaf) as i64;
1104 let iy = ((y - origin.y) * inv_leaf) as i64;
1105 let iz = ((z - origin.z) * inv_leaf) as i64;
1106 (ix, iy, iz)
1107}
1108
1109#[inline]
1110fn voxel_key_nonnegative_u32(
1111 x: f32,
1112 y: f32,
1113 z: f32,
1114 origin: Vec3<f32>,
1115 inv_leaf: f32,
1116) -> (u32, u32, u32) {
1117 let ix = ((x - origin.x) * inv_leaf) as u32;
1118 let iy = ((y - origin.y) * inv_leaf) as u32;
1119 let iz = ((z - origin.z) * inv_leaf) as u32;
1120 (ix, iy, iz)
1121}
1122
1123fn append_voxel_point(
1124 input: &PointCloud,
1125 buffers: &mut PointBufferSet,
1126 fields: &[PointField],
1127 cell: &VoxelCell,
1128 mode: VoxelAggregationMode,
1129 attribute_policy: AttributeAggregation,
1130) -> SpatialResult<()> {
1131 let representative = cell.indices[0];
1132 let average_positions = mode == VoxelAggregationMode::Centroid;
1133
1134 for field in fields {
1135 let value = match field.semantic {
1136 FieldSemantic::PositionX | FieldSemantic::PositionY | FieldSemantic::PositionZ => {
1137 if average_positions {
1138 average_field(input, field, &cell.indices)?
1139 } else {
1140 read_field_f32(input, field, representative)?
1141 }
1142 }
1143 _ => match (mode, attribute_policy) {
1144 (VoxelAggregationMode::ApproximateFirst, _) => {
1145 read_field_f32(input, field, representative)?
1146 }
1147 (_, AttributeAggregation::First) => read_field_f32(input, field, representative)?,
1148 (_, AttributeAggregation::Average) => average_field(input, field, &cell.indices)?,
1149 },
1150 };
1151 push_field(buffers, field, value)?;
1152 }
1153 Ok(())
1154}
1155
1156fn average_field(input: &PointCloud, field: &PointField, indices: &[usize]) -> SpatialResult<f32> {
1157 if indices.is_empty() {
1158 return Err(SpatialError::InvalidArgument("cannot average an empty voxel cell".to_owned()));
1159 }
1160 let mut sum = 0.0_f64;
1161 for &index in indices {
1162 sum += f64::from(read_field_f32(input, field, index)?);
1163 }
1164 Ok((sum / indices.len() as f64) as f32)
1165}
1166
1167fn read_field_f32(input: &PointCloud, field: &PointField, index: usize) -> SpatialResult<f32> {
1168 let buffer = input.field(&field.name)?;
1169 match field.dtype {
1170 DType::F32 | DType::F16 => Ok(buffer.as_f32()?[index]),
1171 DType::F64 => {
1172 let PointBuffer::F64(values) = buffer else {
1173 return Err(SpatialError::UnsupportedDType(field.dtype));
1174 };
1175 Ok(values[index] as f32)
1176 }
1177 DType::U8 => {
1178 let PointBuffer::U8(values) = buffer else {
1179 return Err(SpatialError::UnsupportedDType(field.dtype));
1180 };
1181 Ok(f32::from(values[index]))
1182 }
1183 DType::U16 => {
1184 let PointBuffer::U16(values) = buffer else {
1185 return Err(SpatialError::UnsupportedDType(field.dtype));
1186 };
1187 Ok(f32::from(values[index]))
1188 }
1189 DType::I32 => {
1190 let PointBuffer::I32(values) = buffer else {
1191 return Err(SpatialError::UnsupportedDType(field.dtype));
1192 };
1193 Ok(values[index] as f32)
1194 }
1195 DType::U32 => {
1196 let PointBuffer::U32(values) = buffer else {
1197 return Err(SpatialError::UnsupportedDType(field.dtype));
1198 };
1199 Ok(values[index] as f32)
1200 }
1201 }
1202}
1203
1204#[cfg(feature = "filter-voxel-gpu")]
1205fn set_field_from_f32(
1206 buffers: &mut PointBufferSet,
1207 field: &PointField,
1208 values: Vec<f32>,
1209) -> SpatialResult<()> {
1210 let buffer = match field.dtype {
1211 DType::F32 | DType::F16 => PointBuffer::from_f32(values),
1212 DType::F64 => PointBuffer::F64(values.into_iter().map(f64::from).collect()),
1213 DType::U8 => PointBuffer::U8(values.into_iter().map(|value| value.round() as u8).collect()),
1214 DType::U16 => {
1215 PointBuffer::U16(values.into_iter().map(|value| value.round() as u16).collect())
1216 }
1217 DType::I32 => {
1218 PointBuffer::I32(values.into_iter().map(|value| value.round() as i32).collect())
1219 }
1220 DType::U32 => {
1221 PointBuffer::U32(values.into_iter().map(|value| value.round() as u32).collect())
1222 }
1223 };
1224 buffers.insert(field.name.clone(), buffer);
1225 Ok(())
1226}
1227
1228#[cfg(feature = "filter-voxel-gpu")]
1229fn set_field_from_u8(
1230 buffers: &mut PointBufferSet,
1231 field: &PointField,
1232 values: Vec<u8>,
1233) -> SpatialResult<()> {
1234 if field.dtype != DType::U8 {
1235 return Err(SpatialError::UnsupportedDType(field.dtype));
1236 }
1237 buffers.insert(field.name.clone(), PointBuffer::U8(values));
1238 Ok(())
1239}
1240
1241fn push_field(buffers: &mut PointBufferSet, field: &PointField, value: f32) -> SpatialResult<()> {
1242 let buffer = buffers
1243 .get_mut(&field.name)
1244 .ok_or_else(|| SpatialError::MissingField(field.name.clone()))?;
1245 match field.dtype {
1246 DType::F32 | DType::F16 => buffer.push_f32(value),
1247 DType::F64 => buffer.push_f64(f64::from(value)),
1248 DType::U8 => buffer.push_u8(value.round() as u8),
1249 DType::U16 => buffer.push_u16(value.round() as u16),
1250 DType::I32 => buffer.push_i32(value.round() as i32),
1251 DType::U32 => {
1252 let PointBuffer::U32(values) = buffer else {
1253 return Err(SpatialError::UnsupportedDType(field.dtype));
1254 };
1255 values.push(value.round() as u32);
1256 Ok(())
1257 }
1258 }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::{VoxelGridDownsample, VoxelGridDownsampleConfig};
1264 use crate::PointCloudFilter;
1265 #[cfg(feature = "filter-voxel-gpu")]
1266 use spatialrust_core::HasNormals3;
1267 use spatialrust_core::{HasIntensity, HasPositions3, PointCloudBuilder, StandardSchemas};
1268 use spatialrust_math::Vec3;
1269
1270 #[test]
1271 fn centroid_downsample_reduces_points() {
1272 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1273 builder.push_point([0.0, 0.0, 0.0]).unwrap();
1274 builder.push_point([0.1, 0.0, 0.0]).unwrap();
1275 builder.push_point([1.0, 0.0, 0.0]).unwrap();
1276 builder.push_point([1.1, 0.0, 0.0]).unwrap();
1277 let input = builder.build().unwrap();
1278
1279 let filter = VoxelGridDownsample::new(
1280 VoxelGridDownsampleConfig::centroid(0.5).without_gpu_min_points(),
1281 );
1282 let output = filter.filter(&input).unwrap();
1283 assert_eq!(output.len(), 2);
1284
1285 let (x, _, _) = output.positions3().unwrap();
1286 assert!((x[0] - 0.05).abs() < 1e-5);
1287 assert!((x[1] - 1.05).abs() < 1e-5);
1288 }
1289
1290 #[test]
1291 fn explicit_origin_uses_floor_for_negative_voxels() {
1292 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1293 builder.push_point([-0.6, 0.0, 0.0]).unwrap();
1294 builder.push_point([-0.1, 0.0, 0.0]).unwrap();
1295 builder.push_point([0.1, 0.0, 0.0]).unwrap();
1296 let input = builder.build().unwrap();
1297
1298 let mut config = VoxelGridDownsampleConfig::centroid(1.0).without_gpu_min_points();
1299 config.origin = Some(Vec3::new(0.0, 0.0, 0.0));
1300 let output = VoxelGridDownsample::new(config).filter(&input).unwrap();
1301
1302 let (x, _, _) = output.positions3().unwrap();
1303 assert_eq!(output.len(), 2);
1304 assert!((x[0] - -0.35).abs() < 1e-5);
1305 assert!((x[1] - 0.1).abs() < 1e-5);
1306 }
1307
1308 #[test]
1309 fn default_origin_fast_path_matches_explicit_min_origin() {
1310 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1311 for point in
1312 [[-1.2, 0.0, 0.0], [-1.1, 0.1, 0.0], [-0.7, 0.0, 0.0], [0.4, 1.0, 0.0], [0.6, 1.0, 0.0]]
1313 {
1314 builder.push_point(point).unwrap();
1315 }
1316 let input = builder.build().unwrap();
1317
1318 let default_output = VoxelGridDownsample::new(VoxelGridDownsampleConfig::centroid(0.5))
1319 .filter(&input)
1320 .unwrap();
1321
1322 let mut config = VoxelGridDownsampleConfig::centroid(0.5);
1323 config.origin = Some(Vec3::new(-1.2, 0.0, 0.0));
1324 let explicit_output = VoxelGridDownsample::new(config).filter(&input).unwrap();
1325
1326 assert_eq!(default_output, explicit_output);
1327 }
1328
1329 #[test]
1330 fn approximate_keeps_first_point_in_voxel() {
1331 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
1332 builder.push_point([0.0, 0.0, 0.0, 0.2]).unwrap();
1333 builder.push_point([0.1, 0.0, 0.0, 0.9]).unwrap();
1334 let input = builder.build().unwrap();
1335
1336 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::approximate(1.0));
1337 let output = filter.filter(&input).unwrap();
1338 assert_eq!(output.len(), 1);
1339 assert_eq!(output.intensity().unwrap()[0], 0.2);
1340 }
1341
1342 #[test]
1343 fn average_intensity_in_centroid_mode() {
1344 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
1345 builder.push_point([0.0, 0.0, 0.0, 0.2]).unwrap();
1346 builder.push_point([0.1, 0.0, 0.0, 0.8]).unwrap();
1347 let input = builder.build().unwrap();
1348
1349 let filter = VoxelGridDownsample::new(
1350 VoxelGridDownsampleConfig::centroid(1.0).without_gpu_min_points(),
1351 );
1352 let output = filter.filter(&input).unwrap();
1353 assert_eq!(output.len(), 1);
1354 assert!((output.intensity().unwrap()[0] - 0.5).abs() < 1e-5);
1355 }
1356
1357 #[test]
1358 fn rejects_non_positive_leaf_size() {
1359 let mut builder = PointCloudBuilder::xyz();
1360 builder.push_point([0.0, 0.0, 0.0]).unwrap();
1361 let input = builder.build().unwrap();
1362 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::centroid(0.0));
1363 assert!(filter.filter(&input).is_err());
1364 }
1365
1366 #[cfg(feature = "filter-voxel-gpu")]
1367 #[test]
1368 fn gpu_policy_matches_cpu_downsample() {
1369 use spatialrust_core::ExecutionPolicy;
1370
1371 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1372 builder.push_point([0.0, 0.0, 0.0]).unwrap();
1373 builder.push_point([0.1, 0.0, 0.0]).unwrap();
1374 builder.push_point([1.0, 0.0, 0.0]).unwrap();
1375 builder.push_point([1.1, 0.0, 0.0]).unwrap();
1376 let input = builder.build().unwrap();
1377
1378 let filter = VoxelGridDownsample::new(
1379 VoxelGridDownsampleConfig::centroid(0.5).without_gpu_min_points(),
1380 );
1381 let cpu = filter.filter(&input).unwrap();
1382 let gpu = filter
1383 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Wgpu))
1384 .unwrap();
1385
1386 assert_eq!(cpu.len(), gpu.len());
1387 let (cpu_x, _, _) = cpu.positions3().unwrap();
1388 let (gpu_x, _, _) = gpu.positions3().unwrap();
1389 assert!((cpu_x[0] - gpu_x[0]).abs() < 1e-5);
1390 assert!((cpu_x[1] - gpu_x[1]).abs() < 1e-5);
1391 }
1392
1393 #[cfg(feature = "filter-voxel-gpu")]
1394 #[test]
1395 fn gpu_policy_averages_attributes_on_gpu() {
1396 use spatialrust_core::ExecutionPolicy;
1397
1398 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
1399 builder.push_point([0.0, 0.0, 0.0, 0.2]).unwrap();
1400 builder.push_point([0.1, 0.0, 0.0, 0.8]).unwrap();
1401 let input = builder.build().unwrap();
1402
1403 let filter = VoxelGridDownsample::new(
1404 VoxelGridDownsampleConfig::centroid(1.0).without_gpu_min_points(),
1405 );
1406 let cpu = filter.filter(&input).unwrap();
1407 let gpu = filter
1408 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Wgpu))
1409 .unwrap();
1410
1411 assert_eq!(cpu.len(), gpu.len());
1412 assert!((cpu.intensity().unwrap()[0] - gpu.intensity().unwrap()[0]).abs() < 1e-5);
1413 }
1414
1415 #[cfg(feature = "filter-voxel-gpu")]
1416 #[test]
1417 fn gpu_policy_averages_u8_rgb_on_gpu() {
1418 use spatialrust_core::{ExecutionPolicy, PointBuffer};
1419
1420 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzrgb());
1421 builder.push_point([0.0, 0.0, 0.0, 10.0, 20.0, 30.0]).unwrap();
1422 builder.push_point([0.1, 0.0, 0.0, 30.0, 40.0, 50.0]).unwrap();
1423 let input = builder.build().unwrap();
1424
1425 let filter = VoxelGridDownsample::new(
1426 VoxelGridDownsampleConfig::centroid(1.0).without_gpu_min_points(),
1427 );
1428 let cpu = filter.filter(&input).unwrap();
1429 let gpu = filter
1430 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Wgpu))
1431 .unwrap();
1432
1433 assert_eq!(cpu.len(), gpu.len());
1434 for channel in ["r", "g", "b"] {
1435 let PointBuffer::U8(cpu_values) = cpu.field(channel).unwrap() else {
1436 panic!("expected u8 channel");
1437 };
1438 let PointBuffer::U8(gpu_values) = gpu.field(channel).unwrap() else {
1439 panic!("expected u8 channel");
1440 };
1441 assert_eq!(cpu_values, gpu_values);
1442 }
1443 }
1444
1445 #[cfg(feature = "filter-voxel-gpu")]
1446 #[test]
1447 fn gpu_approximate_first_matches_cpu_downsample() {
1448 use spatialrust_core::ExecutionPolicy;
1449
1450 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
1451 builder.push_point([0.0, 0.0, 0.0, 0.2]).unwrap();
1452 builder.push_point([0.1, 0.0, 0.0, 0.9]).unwrap();
1453 builder.push_point([1.0, 0.0, 0.0, 10.0]).unwrap();
1454 builder.push_point([1.1, 0.0, 0.0, 20.0]).unwrap();
1455 let input = builder.build().unwrap();
1456
1457 let filter = VoxelGridDownsample::new(
1458 VoxelGridDownsampleConfig::approximate(0.5).without_gpu_min_points(),
1459 );
1460 let cpu = filter.filter(&input).unwrap();
1461 let gpu = filter
1462 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Wgpu))
1463 .unwrap();
1464
1465 assert_eq!(cpu.len(), gpu.len());
1466 let (cpu_x, _, _) = cpu.positions3().unwrap();
1467 let (gpu_x, _, _) = gpu.positions3().unwrap();
1468 for index in 0..cpu.len() {
1469 assert!((cpu_x[index] - gpu_x[index]).abs() < 1e-5);
1470 }
1471 assert_eq!(cpu.intensity().unwrap(), gpu.intensity().unwrap());
1472 }
1473
1474 #[cfg(feature = "filter-voxel-gpu")]
1475 #[test]
1476 fn gpu_approximate_first_xyzinormal_matches_cpu_downsample() {
1477 use spatialrust_core::ExecutionPolicy;
1478
1479 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzinormal());
1480 builder.push_point([0.0, 0.0, 0.0, 0.2, 0.0, 0.0, 1.0]).unwrap();
1481 builder.push_point([0.1, 0.0, 0.0, 0.9, 0.1, 0.0, 1.0]).unwrap();
1482 builder.push_point([1.0, 0.0, 0.0, 10.0, 0.0, 1.0, 0.0]).unwrap();
1483 builder.push_point([1.1, 0.0, 0.0, 20.0, 0.0, 0.0, 1.0]).unwrap();
1484 let input = builder.build().unwrap();
1485
1486 let filter = VoxelGridDownsample::new(
1487 VoxelGridDownsampleConfig::approximate(0.5).without_gpu_min_points(),
1488 );
1489 let cpu = filter.filter(&input).unwrap();
1490 let gpu = filter
1491 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Wgpu))
1492 .unwrap();
1493
1494 assert_eq!(cpu.len(), gpu.len());
1495 let (cpu_x, cpu_y, cpu_z) = cpu.positions3().unwrap();
1496 let (gpu_x, gpu_y, gpu_z) = gpu.positions3().unwrap();
1497 for index in 0..cpu.len() {
1498 assert!((cpu_x[index] - gpu_x[index]).abs() < 1e-5);
1499 assert!((cpu_y[index] - gpu_y[index]).abs() < 1e-5);
1500 assert!((cpu_z[index] - gpu_z[index]).abs() < 1e-5);
1501 }
1502 assert_eq!(cpu.intensity().unwrap(), gpu.intensity().unwrap());
1503 let (cpu_nx, cpu_ny, cpu_nz) = cpu.normals3().unwrap();
1504 let (gpu_nx, gpu_ny, gpu_nz) = gpu.normals3().unwrap();
1505 assert_eq!(cpu_nx, gpu_nx);
1506 assert_eq!(cpu_ny, gpu_ny);
1507 assert_eq!(cpu_nz, gpu_nz);
1508 }
1509
1510 #[cfg(feature = "filter-voxel-gpu")]
1511 #[test]
1512 fn unsupported_explicit_gpu_policy_does_not_fallback_to_cpu() {
1513 use spatialrust_core::ExecutionPolicy;
1514
1515 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1516 builder.push_point([0.0, 0.0, 0.0]).unwrap();
1517 builder.push_point([0.1, 0.0, 0.0]).unwrap();
1518 let input = builder.build().unwrap();
1519
1520 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::centroid(0.5));
1521 let cpu = filter.filter(&input).unwrap();
1522 let error = filter
1523 .filter_with_policy(&input, ExecutionPolicy::Gpu(spatialrust_core::DeviceKind::Cuda))
1524 .unwrap_err();
1525 assert!(matches!(error, spatialrust_core::SpatialError::InvalidArgument(_)));
1526 assert_eq!(cpu.len(), 1);
1527 }
1528
1529 #[cfg(feature = "filter-voxel-gpu")]
1530 #[test]
1531 fn auto_policy_uses_cpu_for_small_clouds() {
1532 use spatialrust_core::ExecutionPolicy;
1533
1534 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyz());
1535 builder.push_point([0.0, 0.0, 0.0]).unwrap();
1536 builder.push_point([0.1, 0.0, 0.0]).unwrap();
1537 let input = builder.build().unwrap();
1538
1539 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::centroid(0.5));
1540 let cpu = filter.filter(&input).unwrap();
1541 let auto = filter.filter_with_policy(&input, ExecutionPolicy::Auto).unwrap();
1542
1543 assert_eq!(cpu.len(), auto.len());
1544 let (cpu_x, _, _) = cpu.positions3().unwrap();
1545 let (auto_x, _, _) = auto.positions3().unwrap();
1546 assert!((cpu_x[0] - auto_x[0]).abs() < 1e-5);
1547 }
1548
1549 #[cfg(feature = "filter-voxel-gpu")]
1550 #[test]
1551 fn default_gpu_thresholds_match_their_independent_receipts() {
1552 use super::{
1553 VoxelGridDownsampleConfig, DEFAULT_GPU_MIN_POINTS, DEFAULT_GPU_MIN_POINTS_APPROXIMATE,
1554 };
1555
1556 #[allow(clippy::assertions_on_constants)]
1557 {
1558 assert_eq!(DEFAULT_GPU_MIN_POINTS, usize::MAX);
1559 assert_eq!(DEFAULT_GPU_MIN_POINTS_APPROXIMATE, 2_000_000);
1560 }
1561
1562 let centroid = VoxelGridDownsampleConfig::centroid(0.5);
1563 let approximate = VoxelGridDownsampleConfig::approximate(0.5);
1564 assert_eq!(centroid.gpu_min_points, Some(DEFAULT_GPU_MIN_POINTS));
1565 assert_eq!(approximate.gpu_min_points, Some(DEFAULT_GPU_MIN_POINTS_APPROXIMATE));
1566 }
1567
1568 #[test]
1569 fn effective_gpu_min_points_blocks_heavy_approximate_schema() {
1570 use super::{
1571 VoxelGridDownsampleConfig, APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS,
1572 DEFAULT_GPU_MIN_POINTS_APPROXIMATE, DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY,
1573 };
1574
1575 let approximate = VoxelGridDownsampleConfig::approximate(1.0);
1576 assert_eq!(
1577 approximate.effective_gpu_min_points(&StandardSchemas::point_xyz()),
1578 Some(DEFAULT_GPU_MIN_POINTS_APPROXIMATE)
1579 );
1580 assert_eq!(
1581 approximate.effective_gpu_min_points(&StandardSchemas::point_xyzinormal()),
1582 Some(DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY)
1583 );
1584 assert!(
1585 super::count_non_position_f32_fields(&StandardSchemas::point_xyzinormal())
1586 >= APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS
1587 );
1588 }
1589
1590 #[cfg(feature = "filter-voxel-gpu")]
1591 #[test]
1592 fn auto_approximate_first_uses_cpu_for_xyzinormal() {
1593 use spatialrust_core::ExecutionPolicy;
1594
1595 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzinormal());
1596 for index in 0..128 {
1597 builder
1598 .push_point([
1599 (index % 16) as f32 * 0.1,
1600 (index / 16) as f32 * 0.1,
1601 0.0,
1602 0.5,
1603 0.0,
1604 0.0,
1605 1.0,
1606 ])
1607 .unwrap();
1608 }
1609 let input = builder.build().unwrap();
1610
1611 let mut config = VoxelGridDownsampleConfig::approximate(0.5);
1612 config.gpu_min_points = Some(10);
1613 let filter = VoxelGridDownsample::new(config);
1614 let cpu = filter.filter(&input).unwrap();
1615 let auto = filter.filter_with_policy(&input, ExecutionPolicy::Auto).unwrap();
1616
1617 assert_eq!(cpu.len(), auto.len());
1618 let (cpu_x, _, _) = cpu.positions3().unwrap();
1619 let (auto_x, _, _) = auto.positions3().unwrap();
1620 for index in 0..cpu.len() {
1621 assert!((cpu_x[index] - auto_x[index]).abs() < 1e-5);
1622 }
1623 }
1624
1625 #[cfg(feature = "filter-voxel-gpu")]
1626 fn synthetic_xyzinormal_plane(point_count: usize) -> spatialrust_core::PointCloud {
1627 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzinormal());
1628 for index in 0..point_count {
1629 let x = (index % 256) as f32 * 0.1;
1630 let y = ((index / 256) % 256) as f32 * 0.1;
1631 let intensity = (index % 256) as f32;
1632 builder.push_point([x, y, 0.0, intensity, 0.0, 0.0, 1.0]).unwrap();
1633 }
1634 builder.build().unwrap()
1635 }
1636
1637 #[cfg(feature = "filter-voxel-gpu")]
1638 #[test]
1639 fn auto_approximate_first_uses_cpu_below_heavy_threshold() {
1640 use spatialrust_core::ExecutionPolicy;
1641
1642 const POINT_COUNT: usize = 500_000;
1643 let input = synthetic_xyzinormal_plane(POINT_COUNT);
1644 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::approximate(4.0));
1645 let cpu = filter.filter(&input).unwrap();
1646 let auto = filter.filter_with_policy(&input, ExecutionPolicy::Auto).unwrap();
1647
1648 assert_eq!(cpu.len(), auto.len());
1649 let (cpu_x, cpu_y, cpu_z) = cpu.positions3().unwrap();
1650 let (auto_x, auto_y, auto_z) = auto.positions3().unwrap();
1651 for index in 0..cpu.len() {
1652 assert!((cpu_x[index] - auto_x[index]).abs() < 1e-4);
1653 assert!((cpu_y[index] - auto_y[index]).abs() < 1e-4);
1654 assert!((cpu_z[index] - auto_z[index]).abs() < 1e-4);
1655 }
1656 }
1657
1658 #[cfg(feature = "filter-voxel-gpu")]
1659 #[test]
1660 fn auto_approximate_first_uses_gpu_at_heavy_threshold() {
1661 use spatialrust_core::{DeviceKind, ExecutionPolicy};
1662
1663 const POINT_COUNT: usize = 1_000_000;
1664 let input = synthetic_xyzinormal_plane(POINT_COUNT);
1665 let filter = VoxelGridDownsample::new(VoxelGridDownsampleConfig::approximate(4.0));
1666 let gpu =
1667 filter.filter_with_policy(&input, ExecutionPolicy::Gpu(DeviceKind::Wgpu)).unwrap();
1668 let auto = filter.filter_with_policy(&input, ExecutionPolicy::Auto).unwrap();
1669
1670 assert_eq!(gpu.len(), auto.len());
1671 let (gpu_x, gpu_y, gpu_z) = gpu.positions3().unwrap();
1672 let (auto_x, auto_y, auto_z) = auto.positions3().unwrap();
1673 for index in 0..gpu.len() {
1674 assert!((gpu_x[index] - auto_x[index]).abs() < 1e-4);
1675 assert!((gpu_y[index] - auto_y[index]).abs() < 1e-4);
1676 assert!((gpu_z[index] - auto_z[index]).abs() < 1e-4);
1677 }
1678 }
1679}