Skip to main content

spatialrust_io/copc/
writer.rs

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
13/// Writes point clouds to COPC files.
14pub 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
28/// Writes a point cloud to a COPC file on disk.
29pub fn write_copc(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
30    write_copc_file(path, cloud)
31}
32
33/// Writes a point cloud to a COPC file on disk.
34pub fn write_copc_file(path: impl AsRef<Path>, cloud: &PointCloud) -> Result<(), IoError> {
35    write_copc_file_with_params(path, cloud, &CopcWriterParams::default())
36}
37
38/// Writes a point cloud to a COPC file using custom octree builder parameters.
39pub 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/// Deterministic receipt for a bounded COPC streaming write.
314#[cfg(feature = "streaming")]
315#[derive(Clone, Copy, Debug, PartialEq, Eq)]
316pub struct CopcStreamingWriteReceipt {
317    /// Number of points consumed from the source.
318    pub points: u64,
319    /// Exact fixed-width bytes admitted to the writer's temporary spill.
320    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/// Writes a bounded record source to COPC through the writer's fixed-width
463/// temporary spill, preflighting the complete spill extent before any points
464/// are consumed.
465#[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}