Skip to main content

spatialrust_filtering/
voxel.rs

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/// Voxel keys are small integer tuples, so a fast multiply-rotate hasher (à la
16/// FxHash) beats the default SipHash by a wide margin on the cell map.
17#[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
63/// Cell map keyed by integer voxel coordinates, using the fast voxel hasher.
64type 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/// Voxel aggregation strategy.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum VoxelAggregationMode {
71    /// Average all points in each voxel (centroid).
72    #[default]
73    Centroid,
74    /// Keep the first point that falls into each voxel.
75    ApproximateFirst,
76}
77
78/// Attribute aggregation policy for non-position fields.
79#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
80pub enum AttributeAggregation {
81    /// Average numeric attributes within a voxel.
82    #[default]
83    Average,
84    /// Keep the first point's attribute values.
85    First,
86}
87
88/// Default minimum point count before GPU voxel downsampling is selected (centroid).
89///
90/// The 2026-07-16 end-to-end rebaseline found no GPU crossover through 2M
91/// `point_xyzi` points after the CPU fast paths landed. Auto therefore stays on
92/// CPU until a new crossover is demonstrated. Explicit GPU execution remains
93/// available with [`VoxelGridDownsampleConfig::without_gpu_min_points`].
94pub const DEFAULT_GPU_MIN_POINTS: usize = usize::MAX;
95
96/// Default minimum point count before GPU approximate-first downsampling is selected.
97///
98/// Approximate-first pays a higher gather/readback cost than centroid. End-to-end
99/// benches through 1M still favor CPU (~45 ms vs ~50 ms at 1M); auto-GPU is deferred
100/// until a crossover is measured above that range.
101pub const DEFAULT_GPU_MIN_POINTS_APPROXIMATE: usize = 2_000_000;
102
103/// Non-position F32 attribute count at/above which approximate-first Auto uses a higher GPU threshold.
104///
105/// Epic 38: `point_xyzinormal` approximate-first GPU lost at all measured scales.
106/// Epic 46: upload pool + zero-copy attrs restored GPU crossover at 1M+.
107pub const APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS: usize = 4;
108
109/// Auto GPU threshold for approximate-first on attribute-heavy schemas (e.g. xyzinormal).
110pub const DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY: usize = 1_000_000;
111
112/// Configuration for voxel-grid downsampling.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub struct VoxelGridDownsampleConfig {
115    /// Voxel edge length in meters.
116    pub leaf_size: f32,
117    /// Optional grid origin. Defaults to the cloud minimum corner.
118    pub origin: Option<Vec3<f32>>,
119    /// Position aggregation mode.
120    pub mode: VoxelAggregationMode,
121    /// Aggregation policy for other fields.
122    pub attribute_policy: AttributeAggregation,
123    /// Minimum input point count before GPU execution is considered worthwhile.
124    ///
125    /// `None` always uses GPU when requested. Defaults follow local bench results:
126    /// centroid remains on CPU pending a new crossover; approximate-first uses
127    /// its separately measured thresholds.
128    ///
129    /// Approximate-first Auto also consults the input schema: clouds with
130    /// `APPROXIMATE_HEAVY_F32_ATTRIBUTE_CHANNELS` or more non-position F32 fields
131    /// (e.g. `point_xyzinormal`) use `DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY`.
132    pub gpu_min_points: Option<usize>,
133}
134
135impl VoxelGridDownsampleConfig {
136    /// Creates a centroid downsampling config with uniform leaf size.
137    #[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    /// Creates an approximate first-point downsampling config.
149    #[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    /// Disables the GPU point-count heuristic so GPU is always used when requested.
161    #[must_use]
162    pub const fn without_gpu_min_points(mut self) -> Self {
163        self.gpu_min_points = None;
164        self
165    }
166
167    /// Returns the point-count threshold used by [`ExecutionPolicy::Auto`].
168    ///
169    /// Approximate-first mode raises the effective threshold to
170    /// `DEFAULT_GPU_MIN_POINTS_APPROXIMATE_HEAVY` when the schema carries many F32
171    /// attributes (Epic 38 regression, Epic 46 crossover at 1M+).
172    #[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/// Voxel-grid downsampling filter.
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct VoxelGridDownsample {
188    config: VoxelGridDownsampleConfig,
189}
190
191impl VoxelGridDownsample {
192    /// Creates a filter from config.
193    #[must_use]
194    pub const fn new(config: VoxelGridDownsampleConfig) -> Self {
195        Self { config }
196    }
197
198    /// Returns the filter config.
199    #[must_use]
200    pub const fn config(&self) -> VoxelGridDownsampleConfig {
201        self.config
202    }
203
204    /// Applies the filter using the requested execution policy.
205    ///
206    /// GPU execution assigns voxel keys on wgpu, builds segments with GPU sorting,
207    /// and performs centroid or approximate-first aggregation on wgpu.
208    ///
209    /// [`ExecutionPolicy::Auto`] picks GPU only when the input meets
210    /// [`VoxelGridDownsampleConfig::gpu_min_points`]. An explicit GPU request is
211    /// strict and does not use the point-count heuristic; use `Auto` when CPU
212    /// fallback is desired.
213    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    /// Applies the filter and returns execution/transfer accounting.
222    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        // Fast path: the common centroid + average case (the default config and
311        // what PCL's VoxelGrid does) is a single pass that resolves field
312        // buffers once and accumulates per-cell sums into flat arrays, avoiding a
313        // per-cell index Vec and a string-keyed field lookup per point.
314        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    // The host-staged voxel kernels upload XYZ and each attribute channel, then
426    // read back the reduced channels. U8 channels use a four-byte GPU storage
427    // lane, so the receipt reports the actual staged payload rather than the
428    // compact host representation.
429    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
460/// Single-pass centroid voxel downsampling for the default (Centroid + Average)
461/// case. Resolves every field's buffer once, then accumulates per-cell sums into
462/// flat arrays keyed by a sequential cell id, so there is no per-cell allocation
463/// and no per-point field lookup.
464fn 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    // Resolve each field's backing buffer once.
492    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    // Flat `cell * n_fields + field` accumulator of f64 sums.
500    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    // Deterministic output: emit cells in voxel-key order.
523    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
691/// Reads any numeric buffer column as `f32` by index.
692fn 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}