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
22pub struct CopcReader {
24 path: std::path::PathBuf,
25 metadata: SpatialMetadata,
26 schema: PointSchema,
27 file_info: CopcFileInfo,
28}
29
30impl CopcReader {
31 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 #[must_use]
46 pub fn file_info(&self) -> &CopcFileInfo {
47 &self.file_info
48 }
49
50 #[must_use]
52 pub fn root_bounds(&self) -> crate::copc::CopcBounds {
53 self.file_info.root_bounds
54 }
55
56 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
93pub 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
99pub fn read_copc(path: impl AsRef<Path>) -> Result<PointCloud, IoError> {
101 read_copc_file(path)
102}
103
104pub 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
110pub 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
118pub 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#[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 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 #[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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
415pub struct CopcNode {
416 pub level: i32,
418 pub x: i32,
420 pub y: i32,
422 pub z: i32,
424 pub bounds: crate::copc::CopcBounds,
426 pub point_count: u64,
428}
429
430pub 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 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 #[must_use]
484 pub fn nodes(&self) -> &[CopcNode] {
485 &self.nodes
486 }
487
488 #[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 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}