Skip to main content

spatialrust_io/copc/
reader.rs

1use std::path::Path;
2
3use copc_streaming::{ByteSource, CopcStreamingReader, FileSource};
4#[cfg(feature = "streaming")]
5use copc_streaming::{DecompressedChunk, VoxelKey};
6use las::Header;
7use spatialrust_core::{PointCloud, PointSchema, SpatialMetadata};
8
9use crate::copc::query::{CopcFileInfo, CopcQuery};
10use crate::error::{copc_parse, IoError};
11use crate::las::{metadata_from_las_header, point_cloud_from_las_points, schema_for_las_header};
12use crate::{PointReader, ReadOptions};
13
14#[cfg(feature = "streaming")]
15use crate::streaming::{records_io, FormatStreamState};
16#[cfg(feature = "streaming")]
17use spatialrust_records::{
18    BoundedSpatialRecordSource, CancellationToken, MemoryReservation, MemoryTracker, RecordsResult,
19    SchemaDescriptor, SpatialRecordChunk, StreamOptions,
20};
21
22/// Reads point clouds from COPC files.
23pub struct CopcReader {
24    path: std::path::PathBuf,
25    metadata: SpatialMetadata,
26    schema: PointSchema,
27    file_info: CopcFileInfo,
28}
29
30impl CopcReader {
31    /// Opens a COPC file and parses its header eagerly.
32    pub fn open(path: impl AsRef<Path>) -> Result<Self, IoError> {
33        let path = path.as_ref().to_path_buf();
34        let source = FileSource::open(&path).map_err(|error| copc_parse(error.to_string()))?;
35        let (header, file_info) = pollster::block_on(read_header_info(source))?;
36        Ok(Self {
37            schema: schema_for_las_header(&header),
38            metadata: metadata_from_las_header(),
39            file_info,
40            path,
41        })
42    }
43
44    /// Returns COPC header metadata parsed at open time.
45    #[must_use]
46    pub fn file_info(&self) -> &CopcFileInfo {
47        &self.file_info
48    }
49
50    /// Returns the root octree bounds for this file.
51    #[must_use]
52    pub fn root_bounds(&self) -> crate::copc::CopcBounds {
53        self.file_info.root_bounds
54    }
55
56    /// Reads points matching a spatial query.
57    pub fn read_query(&mut self, query: &CopcQuery) -> Result<PointCloud, IoError> {
58        read_copc_file_with_query(&self.path, query)
59    }
60}
61
62impl PointReader for CopcReader {
63    fn schema(&self) -> spatialrust_core::SpatialResult<PointSchema> {
64        Ok(self.schema.clone())
65    }
66
67    fn metadata(&self) -> spatialrust_core::SpatialResult<SpatialMetadata> {
68        Ok(self.metadata.clone())
69    }
70
71    fn read(&mut self, _options: &ReadOptions) -> spatialrust_core::SpatialResult<PointCloud> {
72        read_copc_file(&self.path)
73            .map_err(|error| spatialrust_core::SpatialError::Io(error.to_string()))
74    }
75}
76
77pub(crate) async fn read_header_info<S: ByteSource>(
78    source: S,
79) -> Result<(Header, CopcFileInfo), IoError> {
80    let reader =
81        CopcStreamingReader::open(source).await.map_err(|error| copc_parse(error.to_string()))?;
82    let las_header = reader.header().las_header().clone();
83    let copc_info = reader.copc_info();
84    let root = copc_info.root_bounds();
85    let file_info = CopcFileInfo {
86        root_bounds: crate::copc::CopcBounds::new(root.min, root.max),
87        spacing: copc_info.spacing,
88        point_count: las_header.number_of_points(),
89    };
90    Ok((las_header, file_info))
91}
92
93/// Reads COPC header metadata without loading points.
94pub fn read_copc_file_info(path: impl AsRef<Path>) -> Result<CopcFileInfo, IoError> {
95    let source = FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?;
96    pollster::block_on(async { read_header_info(source).await.map(|(_, info)| info) })
97}
98
99/// Reads all points from a COPC file on disk.
100pub fn read_copc(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
101    read_copc_file(path)
102}
103
104/// Reads all points from a COPC file on disk.
105pub fn read_copc_file(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
106    let source = FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?;
107    pollster::block_on(read_copc_from_byte_source(source, None))
108}
109
110/// Reads points inside a bounding box at full available detail.
111pub fn read_copc_file_in_bounds(
112    path: impl AsRef<Path>,
113    bounds: crate::copc::CopcBounds,
114) -> Result<PointCloud, IoError> {
115    read_copc_file_with_query(path, &CopcQuery::bounds(bounds))
116}
117
118/// Reads points using a spatial bounds and optional LOD limit.
119pub fn read_copc_file_with_query(
120    path: impl AsRef<Path>,
121    query: &CopcQuery,
122) -> Result<PointCloud, IoError> {
123    query.validate()?;
124    let source = FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?;
125    pollster::block_on(read_copc_from_byte_source(source, Some(query)))
126}
127
128pub(crate) async fn read_copc_from_byte_source<S: ByteSource>(
129    source: S,
130    query: Option<&CopcQuery>,
131) -> Result<PointCloud, IoError> {
132    let mut reader =
133        CopcStreamingReader::open(source).await.map_err(|error| copc_parse(error.to_string()))?;
134
135    let las_header = reader.header().las_header().clone();
136    let schema = schema_for_las_header(&las_header);
137    let metadata = metadata_from_las_header();
138
139    let points = match query {
140        None => read_all_points(&mut reader).await?,
141        Some(query) => read_query_points(&mut reader, query).await?,
142    };
143
144    point_cloud_from_las_points(schema, metadata, points)
145}
146
147async fn read_all_points<S: ByteSource>(
148    reader: &mut CopcStreamingReader<S>,
149) -> Result<Vec<las::Point>, IoError> {
150    reader.load_all_hierarchy().await.map_err(|error| copc_parse(error.to_string()))?;
151
152    let mut points = Vec::new();
153    for (key, entry) in reader.entries() {
154        if entry.point_count == 0 {
155            continue;
156        }
157        let chunk = reader.fetch_chunk(key).await.map_err(|error| copc_parse(error.to_string()))?;
158        let chunk_points =
159            reader.read_points(&chunk).map_err(|error| copc_parse(error.to_string()))?;
160        points.extend(chunk_points);
161    }
162    Ok(points)
163}
164
165async fn read_query_points<S: ByteSource>(
166    reader: &mut CopcStreamingReader<S>,
167    query: &CopcQuery,
168) -> Result<Vec<las::Point>, IoError> {
169    let bounds = query.bounds.to_aabb();
170    if let Some(max_level) = query.max_level_for_spacing(reader.copc_info().spacing) {
171        reader
172            .query_points_to_level(&bounds, max_level)
173            .await
174            .map_err(|error| copc_parse(error.to_string()))
175    } else {
176        reader.query_points(&bounds).await.map_err(|error| copc_parse(error.to_string()))
177    }
178}
179
180/// Bounded, deterministic COPC source over any random-access byte source.
181#[cfg(feature = "streaming")]
182pub struct CopcChunkSource<S: ByteSource> {
183    reader: CopcStreamingReader<S>,
184    keys: Vec<VoxelKey>,
185    query_bounds: Option<copc_streaming::Aabb>,
186    metadata: SpatialMetadata,
187    state: FormatStreamState,
188    key_index: usize,
189    current: Option<DecompressedChunk>,
190    current_reservation: Option<MemoryReservation>,
191    current_offset: u32,
192}
193
194#[cfg(feature = "streaming")]
195impl<S: ByteSource> CopcChunkSource<S> {
196    /// Opens a byte source, loads matching hierarchy metadata, and orders nodes
197    /// by `(level, x, y, z)` for repeatable chunk identities.
198    pub fn from_source(
199        source: S,
200        query: Option<CopcQuery>,
201        options: StreamOptions,
202        cancellation: CancellationToken,
203    ) -> Result<Self, IoError> {
204        if let Some(query) = query {
205            query.validate()?;
206        }
207        let mut reader = pollster::block_on(CopcStreamingReader::open(source))
208            .map_err(|error| copc_parse(error.to_string()))?;
209        let query_bounds = query.map(|query| query.bounds.to_aabb());
210        pollster::block_on(async {
211            match (query, query_bounds.as_ref()) {
212                (Some(query), Some(bounds)) => {
213                    if let Some(level) = query.max_level_for_spacing(reader.copc_info().spacing) {
214                        reader.load_hierarchy_for_bounds_to_level(bounds, level).await
215                    } else {
216                        reader.load_hierarchy_for_bounds(bounds).await
217                    }
218                }
219                _ => reader.load_all_hierarchy().await,
220            }
221        })
222        .map_err(|error| copc_parse(error.to_string()))?;
223
224        let root = reader.copc_info().root_bounds();
225        let max_level =
226            query.and_then(|query| query.max_level_for_spacing(reader.copc_info().spacing));
227        let mut keys: Vec<_> = reader
228            .entries()
229            .filter(|(key, entry)| {
230                entry.point_count > 0
231                    && max_level.map_or(true, |level| key.level <= level)
232                    && query_bounds
233                        .as_ref()
234                        .map_or(true, |bounds| key.bounds(&root).intersects(bounds))
235            })
236            .map(|(key, _)| *key)
237            .collect();
238        keys.sort_by_key(|key| (key.level, key.x, key.y, key.z));
239
240        let schema = schema_for_las_header(reader.header().las_header());
241        let metadata = metadata_from_las_header();
242        let state = FormatStreamState::new("copc", schema, options, cancellation)?;
243        Ok(Self {
244            reader,
245            keys,
246            query_bounds,
247            metadata,
248            state,
249            key_index: 0,
250            current: None,
251            current_reservation: None,
252            current_offset: 0,
253        })
254    }
255
256    /// Returns the declared LAS point count from the COPC header.
257    #[must_use]
258    pub fn declared_point_count(&self) -> u64 {
259        self.reader.header().las_header().number_of_points()
260    }
261
262    fn load_next_node(&mut self) -> RecordsResult<bool> {
263        let Some(key) = self.keys.get(self.key_index).copied() else {
264            return Ok(false);
265        };
266        self.state.cancellation.check()?;
267        let entry = self.reader.get(&key).ok_or_else(|| {
268            spatialrust_records::RecordsError::InvalidChunk(format!(
269                "COPC hierarchy entry disappeared for {key:?}"
270            ))
271        })?;
272        let point_bytes = u64::from(entry.point_count)
273            .checked_mul(u64::from(
274                self.reader.header().las_header().point_format().len()
275                    + self.reader.header().las_header().point_format().extra_bytes,
276            ))
277            .ok_or_else(|| {
278                spatialrust_records::RecordsError::InvalidChunk(
279                    "COPC node byte size overflow".into(),
280                )
281            })?;
282        let working_bytes =
283            point_bytes.checked_add(u64::from(entry.byte_size)).ok_or_else(|| {
284                spatialrust_records::RecordsError::InvalidChunk(
285                    "COPC node working set overflow".into(),
286                )
287            })?;
288        let reservation = self.state.tracker.try_reserve(working_bytes)?;
289        let chunk = pollster::block_on(self.reader.fetch_chunk(&key))
290            .map_err(|error| records_io(copc_parse(error.to_string())))?;
291        self.current = Some(chunk);
292        self.current_reservation = Some(reservation);
293        self.current_offset = 0;
294        self.key_index += 1;
295        Ok(true)
296    }
297}
298
299#[cfg(feature = "streaming")]
300impl CopcChunkSource<FileSource> {
301    /// Opens a local COPC file for bounded node and record chunk reads.
302    pub fn open(
303        path: impl AsRef<Path>,
304        query: Option<CopcQuery>,
305        options: StreamOptions,
306        cancellation: CancellationToken,
307    ) -> Result<Self, IoError> {
308        let source =
309            FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?;
310        Self::from_source(source, query, options, cancellation)
311    }
312}
313
314#[cfg(all(feature = "streaming", feature = "io-copc-http"))]
315impl CopcChunkSource<crate::copc::HttpByteSource> {
316    /// Opens a remote COPC URL using bounded HTTP range requests.
317    pub fn open_url(
318        url: &str,
319        query: Option<CopcQuery>,
320        options: StreamOptions,
321        cancellation: CancellationToken,
322    ) -> Result<Self, IoError> {
323        Self::from_source(crate::copc::HttpByteSource::new(url)?, query, options, cancellation)
324    }
325}
326
327#[cfg(feature = "streaming")]
328impl<S: ByteSource> BoundedSpatialRecordSource for CopcChunkSource<S> {
329    fn schema(&self) -> &SchemaDescriptor {
330        &self.state.schema
331    }
332
333    fn options(&self) -> &StreamOptions {
334        &self.state.options
335    }
336
337    fn memory_tracker(&self) -> &MemoryTracker {
338        &self.state.tracker
339    }
340
341    fn cancellation_token(&self) -> CancellationToken {
342        self.state.cancellation.clone()
343    }
344
345    fn max_chunk_bytes(&self) -> u64 {
346        self.state.max_chunk_bytes
347    }
348
349    fn next_chunk(&mut self) -> Option<RecordsResult<SpatialRecordChunk>> {
350        loop {
351            if self.current.is_none() {
352                match self.load_next_node() {
353                    Ok(true) => {}
354                    Ok(false) => return None,
355                    Err(error) => return Some(Err(error)),
356                }
357            }
358            let chunk = self.current.as_ref().expect("loaded above");
359            let remaining = chunk.point_count - self.current_offset;
360            let count = remaining.min(self.state.options.chunk_points() as u32);
361            let point_vec_bytes = match u64::try_from(std::mem::size_of::<las::Point>())
362                .ok()
363                .and_then(|size| size.checked_mul(u64::from(count)))
364            {
365                Some(bytes) => bytes,
366                None => {
367                    return Some(Err(spatialrust_records::RecordsError::InvalidChunk(
368                        "COPC decoded point buffer size overflow".into(),
369                    )));
370                }
371            };
372            let reservation =
373                match self.state.reserve_points_with_scratch(count as usize, point_vec_bytes) {
374                    Ok(reservation) => reservation,
375                    Err(error) => return Some(Err(error)),
376                };
377            let end = self.current_offset + count;
378            let mut points = match self.reader.read_points_range(chunk, self.current_offset..end) {
379                Ok(points) => points,
380                Err(error) => return Some(Err(records_io(copc_parse(error.to_string())))),
381            };
382            if let Some(bounds) = &self.query_bounds {
383                points.retain(|point| {
384                    point.x >= bounds.min[0]
385                        && point.x <= bounds.max[0]
386                        && point.y >= bounds.min[1]
387                        && point.y <= bounds.max[1]
388                        && point.z >= bounds.min[2]
389                        && point.z <= bounds.max[2]
390                });
391            }
392            self.current_offset = end;
393            if end == chunk.point_count {
394                self.current = None;
395                self.current_reservation = None;
396            }
397            if points.is_empty() {
398                continue;
399            }
400            let cloud = match point_cloud_from_las_points(
401                self.state.schema.point_schema().clone(),
402                self.metadata.clone(),
403                points,
404            ) {
405                Ok(cloud) => cloud,
406                Err(error) => return Some(Err(records_io(error))),
407            };
408            return Some(self.state.lease(cloud, reservation));
409        }
410    }
411}
412
413/// One COPC octree node exposed by [`CopcNodeReader`].
414#[derive(Clone, Copy, Debug, PartialEq)]
415pub struct CopcNode {
416    /// Octree level; 0 is the root.
417    pub level: i32,
418    /// Node X index at `level`.
419    pub x: i32,
420    /// Node Y index at `level`.
421    pub y: i32,
422    /// Node Z index at `level`.
423    pub z: i32,
424    /// World-space bounds of this node.
425    pub bounds: crate::copc::CopcBounds,
426    /// Points materialized by this node's chunk.
427    pub point_count: u64,
428}
429
430/// Bounded per-node COPC reader.
431///
432/// Opens a COPC file once, loads the full hierarchy (metadata only), and
433/// exposes one [`PointCloud`] per node on demand. Nodes are returned in
434/// deterministic `(level, x, y, z)` order so the hierarchy can be turned
435/// into a tile set without materializing the whole cloud.
436pub struct CopcNodeReader {
437    reader: CopcStreamingReader<FileSource>,
438    keys: Vec<copc_streaming::VoxelKey>,
439    root: copc_streaming::Aabb,
440    nodes: Vec<CopcNode>,
441    schema: PointSchema,
442    metadata: SpatialMetadata,
443}
444
445impl CopcNodeReader {
446    /// Opens a local COPC file and loads its hierarchy eagerly.
447    pub fn open(path: impl AsRef<Path>) -> Result<Self, IoError> {
448        let source =
449            FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?;
450        let mut reader = pollster::block_on(CopcStreamingReader::open(source))
451            .map_err(|error| copc_parse(error.to_string()))?;
452        pollster::block_on(reader.load_all_hierarchy())
453            .map_err(|error| copc_parse(error.to_string()))?;
454        let root = reader.copc_info().root_bounds();
455        let schema = schema_for_las_header(reader.header().las_header());
456        let metadata = metadata_from_las_header();
457
458        let mut keys: Vec<_> = reader
459            .entries()
460            .filter(|(_, entry)| entry.point_count > 0)
461            .map(|(key, _)| *key)
462            .collect();
463        keys.sort_by_key(|key| (key.level, key.x, key.y, key.z));
464
465        let mut nodes = Vec::with_capacity(keys.len());
466        for key in &keys {
467            let bounds = key.bounds(&root);
468            let point_count =
469                reader.get(key).map(|entry| u64::from(entry.point_count)).unwrap_or(0);
470            nodes.push(CopcNode {
471                level: key.level,
472                x: key.x,
473                y: key.y,
474                z: key.z,
475                bounds: crate::copc::CopcBounds::new(bounds.min, bounds.max),
476                point_count,
477            });
478        }
479        Ok(Self { reader, keys, root, nodes, schema, metadata })
480    }
481
482    /// Node descriptors in deterministic `(level, x, y, z)` order.
483    #[must_use]
484    pub fn nodes(&self) -> &[CopcNode] {
485        &self.nodes
486    }
487
488    /// Root octree bounds.
489    #[must_use]
490    pub fn root_bounds(&self) -> crate::copc::CopcBounds {
491        crate::copc::CopcBounds::new(self.root.min, self.root.max)
492    }
493
494    /// Reads the points of one node by index into [`Self::nodes`].
495    pub fn read_node(&mut self, index: usize) -> Result<PointCloud, IoError> {
496        let key = *self
497            .keys
498            .get(index)
499            .ok_or_else(|| copc_parse(format!("COPC node index {index} is out of range")))?;
500        let chunk = pollster::block_on(self.reader.fetch_chunk(&key))
501            .map_err(|error| copc_parse(error.to_string()))?;
502        let points =
503            self.reader.read_points(&chunk).map_err(|error| copc_parse(error.to_string()))?;
504        point_cloud_from_las_points(self.schema.clone(), self.metadata.clone(), points)
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::{read_copc_file, read_copc_file_info, read_copc_file_with_query, CopcQuery};
511    use crate::copc::writer::write_copc_file;
512    use crate::copc::{copc_level_for_resolution, CopcBounds};
513    use crate::{write_las_file, LasWriteFormat};
514    use spatialrust_core::PointCloudBuilder;
515
516    #[test]
517    fn rejects_non_copc_laz() {
518        let mut builder = PointCloudBuilder::xyz();
519        builder.push_point([1.0, 2.0, 3.0]).unwrap();
520        let cloud = builder.build().unwrap();
521
522        let path = std::env::temp_dir().join(format!("spatialrust_laz_{}.laz", std::process::id()));
523        write_las_file(&path, &cloud, LasWriteFormat::Laz).unwrap();
524
525        let error = read_copc_file(&path).unwrap_err();
526        let _ = std::fs::remove_file(path);
527        assert!(matches!(error, crate::IoError::CopcParse(_)));
528    }
529
530    #[test]
531    fn rejects_invalid_query_bounds() {
532        let path = std::env::temp_dir()
533            .join(format!("spatialrust_copc_query_{}.copc.laz", std::process::id()));
534        let query = CopcQuery::bounds(CopcBounds::from_ranges((1.0, 0.0), (0.0, 1.0), (0.0, 1.0)));
535        let error = read_copc_file_with_query(&path, &query).unwrap_err();
536        assert!(matches!(error, crate::IoError::CopcFormat(_)));
537    }
538
539    #[test]
540    fn write_copc_rejects_empty_cloud() {
541        use spatialrust_core::{
542            PointBuffer, PointBufferSet, PointCloud, SpatialMetadata, StandardSchemas,
543        };
544
545        let schema = StandardSchemas::point_xyz();
546        let mut buffers = PointBufferSet::new();
547        for field in schema.fields() {
548            buffers.insert(field.name.clone(), PointBuffer::with_capacity(field.dtype, 0));
549        }
550        let cloud =
551            PointCloud::try_from_parts(schema, buffers, SpatialMetadata::default()).unwrap();
552        assert!(cloud.is_empty());
553
554        let path =
555            std::env::temp_dir().join(format!("spatialrust_copc_{}.copc.laz", std::process::id()));
556        let error = write_copc_file(&path, &cloud).unwrap_err();
557        assert!(matches!(error, crate::IoError::CopcFormat(_)));
558    }
559
560    #[test]
561    fn resolution_level_helper_is_usable_from_reader_tests() {
562        assert_eq!(copc_level_for_resolution(4.0, 1.0), 2);
563    }
564
565    #[test]
566    fn multi_resolution_copc_resolution_query_reduces_point_count() {
567        use copc_writer::CopcWriterParams;
568
569        use crate::copc::writer::write_copc_file_with_params;
570
571        let cloud = dense_grid_cloud(7_000);
572        let path = std::env::temp_dir()
573            .join(format!("spatialrust_copc_multires_{}.copc.laz", std::process::id()));
574        write_copc_file_with_params(
575            &path,
576            &cloud,
577            &CopcWriterParams { max_points_per_node: 96, max_depth: 8 },
578        )
579        .unwrap();
580
581        let info = read_copc_file_info(&path).unwrap();
582        let full = read_copc_file(&path).unwrap();
583        assert_eq!(full.len(), cloud.len());
584
585        let coarse = read_copc_file_with_query(
586            &path,
587            &CopcQuery::with_resolution(info.root_bounds, info.spacing * 4.0),
588        )
589        .unwrap();
590        let medium = read_copc_file_with_query(
591            &path,
592            &CopcQuery::with_resolution(info.root_bounds, info.spacing),
593        )
594        .unwrap();
595        let fine = read_copc_file_with_query(
596            &path,
597            &CopcQuery::with_resolution(info.root_bounds, info.spacing / 4.0),
598        )
599        .unwrap();
600
601        assert!(coarse.len() <= medium.len());
602        assert!(medium.len() <= fine.len());
603        assert!(fine.len() <= full.len());
604        assert!(
605            coarse.len() < full.len(),
606            "coarse resolution should load fewer points than full detail"
607        );
608
609        let level0 =
610            read_copc_file_with_query(&path, &CopcQuery::with_level(info.root_bounds, 0)).unwrap();
611        let level2 =
612            read_copc_file_with_query(&path, &CopcQuery::with_level(info.root_bounds, 2)).unwrap();
613        assert!(level0.len() <= level2.len());
614        assert!(level2.len() <= full.len());
615
616        let _ = std::fs::remove_file(path);
617    }
618
619    #[test]
620    fn copc_node_reader_enumerates_hierarchy_bounded() {
621        use copc_writer::CopcWriterParams;
622
623        use crate::copc::writer::write_copc_file_with_params;
624
625        let cloud = dense_grid_cloud(7_000);
626        let path = std::env::temp_dir()
627            .join(format!("spatialrust_copc_nodes_{}.copc.laz", std::process::id()));
628        write_copc_file_with_params(
629            &path,
630            &cloud,
631            &CopcWriterParams { max_points_per_node: 96, max_depth: 8 },
632        )
633        .unwrap();
634
635        let mut reader = super::CopcNodeReader::open(&path).unwrap();
636        let nodes = reader.nodes().to_vec();
637        assert!(nodes.len() > 1, "expected a multi-node hierarchy");
638        assert!(nodes.iter().all(|node| node.point_count > 0));
639        assert!(
640            nodes.windows(2).all(|pair| (pair[0].level, pair[0].x, pair[0].y, pair[0].z)
641                <= (pair[1].level, pair[1].x, pair[1].y, pair[1].z)),
642            "nodes must be deterministically ordered"
643        );
644
645        let mut total = 0u64;
646        for index in 0..nodes.len() {
647            let cloud = reader.read_node(index).unwrap();
648            assert_eq!(cloud.len() as u64, nodes[index].point_count);
649            total += nodes[index].point_count;
650        }
651        assert_eq!(total, cloud.len() as u64, "per-node reads must reconstruct the cloud");
652        let _ = std::fs::remove_file(path);
653    }
654
655    fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud {
656        use spatialrust_core::PointCloudBuilder;
657
658        let mut builder = PointCloudBuilder::xyz();
659        for index in 0..count {
660            let x = (index % 31) as f32 - 15.0;
661            let y = ((index / 31) % 29) as f32 - 14.0;
662            let z = ((index / (31 * 29)) % 23) as f32 - 11.0;
663            builder.push_point([x, y, z]).unwrap();
664        }
665        builder.build().unwrap()
666    }
667}