Skip to main content

copc_reader/
points.rs

1use std::fs::File;
2use std::io::{BufReader, Read, Seek, SeekFrom};
3use std::path::Path;
4
5use copc_core::{
6    layout_for_las_format, scan_angle_rank_from_degrees, Bounds, CancelCheck, ColumnData,
7    ColumnSelection, ColumnSpec, CopcInfo, Entry, Error, LasColumnBatch, LasDimension, Result,
8};
9use las::point::Format as LasPointFormat;
10use las::{Point, Transform, Vector};
11use laz::record::{LayeredPointRecordDecompressor, RecordDecompressor};
12use laz::LazVlr;
13
14use crate::{CopcFile, LasHeader};
15
16const CANCEL_POLL_STRIDE: usize = 4_096;
17
18/// COPC point reader owning the underlying stream.
19pub struct CopcReader<R> {
20    source: R,
21    file: CopcFile,
22}
23
24/// Limits the octree levels included in a point query.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub enum LodSelection {
27    /// Full resolution: every point chunk in every LOD.
28    All,
29    /// Include levels needed to satisfy the requested spacing.
30    Resolution(f64),
31    /// Include exactly one octree level.
32    Level(i32),
33    /// Include levels in `[min, max)`.
34    LevelMinMax(i32, i32),
35}
36
37/// Limits points by XYZ bounds.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub enum BoundsSelection {
40    /// No bounds filter.
41    All,
42    /// Include points within the supplied bounds.
43    Within(Bounds),
44}
45
46/// Point query used by [`CopcReader::points_for_query`].
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct PointQuery {
49    pub lod: LodSelection,
50    pub bounds: BoundsSelection,
51}
52
53impl PointQuery {
54    pub const fn all() -> Self {
55        Self {
56            lod: LodSelection::All,
57            bounds: BoundsSelection::All,
58        }
59    }
60
61    pub const fn new(lod: LodSelection, bounds: BoundsSelection) -> Self {
62        Self { lod, bounds }
63    }
64}
65
66impl CopcReader<BufReader<File>> {
67    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
68        let file = File::open(path.as_ref()).map_err(|e| Error::io("open COPC file", e))?;
69        Self::open(BufReader::new(file))
70    }
71}
72
73impl<R: Read + Seek + Send> CopcReader<R> {
74    /// Open a COPC reader from an already-open stream.
75    pub fn open(mut source: R) -> Result<Self> {
76        let file = CopcFile::from_reader(&mut source)?;
77        Ok(Self { source, file })
78    }
79
80    pub fn file(&self) -> &CopcFile {
81        &self.file
82    }
83
84    pub fn header(&self) -> &LasHeader {
85        self.file.header()
86    }
87
88    pub fn copc_info(&self) -> &CopcInfo {
89        self.file.copc_info()
90    }
91
92    pub fn into_inner(self) -> R {
93        self.source
94    }
95
96    /// Iterate points matching the requested LOD and bounds.
97    ///
98    /// The iterator yields `Result<las::Point>` so IO, LAZ, and malformed-point
99    /// failures are reported to the caller instead of panicking mid-stream.
100    pub fn points(
101        &mut self,
102        lod: LodSelection,
103        bounds: BoundsSelection,
104    ) -> Result<PointIter<'_, R>> {
105        self.points_for_query(PointQuery::new(lod, bounds))
106    }
107
108    pub fn points_for_query(&mut self, query: PointQuery) -> Result<PointIter<'_, R>> {
109        PointIter::new(&mut self.source, &self.file, query, None)
110    }
111
112    pub fn points_with_cancel<'a>(
113        &'a mut self,
114        lod: LodSelection,
115        bounds: BoundsSelection,
116        cancel: &'a dyn CancelCheck,
117    ) -> Result<PointIter<'a, R>> {
118        PointIter::new(
119            &mut self.source,
120            &self.file,
121            PointQuery::new(lod, bounds),
122            Some(cancel),
123        )
124    }
125
126    pub fn read_columns(
127        &mut self,
128        query: PointQuery,
129        selection: ColumnSelection,
130    ) -> Result<LasColumnBatch> {
131        self.read_columns_inner(query, selection, None)
132    }
133
134    pub fn read_columns_with_cancel(
135        &mut self,
136        query: PointQuery,
137        selection: ColumnSelection,
138        cancel: &dyn CancelCheck,
139    ) -> Result<LasColumnBatch> {
140        self.read_columns_inner(query, selection, Some(cancel))
141    }
142
143    fn read_columns_inner(
144        &mut self,
145        query: PointQuery,
146        selection: ColumnSelection,
147        cancel: Option<&dyn CancelCheck>,
148    ) -> Result<LasColumnBatch> {
149        let chunks = select_point_chunks(&self.file, query)?;
150        let point_format = self.file.point_format()?;
151        let transforms = self.file.transforms();
152        let bounds = match query.bounds {
153            BoundsSelection::All => None,
154            BoundsSelection::Within(bounds) => Some(bounds),
155        };
156        let mut decoder = ChunkLazDecoder::new(&mut self.source, self.file.laszip_vlr().clone())?;
157        let expected_record_size = usize::from(point_format.len());
158        if decoder.record_size() != expected_record_size {
159            return Err(Error::InvalidData(format!(
160                "LASzip item size is {} bytes, but LAS point record length is {} bytes",
161                decoder.record_size(),
162                expected_record_size
163            )));
164        }
165
166        let capacity = match bounds {
167            Some(_) => 0,
168            None => total_candidate_points(&chunks)?,
169        };
170        let mut columns = selected_column_builders(point_format, selection, capacity)?;
171        let mut point_buf = vec![0u8; expected_record_size];
172        let mut decoded_points = 0usize;
173        let mut accepted_points = 0usize;
174
175        for entry in chunks {
176            if entry.point_count <= 0 {
177                continue;
178            }
179            decoder.seek_to_chunk(entry.offset)?;
180            let points_in_chunk = usize::try_from(entry.point_count).map_err(|_| {
181                Error::InvalidData(format!(
182                    "negative point count {} for {:?}",
183                    entry.point_count, entry.key
184                ))
185            })?;
186            for _ in 0..points_in_chunk {
187                if decoded_points % CANCEL_POLL_STRIDE == 0 {
188                    if let Some(cancel) = cancel {
189                        cancel.check()?;
190                    }
191                }
192
193                decoder.decompress_one(&mut point_buf)?;
194                decoded_points += 1;
195
196                let raw_point = las::raw::Point::read_from(point_buf.as_slice(), &point_format)
197                    .map_err(|e| Error::Las(e.to_string()))?;
198                let x = transforms.x.direct(raw_point.x);
199                let y = transforms.y.direct(raw_point.y);
200                let z = transforms.z.direct(raw_point.z);
201                if let Some(bounds) = bounds {
202                    if !bounds.contains_xyz(x, y, z) {
203                        continue;
204                    }
205                }
206
207                append_columns(&mut columns, &raw_point, (x, y, z))?;
208                accepted_points += 1;
209            }
210        }
211
212        let batch = LasColumnBatch {
213            len: accepted_points,
214            columns,
215        };
216        batch.validate()?;
217        Ok(batch)
218    }
219}
220
221fn selected_column_builders(
222    point_format: LasPointFormat,
223    selection: ColumnSelection,
224    capacity: usize,
225) -> Result<Vec<(ColumnSpec, ColumnData)>> {
226    layout_for_las_format(point_format)
227        .into_iter()
228        .filter(|spec| selection.contains(spec.dimension))
229        .map(|spec| empty_column(spec, capacity))
230        .collect()
231}
232
233fn empty_column(spec: ColumnSpec, capacity: usize) -> Result<(ColumnSpec, ColumnData)> {
234    let data = match spec.scalar {
235        copc_core::ScalarType::F64 => ColumnData::F64(Vec::with_capacity(capacity)),
236        copc_core::ScalarType::F32 => ColumnData::F32(Vec::with_capacity(capacity)),
237        copc_core::ScalarType::I64 => ColumnData::I64(Vec::with_capacity(capacity)),
238        copc_core::ScalarType::I32 => ColumnData::I32(Vec::with_capacity(capacity)),
239        copc_core::ScalarType::I16 => ColumnData::I16(Vec::with_capacity(capacity)),
240        copc_core::ScalarType::I8 => ColumnData::I8(Vec::with_capacity(capacity)),
241        copc_core::ScalarType::U64 => ColumnData::U64(Vec::with_capacity(capacity)),
242        copc_core::ScalarType::U32 => ColumnData::U32(Vec::with_capacity(capacity)),
243        copc_core::ScalarType::U16 => ColumnData::U16(Vec::with_capacity(capacity)),
244        copc_core::ScalarType::U8 => {
245            let capacity = if spec.dimension == LasDimension::ExtraBytes {
246                let width = spec.extra_byte_width().ok_or_else(|| {
247                    Error::InvalidInput("ExtraBytes column requires a non-zero byte width".into())
248                })?;
249                capacity.checked_mul(width).ok_or_else(|| {
250                    Error::InvalidInput("ExtraBytes column capacity exceeds usize range".into())
251                })?
252            } else {
253                capacity
254            };
255            ColumnData::U8(Vec::with_capacity(capacity))
256        }
257        copc_core::ScalarType::Bool => ColumnData::Bool(Vec::with_capacity(capacity)),
258    };
259    Ok((spec, data))
260}
261
262fn append_columns(
263    columns: &mut [(ColumnSpec, ColumnData)],
264    raw_point: &las::raw::Point,
265    xyz: (f64, f64, f64),
266) -> Result<()> {
267    let mut flags = raw_point.flags;
268    let is_overlap = flags.is_overlap();
269    flags.clear_overlap_class();
270    let classification = u8::from(
271        flags
272            .to_classification()
273            .map_err(|e| Error::Las(e.to_string()))?,
274    );
275    let scan_direction_flag = matches!(
276        flags.scan_direction(),
277        las::point::ScanDirection::LeftToRight
278    );
279    let scan_angle_rank = scan_angle_rank_from_degrees(f32::from(raw_point.scan_angle));
280    let context = ColumnAppendContext {
281        raw_point,
282        xyz,
283        flags,
284        classification,
285        is_overlap,
286        scan_direction_flag,
287        scan_angle_rank,
288    };
289
290    for (spec, data) in columns {
291        append_column(*spec, data, &context)?;
292    }
293    Ok(())
294}
295
296struct ColumnAppendContext<'a> {
297    raw_point: &'a las::raw::Point,
298    xyz: (f64, f64, f64),
299    flags: las::raw::point::Flags,
300    classification: u8,
301    is_overlap: bool,
302    scan_direction_flag: bool,
303    scan_angle_rank: i16,
304}
305
306fn append_column(
307    spec: ColumnSpec,
308    data: &mut ColumnData,
309    context: &ColumnAppendContext<'_>,
310) -> Result<()> {
311    let dimension = spec.dimension;
312    let scalar = data.scalar();
313    match (dimension, data) {
314        (LasDimension::X, ColumnData::F64(values)) => values.push(context.xyz.0),
315        (LasDimension::Y, ColumnData::F64(values)) => values.push(context.xyz.1),
316        (LasDimension::Z, ColumnData::F64(values)) => values.push(context.xyz.2),
317        (LasDimension::Intensity, ColumnData::U16(values)) => {
318            values.push(context.raw_point.intensity);
319        }
320        (LasDimension::ReturnNumber, ColumnData::U8(values)) => {
321            values.push(context.flags.return_number());
322        }
323        (LasDimension::NumberOfReturns, ColumnData::U8(values)) => {
324            values.push(context.flags.number_of_returns());
325        }
326        (LasDimension::Classification, ColumnData::U8(values)) => {
327            values.push(context.classification);
328        }
329        (LasDimension::ScanDirectionFlag, ColumnData::Bool(values)) => {
330            values.push(context.scan_direction_flag);
331        }
332        (LasDimension::EdgeOfFlightLine, ColumnData::Bool(values)) => {
333            values.push(context.flags.is_edge_of_flight_line());
334        }
335        (LasDimension::ScanAngleRank, ColumnData::I16(values)) => {
336            values.push(context.scan_angle_rank);
337        }
338        (LasDimension::UserData, ColumnData::U8(values)) => {
339            values.push(context.raw_point.user_data);
340        }
341        (LasDimension::PointSourceId, ColumnData::U16(values)) => {
342            values.push(context.raw_point.point_source_id);
343        }
344        (LasDimension::Synthetic, ColumnData::Bool(values)) => {
345            values.push(context.flags.is_synthetic());
346        }
347        (LasDimension::KeyPoint, ColumnData::Bool(values)) => {
348            values.push(context.flags.is_key_point());
349        }
350        (LasDimension::Withheld, ColumnData::Bool(values)) => {
351            values.push(context.flags.is_withheld());
352        }
353        (LasDimension::Overlap, ColumnData::Bool(values)) => values.push(context.is_overlap),
354        (LasDimension::ScanChannel, ColumnData::U8(values)) => {
355            values.push(context.flags.scanner_channel());
356        }
357        (LasDimension::GpsTime, ColumnData::F64(values)) => {
358            values.push(context.raw_point.gps_time.unwrap_or(0.0));
359        }
360        (LasDimension::Red, ColumnData::U16(values)) => {
361            values.push(context.raw_point.color.unwrap_or_default().red);
362        }
363        (LasDimension::Green, ColumnData::U16(values)) => {
364            values.push(context.raw_point.color.unwrap_or_default().green);
365        }
366        (LasDimension::Blue, ColumnData::U16(values)) => {
367            values.push(context.raw_point.color.unwrap_or_default().blue);
368        }
369        (LasDimension::Nir, ColumnData::U16(values)) => {
370            values.push(context.raw_point.nir.unwrap_or(0));
371        }
372        (LasDimension::WaveformPacketDescriptorIndex, ColumnData::U8(values)) => {
373            values.push(
374                context
375                    .raw_point
376                    .waveform
377                    .unwrap_or_default()
378                    .wave_packet_descriptor_index,
379            );
380        }
381        (LasDimension::WaveformPacketByteOffset, ColumnData::U64(values)) => {
382            values.push(
383                context
384                    .raw_point
385                    .waveform
386                    .unwrap_or_default()
387                    .byte_offset_to_waveform_data,
388            );
389        }
390        (LasDimension::WaveformPacketSize, ColumnData::U32(values)) => {
391            values.push(
392                context
393                    .raw_point
394                    .waveform
395                    .unwrap_or_default()
396                    .waveform_packet_size_in_bytes,
397            );
398        }
399        (LasDimension::WavePacketReturnPointWaveformLocation, ColumnData::F32(values)) => {
400            values.push(
401                context
402                    .raw_point
403                    .waveform
404                    .unwrap_or_default()
405                    .return_point_waveform_location,
406            );
407        }
408        (LasDimension::ExtraBytes, ColumnData::U8(values)) => {
409            let width = spec.extra_byte_width().ok_or_else(|| {
410                Error::InvalidData("ExtraBytes column requires a non-zero byte width".into())
411            })?;
412            if context.raw_point.extra_bytes.len() != width {
413                return Err(Error::InvalidData(format!(
414                    "ExtraBytes point has {} bytes, expected {width}",
415                    context.raw_point.extra_bytes.len()
416                )));
417            }
418            values.extend_from_slice(&context.raw_point.extra_bytes);
419        }
420        _ => {
421            return Err(Error::InvalidData(format!(
422                "column {:?} has incompatible data type {:?}",
423                dimension, scalar
424            )));
425        }
426    }
427    Ok(())
428}
429
430/// Iterator over selected COPC point chunks.
431pub struct PointIter<'a, R: Read + Seek + Send> {
432    chunks: Vec<Entry>,
433    next_chunk: usize,
434    current_chunk_points_left: usize,
435    remaining_candidate_points: usize,
436    exact_size: bool,
437    point_format: LasPointFormat,
438    transforms: Vector<Transform>,
439    bounds: Option<Bounds>,
440    decoder: ChunkLazDecoder<'a, R>,
441    point_buf: Vec<u8>,
442    decoded_points: usize,
443    cancel: Option<&'a dyn CancelCheck>,
444    finished: bool,
445}
446
447impl<'a, R: Read + Seek + Send> PointIter<'a, R> {
448    fn new(
449        source: &'a mut R,
450        file: &CopcFile,
451        query: PointQuery,
452        cancel: Option<&'a dyn CancelCheck>,
453    ) -> Result<Self> {
454        let chunks = select_point_chunks(file, query)?;
455        let point_format = file.point_format()?;
456        let transforms = file.transforms();
457        let bounds = match query.bounds {
458            BoundsSelection::All => None,
459            BoundsSelection::Within(bounds) => Some(bounds),
460        };
461        let decoder = ChunkLazDecoder::new(source, file.laszip_vlr().clone())?;
462        let expected_record_size = usize::from(point_format.len());
463        if decoder.record_size() != expected_record_size {
464            return Err(Error::InvalidData(format!(
465                "LASzip item size is {} bytes, but LAS point record length is {} bytes",
466                decoder.record_size(),
467                expected_record_size
468            )));
469        }
470        let remaining_candidate_points = total_candidate_points(&chunks)?;
471        let point_buf = vec![0u8; expected_record_size];
472        Ok(Self {
473            chunks,
474            next_chunk: 0,
475            current_chunk_points_left: 0,
476            remaining_candidate_points,
477            exact_size: bounds.is_none(),
478            point_format,
479            transforms,
480            bounds,
481            decoder,
482            point_buf,
483            decoded_points: 0,
484            cancel,
485            finished: false,
486        })
487    }
488
489    fn load_next_chunk(&mut self) -> Result<bool> {
490        while self.next_chunk < self.chunks.len() {
491            let entry = self.chunks[self.next_chunk];
492            self.next_chunk += 1;
493            if entry.point_count <= 0 {
494                continue;
495            }
496            self.decoder.seek_to_chunk(entry.offset)?;
497            self.current_chunk_points_left = usize::try_from(entry.point_count).map_err(|_| {
498                Error::InvalidData(format!(
499                    "negative point count {} for {:?}",
500                    entry.point_count, entry.key
501                ))
502            })?;
503            return Ok(true);
504        }
505        Ok(false)
506    }
507
508    fn next_inner(&mut self) -> Result<Option<Point>> {
509        loop {
510            while self.current_chunk_points_left == 0 {
511                if !self.load_next_chunk()? {
512                    return Ok(None);
513                }
514            }
515
516            if self.decoded_points % CANCEL_POLL_STRIDE == 0 {
517                if let Some(cancel) = self.cancel {
518                    cancel.check()?;
519                }
520            }
521
522            self.decoder.decompress_one(&mut self.point_buf)?;
523            self.current_chunk_points_left -= 1;
524            self.remaining_candidate_points -= 1;
525            self.decoded_points += 1;
526
527            let raw_point =
528                las::raw::Point::read_from(self.point_buf.as_slice(), &self.point_format)
529                    .map_err(|e| Error::Las(e.to_string()))?;
530            if let Some(bounds) = self.bounds {
531                let x = self.transforms.x.direct(raw_point.x);
532                let y = self.transforms.y.direct(raw_point.y);
533                let z = self.transforms.z.direct(raw_point.z);
534                if !bounds.contains_xyz(x, y, z) {
535                    continue;
536                }
537            }
538            return Ok(Some(Point::new(raw_point, &self.transforms)));
539        }
540    }
541}
542
543impl<R: Read + Seek + Send> Iterator for PointIter<'_, R> {
544    type Item = Result<Point>;
545
546    fn next(&mut self) -> Option<Self::Item> {
547        if self.finished {
548            return None;
549        }
550        match self.next_inner() {
551            Ok(Some(point)) => Some(Ok(point)),
552            Ok(None) => {
553                self.finished = true;
554                None
555            }
556            Err(error) => {
557                self.finished = true;
558                Some(Err(error))
559            }
560        }
561    }
562
563    fn size_hint(&self) -> (usize, Option<usize>) {
564        if self.exact_size {
565            (
566                self.remaining_candidate_points,
567                Some(self.remaining_candidate_points),
568            )
569        } else {
570            (0, Some(self.remaining_candidate_points))
571        }
572    }
573}
574
575struct ChunkLazDecoder<'a, R: Read + Seek + Send> {
576    laz_vlr: LazVlr,
577    decompressor: LayeredPointRecordDecompressor<'a, &'a mut R>,
578    record_size: usize,
579}
580
581impl<'a, R: Read + Seek + Send> ChunkLazDecoder<'a, R> {
582    fn new(source: &'a mut R, laz_vlr: LazVlr) -> Result<Self> {
583        let mut decompressor = LayeredPointRecordDecompressor::new(source);
584        let record_size = configure_layered_decompressor(&mut decompressor, &laz_vlr)?;
585        Ok(Self {
586            laz_vlr,
587            decompressor,
588            record_size,
589        })
590    }
591
592    fn record_size(&self) -> usize {
593        self.record_size
594    }
595
596    fn seek_to_chunk(&mut self, offset: u64) -> Result<()> {
597        self.decompressor
598            .get_mut()
599            .seek(SeekFrom::Start(offset))
600            .map_err(|e| Error::io("seek COPC point chunk", e))?;
601        self.decompressor.reset();
602        self.record_size = configure_layered_decompressor(&mut self.decompressor, &self.laz_vlr)?;
603        Ok(())
604    }
605
606    fn decompress_one(&mut self, out: &mut [u8]) -> Result<()> {
607        self.decompressor
608            .decompress_next(out)
609            .map_err(|e| Error::io("decompress COPC point", e))
610    }
611}
612
613fn configure_layered_decompressor<R: Read + Seek>(
614    decompressor: &mut LayeredPointRecordDecompressor<'_, R>,
615    laz_vlr: &LazVlr,
616) -> Result<usize> {
617    decompressor
618        .set_fields_from(laz_vlr.items())
619        .map_err(|e| Error::Las(e.to_string()))?;
620    let record_size = decompressor.record_size();
621    if record_size == 0 {
622        return Err(Error::Unsupported(
623            "COPC point iteration requires layered LAZ point records".into(),
624        ));
625    }
626    Ok(record_size)
627}
628
629fn select_point_chunks(file: &CopcFile, query: PointQuery) -> Result<Vec<Entry>> {
630    let (level_min, level_max) = level_range(query.lod, file.copc_info())?;
631    let query_bounds = match query.bounds {
632        BoundsSelection::All => None,
633        BoundsSelection::Within(bounds) => Some(bounds),
634    };
635
636    let mut chunks = Vec::new();
637    for entry in file.hierarchy_entries() {
638        if !entry.has_point_data() {
639            continue;
640        }
641        if entry.byte_size <= 0 {
642            return Err(Error::InvalidData(format!(
643                "point chunk {:?} has invalid byte size {}",
644                entry.key, entry.byte_size
645            )));
646        }
647        if !(level_min..level_max).contains(&entry.key.level) {
648            continue;
649        }
650        if let Some(bounds) = query_bounds {
651            let node_bounds = voxel_bounds(entry.key, file.copc_info())?;
652            if !node_bounds.intersects(bounds) {
653                continue;
654            }
655        }
656        chunks.push(*entry);
657    }
658    chunks.sort_by_key(|entry| (entry.offset, entry.key));
659    Ok(chunks)
660}
661
662fn level_range(selection: LodSelection, info: &CopcInfo) -> Result<(i32, i32)> {
663    match selection {
664        LodSelection::All => Ok((0, i32::MAX)),
665        LodSelection::Resolution(resolution) => {
666            if !resolution.is_finite() || resolution <= 0.0 {
667                return Err(Error::InvalidInput(format!(
668                    "resolution must be finite and positive, got {resolution}"
669                )));
670            }
671            if !info.spacing.is_finite() || info.spacing <= 0.0 {
672                return Err(Error::InvalidData(format!(
673                    "COPC spacing must be finite and positive, got {}",
674                    info.spacing
675                )));
676            }
677            let level_max = ((info.spacing / resolution).log2().ceil() as i64 + 1)
678                .max(1)
679                .min(i64::from(i32::MAX)) as i32;
680            Ok((0, level_max))
681        }
682        LodSelection::Level(level) => {
683            validate_level(level)?;
684            let max = level
685                .checked_add(1)
686                .ok_or_else(|| Error::InvalidInput(format!("LOD level {level} is too large")))?;
687            Ok((level, max))
688        }
689        LodSelection::LevelMinMax(min, max) => {
690            validate_level(min)?;
691            validate_level(max)?;
692            if max < min {
693                return Err(Error::InvalidInput(format!(
694                    "LOD max {max} is smaller than min {min}"
695                )));
696            }
697            Ok((min, max))
698        }
699    }
700}
701
702fn validate_level(level: i32) -> Result<()> {
703    if level < 0 {
704        return Err(Error::InvalidInput(format!(
705            "LOD level must be non-negative, got {level}"
706        )));
707    }
708    Ok(())
709}
710
711fn total_candidate_points(entries: &[Entry]) -> Result<usize> {
712    entries.iter().try_fold(0usize, |total, entry| {
713        let count = usize::try_from(entry.point_count).map_err(|_| {
714            Error::InvalidData(format!(
715                "negative point count {} for {:?}",
716                entry.point_count, entry.key
717            ))
718        })?;
719        total
720            .checked_add(count)
721            .ok_or_else(|| Error::InvalidData("selected point count overflows usize".into()))
722    })
723}
724
725fn voxel_bounds(key: copc_core::VoxelKey, info: &CopcInfo) -> Result<Bounds> {
726    if key.level < 0 || key.x < 0 || key.y < 0 || key.z < 0 {
727        return Err(Error::InvalidData(format!(
728            "invalid negative voxel key {:?}",
729            key
730        )));
731    }
732    let side = (info.halfsize * 2.0) / 2.0_f64.powi(key.level);
733    let root_min = (
734        info.center.0 - info.halfsize,
735        info.center.1 - info.halfsize,
736        info.center.2 - info.halfsize,
737    );
738    let min = (
739        root_min.0 + f64::from(key.x) * side,
740        root_min.1 + f64::from(key.y) * side,
741        root_min.2 + f64::from(key.z) * side,
742    );
743    Ok(Bounds::new(min, (min.0 + side, min.1 + side, min.2 + side)))
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn selected_column_builders_include_extra_bytes_width() {
752        let mut format = LasPointFormat::new(6).unwrap();
753        format.extra_bytes = 3;
754
755        let columns = selected_column_builders(format, ColumnSelection::all(), 2).unwrap();
756
757        let extra_spec = columns
758            .iter()
759            .map(|(spec, _)| *spec)
760            .find(|spec| spec.dimension == LasDimension::ExtraBytes)
761            .expect("ExtraBytes column spec");
762        assert_eq!(Some(3), extra_spec.extra_byte_width());
763        assert_eq!(copc_core::ScalarType::U8, extra_spec.scalar);
764    }
765
766    #[test]
767    fn append_columns_preserves_fixed_width_extra_bytes() {
768        let mut format = LasPointFormat::new(6).unwrap();
769        format.extra_bytes = 3;
770        let mut columns = selected_column_builders(
771            format,
772            ColumnSelection::from_dimensions([LasDimension::X, LasDimension::ExtraBytes]),
773            1,
774        )
775        .unwrap();
776        let raw_point = las::raw::Point {
777            x: 10,
778            y: 20,
779            z: 30,
780            flags: las::raw::point::Flags::ThreeByte(1 | (1 << 4), 0, 2),
781            scan_angle: las::raw::point::ScanAngle::from(0.0),
782            extra_bytes: vec![9, 8, 7],
783            ..Default::default()
784        };
785
786        append_columns(&mut columns, &raw_point, (1.0, 2.0, 3.0)).unwrap();
787        let batch = LasColumnBatch::new(columns).unwrap();
788
789        assert_eq!(1, batch.len());
790        assert_eq!(
791            Some(&ColumnData::U8(vec![9, 8, 7])),
792            batch.column(LasDimension::ExtraBytes)
793        );
794    }
795
796    #[test]
797    fn append_columns_rejects_wrong_extra_bytes_width() {
798        let mut format = LasPointFormat::new(6).unwrap();
799        format.extra_bytes = 3;
800        let mut columns = selected_column_builders(
801            format,
802            ColumnSelection::from_dimensions([LasDimension::ExtraBytes]),
803            1,
804        )
805        .unwrap();
806        let raw_point = las::raw::Point {
807            flags: las::raw::point::Flags::ThreeByte(1 | (1 << 4), 0, 2),
808            extra_bytes: vec![9, 8],
809            ..Default::default()
810        };
811
812        let err = append_columns(&mut columns, &raw_point, (1.0, 2.0, 3.0)).unwrap_err();
813
814        assert!(err.to_string().contains("expected 3"));
815    }
816}