1use std::path::Path;
2
3use copc_core::{Bounds as CopcBounds, Error as CopcCoreError, Result as CopcCoreResult};
4use copc_writer::{write_source, CopcPointFields, CopcPointSource, CopcWriterParams};
5use spatialrust_core::{DType, FieldSemantic, HasPositions3, PointCloud, PointField, PointSchema};
6
7use crate::error::{copc_format, copc_parse, IoError};
8use crate::{PointWriter, WriteOptions};
9
10#[cfg(feature = "streaming")]
11use spatialrust_records::{BoundedSpatialRecordSource, CancellationToken, SpatialRecordChunk};
12
13pub struct CopcWriter;
15
16impl PointWriter for CopcWriter {
17 fn write(
18 &mut self,
19 _cloud: &PointCloud,
20 _options: &WriteOptions,
21 ) -> spatialrust_core::SpatialResult<()> {
22 Err(spatialrust_core::SpatialError::InvalidArgument(
23 "CopcWriter requires write_copc_file with a path ending in .copc.laz".to_owned(),
24 ))
25 }
26}
27
28pub fn write_copc(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
30 write_copc_file(path, cloud)
31}
32
33pub fn write_copc_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
35 write_copc_file_with_params(path, cloud, &CopcWriterParams::default())
36}
37
38pub fn write_copc_file_with_params(
40 path: impl AsRef<Path>,
41 cloud: &PointCloud,
42 params: &CopcWriterParams,
43) -> Result<(), IoError> {
44 validate_copc_output_path(path.as_ref())?;
45 cloud.validate()?;
46 if cloud.is_empty() {
47 return Err(copc_format("cannot write an empty point cloud to COPC".to_owned()));
48 }
49
50 let has_color = cloud.schema().fields().iter().any(|field| {
51 matches!(
52 field.semantic,
53 FieldSemantic::ColorR | FieldSemantic::ColorG | FieldSemantic::ColorB
54 )
55 });
56 let bounds = bounds_from_cloud(cloud)?;
57 let source = PointCloudCopcSource { cloud };
58 write_source(path.as_ref(), &source, has_color, bounds, params).map_err(map_copc_writer_error)
59}
60
61fn validate_copc_output_path(path: &Path) -> Result<(), IoError> {
62 let file_stem = path.file_stem().and_then(|stem| stem.to_str());
63 let extension = path.extension().and_then(|ext| ext.to_str());
64 match (file_stem, extension) {
65 (Some(stem), Some(ext)) if ext.eq_ignore_ascii_case("laz") => {
66 Path::new(stem)
67 .extension()
68 .and_then(|copc| copc.to_str())
69 .filter(|copc| copc.eq_ignore_ascii_case("copc"))
70 .ok_or_else(|| {
71 copc_format(format!(
72 "COPC output path must end with `.copc.laz`, got `{}`",
73 path.display()
74 ))
75 })?;
76 Ok(())
77 }
78 _ => Err(copc_format(format!(
79 "COPC output path must end with `.copc.laz`, got `{}`",
80 path.display()
81 ))),
82 }
83}
84
85fn bounds_from_cloud(cloud: &PointCloud) -> Result<CopcBounds, IoError> {
86 let (x, y, z) = cloud.positions3()?;
87 let mut min = [f64::INFINITY; 3];
88 let mut max = [f64::NEG_INFINITY; 3];
89 for index in 0..cloud.len() {
90 min[0] = min[0].min(f64::from(x[index]));
91 min[1] = min[1].min(f64::from(y[index]));
92 min[2] = min[2].min(f64::from(z[index]));
93 max[0] = max[0].max(f64::from(x[index]));
94 max[1] = max[1].max(f64::from(y[index]));
95 max[2] = max[2].max(f64::from(z[index]));
96 }
97 expand_degenerate_bounds(&mut min, &mut max);
98 Ok(CopcBounds::new((min[0], min[1], min[2]), (max[0], max[1], max[2])))
99}
100
101fn expand_degenerate_bounds(min: &mut [f64; 3], max: &mut [f64; 3]) {
102 const EPS: f64 = 0.001;
103 for axis in 0..3 {
104 if !(max[axis] - min[axis]).is_normal() {
105 min[axis] -= EPS;
106 max[axis] += EPS;
107 }
108 }
109}
110
111fn map_copc_writer_error(error: CopcCoreError) -> IoError {
112 match error {
113 CopcCoreError::InvalidInput(message) => copc_format(message),
114 other => copc_parse(other.to_string()),
115 }
116}
117
118struct PointCloudCopcSource<'a> {
119 cloud: &'a PointCloud,
120}
121
122impl CopcPointSource for PointCloudCopcSource<'_> {
123 fn len(&self) -> usize {
124 self.cloud.len()
125 }
126
127 fn xyz(&self, index: usize) -> (f64, f64, f64) {
128 let (x, y, z) = self.cloud.positions3().expect("validated cloud positions");
129 (f64::from(x[index]), f64::from(y[index]), f64::from(z[index]))
130 }
131
132 fn fields(&self, index: usize) -> CopcCoreResult<CopcPointFields> {
133 point_fields_from_cloud(self.cloud, index)
134 }
135}
136
137fn point_fields_from_cloud(cloud: &PointCloud, index: usize) -> CopcCoreResult<CopcPointFields> {
138 let (x, y, z) =
139 cloud.positions3().map_err(|error| CopcCoreError::InvalidInput(error.to_string()))?;
140 let schema = cloud.schema();
141 Ok(CopcPointFields {
142 x: f64::from(x[index]),
143 y: f64::from(y[index]),
144 z: f64::from(z[index]),
145 intensity: read_optional_u16(cloud, schema, FieldSemantic::Intensity, "intensity", index)?
146 .unwrap_or(0),
147 return_number: read_optional_u8(
148 cloud,
149 schema,
150 FieldSemantic::Unknown,
151 "return_number",
152 index,
153 )?
154 .unwrap_or(1),
155 number_of_returns: read_optional_u8(
156 cloud,
157 schema,
158 FieldSemantic::Unknown,
159 "number_of_returns",
160 index,
161 )?
162 .unwrap_or(1),
163 synthetic: 0,
164 key_point: 0,
165 withheld: 0,
166 overlap: 0,
167 scan_channel: 0,
168 scan_direction_flag: 0,
169 edge_of_flight_line: 0,
170 classification: read_optional_u8(
171 cloud,
172 schema,
173 FieldSemantic::Label,
174 "classification",
175 index,
176 )?
177 .unwrap_or(0),
178 user_data: 0,
179 scan_angle: 0.0,
180 point_source_id: read_optional_u16(
181 cloud,
182 schema,
183 FieldSemantic::Unknown,
184 "point_source_id",
185 index,
186 )?
187 .unwrap_or(0),
188 gps_time: read_optional_f64(cloud, schema, FieldSemantic::TimeOffset, "gps_time", index)?
189 .unwrap_or(0.0),
190 red: read_optional_u16(cloud, schema, FieldSemantic::ColorR, "red", index)?.unwrap_or(0),
191 green: read_optional_u16(cloud, schema, FieldSemantic::ColorG, "green", index)?
192 .unwrap_or(0),
193 blue: read_optional_u16(cloud, schema, FieldSemantic::ColorB, "blue", index)?.unwrap_or(0),
194 })
195}
196
197fn read_optional_u8(
198 cloud: &PointCloud,
199 schema: &PointSchema,
200 semantic: FieldSemantic,
201 fallback_name: &str,
202 index: usize,
203) -> CopcCoreResult<Option<u8>> {
204 let Some(field) = find_field(schema, semantic, fallback_name) else {
205 return Ok(None);
206 };
207 read_scalar_as_u8(cloud, field, index).map(Some)
208}
209
210fn read_optional_u16(
211 cloud: &PointCloud,
212 schema: &PointSchema,
213 semantic: FieldSemantic,
214 fallback_name: &str,
215 index: usize,
216) -> CopcCoreResult<Option<u16>> {
217 let Some(field) = find_field(schema, semantic, fallback_name) else {
218 return Ok(None);
219 };
220 read_scalar_as_u16(cloud, field, index).map(Some)
221}
222
223fn read_optional_f64(
224 cloud: &PointCloud,
225 schema: &PointSchema,
226 semantic: FieldSemantic,
227 fallback_name: &str,
228 index: usize,
229) -> CopcCoreResult<Option<f64>> {
230 let Some(field) = find_field(schema, semantic, fallback_name) else {
231 return Ok(None);
232 };
233 read_scalar_as_f64(cloud, field, index).map(Some)
234}
235
236fn find_field<'a>(
237 schema: &'a PointSchema,
238 semantic: FieldSemantic,
239 fallback_name: &str,
240) -> Option<&'a PointField> {
241 schema
242 .find_semantic(semantic)
243 .or_else(|| schema.fields().iter().find(|field| field.name == fallback_name))
244}
245
246fn read_scalar_as_u8(cloud: &PointCloud, field: &PointField, index: usize) -> CopcCoreResult<u8> {
247 let value = read_scalar_as_f64(cloud, field, index)?;
248 Ok(value.round() as u8)
249}
250
251fn read_scalar_as_u16(cloud: &PointCloud, field: &PointField, index: usize) -> CopcCoreResult<u16> {
252 let value = read_scalar_as_f64(cloud, field, index)?;
253 Ok(value.round() as u16)
254}
255
256fn read_scalar_as_f64(cloud: &PointCloud, field: &PointField, index: usize) -> CopcCoreResult<f64> {
257 use spatialrust_core::PointBuffer;
258
259 let buffer =
260 cloud.field(&field.name).map_err(|error| CopcCoreError::InvalidInput(error.to_string()))?;
261 match field.dtype {
262 DType::F32 | DType::F16 => Ok(f64::from(
263 buffer.as_f32().map_err(|error| CopcCoreError::InvalidInput(error.to_string()))?[index],
264 )),
265 DType::F64 => {
266 let PointBuffer::F64(values) = buffer else {
267 return Err(CopcCoreError::InvalidInput(format!(
268 "unsupported dtype {:?} for field `{}`",
269 field.dtype, field.name
270 )));
271 };
272 Ok(values[index])
273 }
274 DType::U8 => {
275 let PointBuffer::U8(values) = buffer else {
276 return Err(CopcCoreError::InvalidInput(format!(
277 "unsupported dtype {:?} for field `{}`",
278 field.dtype, field.name
279 )));
280 };
281 Ok(f64::from(values[index]))
282 }
283 DType::U16 => {
284 let PointBuffer::U16(values) = buffer else {
285 return Err(CopcCoreError::InvalidInput(format!(
286 "unsupported dtype {:?} for field `{}`",
287 field.dtype, field.name
288 )));
289 };
290 Ok(f64::from(values[index]))
291 }
292 DType::I32 => {
293 let PointBuffer::I32(values) = buffer else {
294 return Err(CopcCoreError::InvalidInput(format!(
295 "unsupported dtype {:?} for field `{}`",
296 field.dtype, field.name
297 )));
298 };
299 Ok(f64::from(values[index]))
300 }
301 DType::U32 => {
302 let PointBuffer::U32(values) = buffer else {
303 return Err(CopcCoreError::InvalidInput(format!(
304 "unsupported dtype {:?} for field `{}`",
305 field.dtype, field.name
306 )));
307 };
308 Ok(f64::from(values[index]))
309 }
310 }
311}
312
313#[cfg(feature = "streaming")]
315#[derive(Clone, Copy, Debug, PartialEq, Eq)]
316pub struct CopcStreamingWriteReceipt {
317 pub points: u64,
319 pub spill_bytes: u64,
321}
322
323#[cfg(feature = "streaming")]
324struct SourcePointIter<'a, S: BoundedSpatialRecordSource> {
325 source: &'a mut S,
326 current: Option<SpatialRecordChunk>,
327 index: usize,
328 export_schema: PointSchema,
329 point_format: las::point::Format,
330 expected_points: u64,
331 emitted_points: u64,
332 ended: bool,
333}
334
335#[cfg(feature = "streaming")]
336impl<S: BoundedSpatialRecordSource> Iterator for SourcePointIter<'_, S> {
337 type Item = copc_core::Result<copc_core::LasPointRecord>;
338
339 fn next(&mut self) -> Option<Self::Item> {
340 loop {
341 if let Some(chunk) = &self.current {
342 let cloud = chunk.record().cloud();
343 if self.index < cloud.len() {
344 if self.emitted_points == self.expected_points {
345 self.ended = true;
346 return Some(Err(CopcCoreError::InvalidInput(format!(
347 "source exceeds declared point count {}",
348 self.expected_points
349 ))));
350 }
351 let point = crate::las::point_from_cloud(
352 cloud,
353 &self.export_schema,
354 self.index,
355 self.point_format,
356 )
357 .map(|point| streaming_record_from_las(&point))
358 .map_err(|error| CopcCoreError::InvalidInput(error.to_string()));
359 self.index += 1;
360 self.emitted_points += 1;
361 return Some(point);
362 }
363 self.current = None;
364 self.index = 0;
365 }
366 if self.ended {
367 return None;
368 }
369 match self.source.next_chunk() {
370 Some(Ok(chunk)) => {
371 if chunk.record().cloud().schema() != self.source.schema().point_schema() {
372 self.ended = true;
373 return Some(Err(CopcCoreError::InvalidInput(
374 "source chunk schema changed during COPC write".into(),
375 )));
376 }
377 self.current = Some(chunk);
378 }
379 Some(Err(error)) => {
380 self.ended = true;
381 return Some(Err(CopcCoreError::InvalidInput(error.to_string())));
382 }
383 None if self.emitted_points != self.expected_points => {
384 self.ended = true;
385 return Some(Err(CopcCoreError::InvalidInput(format!(
386 "source ended at {} of {} declared points",
387 self.emitted_points, self.expected_points
388 ))));
389 }
390 None => {
391 self.ended = true;
392 return None;
393 }
394 }
395 }
396 }
397}
398
399#[cfg(feature = "streaming")]
400struct CopcCancellation(CancellationToken);
401
402#[cfg(feature = "streaming")]
403impl copc_core::CancelCheck for CopcCancellation {
404 fn check(&self) -> copc_core::Result<()> {
405 self.0.check().map_err(|_| CopcCoreError::Cancelled)
406 }
407}
408
409#[cfg(feature = "streaming")]
410fn streaming_record_from_las(point: &las::Point) -> copc_core::LasPointRecord {
411 let (red, green, blue) = point
412 .color
413 .map(|color| (color.red, color.green, color.blue))
414 .unwrap_or((32_768, 32_768, 32_768));
415 let (
416 wave_packet_descriptor_index,
417 byte_offset_to_waveform_data,
418 waveform_packet_size,
419 return_point_waveform_location,
420 ) = point
421 .waveform
422 .as_ref()
423 .map(|waveform| {
424 (
425 waveform.wave_packet_descriptor_index,
426 waveform.byte_offset_to_waveform_data,
427 waveform.waveform_packet_size_in_bytes,
428 waveform.return_point_waveform_location,
429 )
430 })
431 .unwrap_or((0, 0, 0, 0.0));
432 copc_core::LasPointRecord {
433 x: point.x,
434 y: point.y,
435 z: point.z,
436 intensity: point.intensity,
437 return_number: point.return_number,
438 number_of_returns: point.number_of_returns,
439 classification: u8::from(point.classification),
440 scan_direction_flag: matches!(point.scan_direction, las::point::ScanDirection::LeftToRight),
441 edge_of_flight_line: point.is_edge_of_flight_line,
442 scan_angle: point.scan_angle,
443 user_data: point.user_data,
444 point_source_id: point.point_source_id,
445 synthetic: point.is_synthetic,
446 key_point: point.is_key_point,
447 withheld: point.is_withheld,
448 overlap: point.is_overlap,
449 scan_channel: point.scanner_channel,
450 gps_time: point.gps_time.unwrap_or(0.0),
451 red,
452 green,
453 blue,
454 nir: point.nir.unwrap_or(0),
455 wave_packet_descriptor_index,
456 byte_offset_to_waveform_data,
457 waveform_packet_size,
458 return_point_waveform_location,
459 }
460}
461
462#[cfg(feature = "streaming")]
466pub fn write_copc_stream<S: BoundedSpatialRecordSource>(
467 path: impl AsRef<Path>,
468 source: &mut S,
469 expected_points: u64,
470 params: &CopcWriterParams,
471 spool: &crate::SpoolOptions,
472) -> Result<CopcStreamingWriteReceipt, IoError> {
473 validate_copc_output_path(path.as_ref())?;
474 if expected_points == 0 {
475 return Err(copc_format("cannot write an empty point stream to COPC"));
476 }
477 let (point_format, export_schema) =
478 crate::las::schema_from_point_cloud(source.schema().point_schema())?;
479 let layout = copc_core::StreamingLayout {
480 point_format: point_format.to_u8().unwrap_or(0),
481 has_gps: point_format.has_gps_time,
482 has_color: point_format.has_color,
483 has_nir: point_format.has_nir,
484 has_waveform: point_format.has_waveform,
485 };
486 let spill_bytes = u64::try_from(layout.record_width())
487 .ok()
488 .and_then(|width| width.checked_mul(expected_points))
489 .ok_or_else(|| IoError::Streaming("COPC spill byte size overflow".into()))?;
490 if spill_bytes > spool.limit_bytes() {
491 return Err(IoError::Streaming(format!(
492 "COPC spill requires {spill_bytes} bytes, limit is {}",
493 spool.limit_bytes()
494 )));
495 }
496 let cancellation = source.cancellation_token();
497 let points = SourcePointIter {
498 source,
499 current: None,
500 index: 0,
501 export_schema,
502 point_format,
503 expected_points,
504 emitted_points: 0,
505 ended: false,
506 };
507 copc_writer::write_streaming_with_cancel(
508 path.as_ref(),
509 layout,
510 points,
511 params,
512 spool.directory(),
513 &CopcCancellation(cancellation),
514 )
515 .map_err(|error| copc_parse(error.to_string()))?;
516 Ok(CopcStreamingWriteReceipt { points: expected_points, spill_bytes })
517}
518
519#[cfg(test)]
520mod tests {
521 use std::path::Path;
522
523 use super::{validate_copc_output_path, write_copc_file};
524 use crate::copc::read_copc_file;
525 use spatialrust_core::{HasPositions3, PointCloudBuilder};
526
527 #[test]
528 fn rejects_non_copc_extension() {
529 let mut builder = PointCloudBuilder::xyz();
530 builder.push_point([0.0, 0.0, 0.0]).unwrap();
531 let cloud = builder.build().unwrap();
532 let path =
533 std::env::temp_dir().join(format!("spatialrust_bad_ext_{}.laz", std::process::id()));
534 let error = write_copc_file(&path, &cloud).unwrap_err();
535 assert!(matches!(error, crate::IoError::CopcFormat(_)));
536 }
537
538 #[test]
539 fn validate_copc_output_path_accepts_copc_laz_suffix() {
540 assert!(validate_copc_output_path(Path::new("scan.copc.laz")).is_ok());
541 assert!(validate_copc_output_path(Path::new("scan.laz")).is_err());
542 }
543
544 #[test]
545 fn roundtrip_xyzi_cloud() {
546 use spatialrust_core::{HasIntensity, StandardSchemas};
547
548 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzi());
549 builder.push_point([1.0, 2.0, 3.0, 128.0]).unwrap();
550 builder.push_point([4.0, 5.0, 6.0, 64.0]).unwrap();
551 let cloud = builder.build().unwrap();
552
553 let path = std::env::temp_dir()
554 .join(format!("spatialrust_copc_xyzi_{}.copc.laz", std::process::id()));
555 write_copc_file(&path, &cloud).expect("write copc");
556 let loaded = read_copc_file(&path).expect("read copc");
557 let _ = std::fs::remove_file(&path);
558
559 assert_eq!(loaded.len(), cloud.len());
560 let (src_x, src_y, src_z) = cloud.positions3().unwrap();
561 let (out_x, out_y, out_z) = loaded.positions3().unwrap();
562 let src_i = cloud.intensity().unwrap();
563 let out_i = loaded.intensity().unwrap();
564 for index in 0..cloud.len() {
565 assert!((out_x[index] - src_x[index]).abs() < 1e-3);
566 assert!((out_y[index] - src_y[index]).abs() < 1e-3);
567 assert!((out_z[index] - src_z[index]).abs() < 1e-3);
568 assert!((out_i[index] - src_i[index]).abs() < 1.0);
569 }
570 }
571
572 #[test]
573 fn roundtrip_xyzrgb_cloud() {
574 use spatialrust_core::{PointBuffer, StandardSchemas};
575
576 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzrgb());
577 builder.push_point([0.0, 0.0, 0.0, 10.0, 20.0, 30.0]).unwrap();
578 builder.push_point([1.0, 1.0, 1.0, 40.0, 50.0, 60.0]).unwrap();
579 let cloud = builder.build().unwrap();
580
581 let path = std::env::temp_dir()
582 .join(format!("spatialrust_copc_xyzrgb_{}.copc.laz", std::process::id()));
583 write_copc_file(&path, &cloud).expect("write copc");
584 let loaded = read_copc_file(&path).expect("read copc");
585 let _ = std::fs::remove_file(&path);
586
587 assert_eq!(loaded.len(), cloud.len());
588 let (src_x, src_y, src_z) = cloud.positions3().unwrap();
589 let (out_x, out_y, out_z) = loaded.positions3().unwrap();
590 for index in 0..cloud.len() {
591 assert!((out_x[index] - src_x[index]).abs() < 1e-3);
592 assert!((out_y[index] - src_y[index]).abs() < 1e-3);
593 assert!((out_z[index] - src_z[index]).abs() < 1e-3);
594 }
595
596 let PointBuffer::U8(src_r) = cloud.field("r").unwrap() else {
597 panic!("expected u8 red channel");
598 };
599 let PointBuffer::U8(src_g) = cloud.field("g").unwrap() else {
600 panic!("expected u8 green channel");
601 };
602 let PointBuffer::U8(src_b) = cloud.field("b").unwrap() else {
603 panic!("expected u8 blue channel");
604 };
605 let PointBuffer::U16(out_r) = loaded.field("red").unwrap() else {
606 panic!("expected u16 red channel");
607 };
608 let PointBuffer::U16(out_g) = loaded.field("green").unwrap() else {
609 panic!("expected u16 green channel");
610 };
611 let PointBuffer::U16(out_b) = loaded.field("blue").unwrap() else {
612 panic!("expected u16 blue channel");
613 };
614 for index in 0..cloud.len() {
615 assert_eq!(u16::from(src_r[index]), out_r[index]);
616 assert_eq!(u16::from(src_g[index]), out_g[index]);
617 assert_eq!(u16::from(src_b[index]), out_b[index]);
618 }
619 }
620
621 #[test]
622 fn roundtrip_xyz_cloud() {
623 let mut builder = PointCloudBuilder::xyz();
624 builder.push_point([1.0, 2.0, 3.0]).unwrap();
625 builder.push_point([1.5, 2.5, 3.5]).unwrap();
626 let cloud = builder.build().unwrap();
627
628 let path = std::env::temp_dir()
629 .join(format!("spatialrust_copc_roundtrip_{}.copc.laz", std::process::id()));
630 write_copc_file(&path, &cloud).expect("write copc");
631 let loaded = read_copc_file(&path).expect("read copc");
632 let _ = std::fs::remove_file(&path);
633
634 assert_eq!(loaded.len(), cloud.len());
635 let (src_x, src_y, src_z) = cloud.positions3().unwrap();
636 let (out_x, out_y, out_z) = loaded.positions3().unwrap();
637 for index in 0..cloud.len() {
638 assert!((out_x[index] - src_x[index]).abs() < 1e-3);
639 assert!((out_y[index] - src_y[index]).abs() < 1e-3);
640 assert!((out_z[index] - src_z[index]).abs() < 1e-3);
641 }
642 }
643
644 #[test]
645 fn bounds_query_excludes_out_of_region_points() {
646 use crate::copc::{read_copc_file_with_query, CopcBounds, CopcQuery};
647
648 let mut builder = PointCloudBuilder::xyz();
649 for x in 0..10 {
650 for y in 0..10 {
651 builder.push_point([x as f32 * 0.1, y as f32 * 0.1, 0.0]).unwrap();
652 }
653 }
654 builder.push_point([0.0, 0.0, 0.5]).unwrap();
655 let cloud = builder.build().unwrap();
656
657 let path = std::env::temp_dir()
658 .join(format!("spatialrust_copc_bounds_query_{}.copc.laz", std::process::id()));
659 write_copc_file(&path, &cloud).expect("write copc");
660
661 let bounds = CopcBounds::from_ranges((0.0, 0.85), (0.0, 0.85), (-0.01, 0.01));
662 let loaded = read_copc_file_with_query(&path, &CopcQuery::bounds(bounds)).expect("query");
663 let _ = std::fs::remove_file(&path);
664
665 assert!(loaded.len() < cloud.len());
666 assert!(loaded.len() >= 10);
667 assert!(loaded
668 .schema()
669 .find_semantic(spatialrust_core::FieldSemantic::PositionX)
670 .is_some());
671 let (x, _, _) = loaded.positions3().expect("positions3");
672 assert_eq!(x.len(), loaded.len());
673 }
674}