Skip to main content

copc_reader/
points.rs

1use std::fs::File;
2use std::io::{BufReader, Cursor, Read, Seek, SeekFrom};
3use std::path::Path;
4
5use copc_core::{
6    layout_for_las_format, Bounds, CancelCheck, ColumnData, ColumnSelection, ColumnSpec, CopcInfo,
7    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/// Upper bound on the column capacity reserved up front from hierarchy point
18/// counts. Counts are validated against the LAS header total at open, but the
19/// header itself is untrusted input, so reservations beyond this grow lazily.
20pub(crate) const MAX_INITIAL_COLUMN_RESERVE_POINTS: usize = 4 * 1024 * 1024;
21
22/// COPC point reader owning the underlying stream.
23pub struct CopcReader<R> {
24    source: R,
25    file: CopcFile,
26}
27
28/// Limits the octree levels included in a point query.
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub enum LodSelection {
31    /// Full resolution: every point chunk in every LOD.
32    All,
33    /// Include levels needed to satisfy the requested spacing.
34    Resolution(f64),
35    /// Include exactly one octree level.
36    Level(i32),
37    /// Include levels in `[min, max)`.
38    LevelMinMax(i32, i32),
39}
40
41/// Limits points by XYZ bounds.
42#[derive(Clone, Copy, Debug, PartialEq)]
43pub enum BoundsSelection {
44    /// No bounds filter.
45    All,
46    /// Include points within the supplied bounds.
47    Within(Bounds),
48}
49
50/// Point query used by [`CopcReader::points_for_query`].
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct PointQuery {
53    pub lod: LodSelection,
54    pub bounds: BoundsSelection,
55}
56
57impl PointQuery {
58    pub const fn all() -> Self {
59        Self {
60            lod: LodSelection::All,
61            bounds: BoundsSelection::All,
62        }
63    }
64
65    pub const fn new(lod: LodSelection, bounds: BoundsSelection) -> Self {
66        Self { lod, bounds }
67    }
68}
69
70impl CopcReader<BufReader<File>> {
71    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
72        let file = File::open(path.as_ref()).map_err(|e| Error::io("open COPC file", e))?;
73        Self::open(BufReader::new(file))
74    }
75}
76
77impl<R: Read + Seek + Send> CopcReader<R> {
78    /// Open a COPC reader from an already-open stream.
79    pub fn open(mut source: R) -> Result<Self> {
80        let file = CopcFile::from_reader(&mut source)?;
81        Ok(Self { source, file })
82    }
83
84    pub fn file(&self) -> &CopcFile {
85        &self.file
86    }
87
88    pub fn header(&self) -> &LasHeader {
89        self.file.header()
90    }
91
92    pub fn copc_info(&self) -> &CopcInfo {
93        self.file.copc_info()
94    }
95
96    pub fn into_inner(self) -> R {
97        self.source
98    }
99
100    /// Iterate points matching the requested LOD and bounds.
101    ///
102    /// The iterator yields `Result<las::Point>` so IO, LAZ, and malformed-point
103    /// failures are reported to the caller instead of panicking mid-stream.
104    pub fn points(
105        &mut self,
106        lod: LodSelection,
107        bounds: BoundsSelection,
108    ) -> Result<PointIter<'_, R>> {
109        self.points_for_query(PointQuery::new(lod, bounds))
110    }
111
112    pub fn points_for_query(&mut self, query: PointQuery) -> Result<PointIter<'_, R>> {
113        PointIter::new(&mut self.source, &self.file, query, None)
114    }
115
116    pub fn points_with_cancel<'a>(
117        &'a mut self,
118        lod: LodSelection,
119        bounds: BoundsSelection,
120        cancel: &'a dyn CancelCheck,
121    ) -> Result<PointIter<'a, R>> {
122        PointIter::new(
123            &mut self.source,
124            &self.file,
125            PointQuery::new(lod, bounds),
126            Some(cancel),
127        )
128    }
129
130    pub fn read_columns(
131        &mut self,
132        query: PointQuery,
133        selection: ColumnSelection,
134    ) -> Result<LasColumnBatch> {
135        self.read_columns_inner(query, selection, None)
136    }
137
138    pub fn read_columns_with_cancel(
139        &mut self,
140        query: PointQuery,
141        selection: ColumnSelection,
142        cancel: &dyn CancelCheck,
143    ) -> Result<LasColumnBatch> {
144        self.read_columns_inner(query, selection, Some(cancel))
145    }
146
147    fn read_columns_inner(
148        &mut self,
149        query: PointQuery,
150        selection: ColumnSelection,
151        cancel: Option<&dyn CancelCheck>,
152    ) -> Result<LasColumnBatch> {
153        let chunks = select_point_chunks(&self.file, query)?;
154        let point_format = self.file.point_format()?;
155        let decoder = ChunkColumnDecoder::new(
156            self.file.laszip_vlr().clone(),
157            point_format,
158            self.file.transforms(),
159            match query.bounds {
160                BoundsSelection::All => None,
161                BoundsSelection::Within(bounds) => Some(bounds),
162            },
163        )?;
164
165        let capacity = match decoder.bounds {
166            Some(_) => 0,
167            None => total_candidate_points(&chunks)?.min(MAX_INITIAL_COLUMN_RESERVE_POINTS),
168        };
169        let mut columns = selected_column_builders(point_format, selection.clone(), capacity)?;
170        let mut accepted_points = 0usize;
171
172        #[cfg(not(feature = "parallel"))]
173        {
174            let mut chunk_bytes = Vec::new();
175            for entry in chunks {
176                if let Some(cancel) = cancel {
177                    cancel.check()?;
178                }
179                let points_in_chunk = read_chunk_bytes(&mut self.source, entry, &mut chunk_bytes)?;
180                accepted_points +=
181                    decoder.decode_into(&chunk_bytes, points_in_chunk, &mut columns, cancel)?;
182            }
183        }
184
185        #[cfg(feature = "parallel")]
186        {
187            use rayon::prelude::*;
188
189            type DecodedChunk = Result<(Vec<(ColumnSpec, ColumnData)>, usize)>;
190
191            let batch_size = rayon::current_num_threads().max(1) * 2;
192            for batch in chunks.chunks(batch_size) {
193                if let Some(cancel) = cancel {
194                    cancel.check()?;
195                }
196                let mut batch_bytes = Vec::with_capacity(batch.len());
197                for entry in batch {
198                    let mut chunk_bytes = Vec::new();
199                    let points_in_chunk =
200                        read_chunk_bytes(&mut self.source, *entry, &mut chunk_bytes)?;
201                    batch_bytes.push((chunk_bytes, points_in_chunk));
202                }
203                let decoded: Vec<DecodedChunk> = batch_bytes
204                    .par_iter()
205                    .map(|(chunk_bytes, points_in_chunk)| {
206                        let mut chunk_columns = selected_column_builders(
207                            point_format,
208                            selection.clone(),
209                            (*points_in_chunk).min(MAX_INITIAL_COLUMN_RESERVE_POINTS),
210                        )?;
211                        let accepted = decoder.decode_into(
212                            chunk_bytes,
213                            *points_in_chunk,
214                            &mut chunk_columns,
215                            None,
216                        )?;
217                        Ok((chunk_columns, accepted))
218                    })
219                    .collect();
220                for result in decoded {
221                    let (chunk_columns, accepted) = result?;
222                    merge_columns(&mut columns, chunk_columns)?;
223                    accepted_points += accepted;
224                }
225            }
226        }
227
228        let batch = LasColumnBatch {
229            len: accepted_points,
230            columns,
231        };
232        batch.validate()?;
233        Ok(batch)
234    }
235}
236
237/// Reads one chunk's compressed bytes into `chunk_bytes` (replacing its
238/// contents) and returns the chunk's point count.
239fn read_chunk_bytes<R: Read + Seek>(
240    source: &mut R,
241    entry: Entry,
242    chunk_bytes: &mut Vec<u8>,
243) -> Result<usize> {
244    let points_in_chunk = usize::try_from(entry.point_count).map_err(|_| {
245        Error::InvalidData(format!(
246            "negative point count {} for {:?}",
247            entry.point_count, entry.key
248        ))
249    })?;
250    let byte_size = usize::try_from(entry.byte_size).map_err(|_| {
251        Error::InvalidData(format!(
252            "invalid byte size {} for {:?}",
253            entry.byte_size, entry.key
254        ))
255    })?;
256    chunk_bytes.clear();
257    chunk_bytes.resize(byte_size, 0);
258    source
259        .seek(SeekFrom::Start(entry.offset))
260        .map_err(|e| Error::io("seek COPC point chunk", e))?;
261    source
262        .read_exact(chunk_bytes)
263        .map_err(|e| Error::io("read COPC point chunk", e))?;
264    Ok(points_in_chunk)
265}
266
267/// Decodes standalone COPC chunk bytes into column builders.
268pub(crate) struct ChunkColumnDecoder {
269    laz_vlr: LazVlr,
270    layout: ExtendedPointLayout,
271    transforms: Vector<Transform>,
272    bounds: Option<Bounds>,
273    record_size: usize,
274}
275
276impl ChunkColumnDecoder {
277    pub(crate) fn new(
278        laz_vlr: LazVlr,
279        point_format: LasPointFormat,
280        transforms: Vector<Transform>,
281        bounds: Option<Bounds>,
282    ) -> Result<Self> {
283        let layout = ExtendedPointLayout::for_format(&point_format)?;
284        let record_size = validated_record_size(&laz_vlr, &point_format)?;
285        Ok(Self {
286            laz_vlr,
287            layout,
288            transforms,
289            bounds,
290            record_size,
291        })
292    }
293
294    /// Decode `points_in_chunk` points from one chunk's compressed bytes,
295    /// appending selected values to `columns`; returns the number of points
296    /// accepted by the bounds filter.
297    pub(crate) fn decode_into(
298        &self,
299        chunk_bytes: &[u8],
300        points_in_chunk: usize,
301        columns: &mut [(ColumnSpec, ColumnData)],
302        cancel: Option<&dyn CancelCheck>,
303    ) -> Result<usize> {
304        let mut decompressor = LayeredPointRecordDecompressor::new(Cursor::new(chunk_bytes));
305        configure_layered_decompressor(&mut decompressor, &self.laz_vlr)?;
306        let mut point_buf = vec![0u8; self.record_size];
307        let mut accepted = 0usize;
308        for decoded in 0..points_in_chunk {
309            if decoded.is_multiple_of(CANCEL_POLL_STRIDE) {
310                if let Some(cancel) = cancel {
311                    cancel.check()?;
312                }
313            }
314            decompressor
315                .decompress_next(&mut point_buf)
316                .map_err(|e| Error::io("decompress COPC point", e))?;
317
318            let x = self.transforms.x.direct(i32_at(&point_buf, 0));
319            let y = self.transforms.y.direct(i32_at(&point_buf, 4));
320            let z = self.transforms.z.direct(i32_at(&point_buf, 8));
321            if let Some(bounds) = self.bounds {
322                if !bounds.contains_xyz(x, y, z) {
323                    continue;
324                }
325            }
326
327            append_columns(columns, &point_buf, &self.layout, (x, y, z))?;
328            accepted += 1;
329        }
330        Ok(accepted)
331    }
332}
333
334/// Decodes one chunk's compressed bytes into `las::Point` rows, appending
335/// bounds-accepted points to `out`.
336pub(crate) fn decode_chunk_points(
337    chunk_bytes: &[u8],
338    points_in_chunk: usize,
339    laz_vlr: &LazVlr,
340    point_format: &LasPointFormat,
341    transforms: &Vector<Transform>,
342    bounds: Option<Bounds>,
343    out: &mut Vec<Point>,
344) -> Result<()> {
345    let mut decompressor = LayeredPointRecordDecompressor::new(Cursor::new(chunk_bytes));
346    let record_size = configure_layered_decompressor(&mut decompressor, laz_vlr)?;
347    if record_size != usize::from(point_format.len()) {
348        return Err(Error::InvalidData(format!(
349            "LASzip item size is {record_size} bytes, but LAS point record length is {} bytes",
350            point_format.len()
351        )));
352    }
353    let mut point_buf = vec![0u8; record_size];
354    for _ in 0..points_in_chunk {
355        decompressor
356            .decompress_next(&mut point_buf)
357            .map_err(|e| Error::io("decompress COPC point", e))?;
358        let raw_point = las::raw::Point::read_from(point_buf.as_slice(), point_format)
359            .map_err(|e| Error::Las(e.to_string()))?;
360        if let Some(bounds) = bounds {
361            let x = transforms.x.direct(raw_point.x);
362            let y = transforms.y.direct(raw_point.y);
363            let z = transforms.z.direct(raw_point.z);
364            if !bounds.contains_xyz(x, y, z) {
365                continue;
366            }
367        }
368        out.push(Point::new(raw_point, transforms));
369    }
370    Ok(())
371}
372
373/// Validates the LASzip item layout against the LAS point record length and
374/// returns the record size.
375fn validated_record_size(laz_vlr: &LazVlr, point_format: &LasPointFormat) -> Result<usize> {
376    let mut probe = LayeredPointRecordDecompressor::new(Cursor::new(&[][..]));
377    let record_size = configure_layered_decompressor(&mut probe, laz_vlr)?;
378    let expected = usize::from(point_format.len());
379    if record_size != expected {
380        return Err(Error::InvalidData(format!(
381            "LASzip item size is {record_size} bytes, but LAS point record length is {expected} bytes"
382        )));
383    }
384    Ok(expected)
385}
386
387/// Appends `src` column data onto `dst`, preserving chunk order.
388#[cfg(feature = "parallel")]
389fn merge_columns(
390    dst: &mut [(ColumnSpec, ColumnData)],
391    src: Vec<(ColumnSpec, ColumnData)>,
392) -> Result<()> {
393    if dst.len() != src.len() {
394        return Err(Error::InvalidData(format!(
395            "chunk produced {} columns, expected {}",
396            src.len(),
397            dst.len()
398        )));
399    }
400    for ((dst_spec, dst_data), (src_spec, src_data)) in dst.iter_mut().zip(src) {
401        if *dst_spec != src_spec {
402            return Err(Error::InvalidData(format!(
403                "chunk column spec mismatch: found {src_spec:?}, expected {dst_spec:?}"
404            )));
405        }
406        match (dst_data, src_data) {
407            (ColumnData::F64(dst), ColumnData::F64(src)) => dst.extend_from_slice(&src),
408            (ColumnData::F32(dst), ColumnData::F32(src)) => dst.extend_from_slice(&src),
409            (ColumnData::I64(dst), ColumnData::I64(src)) => dst.extend_from_slice(&src),
410            (ColumnData::I32(dst), ColumnData::I32(src)) => dst.extend_from_slice(&src),
411            (ColumnData::I16(dst), ColumnData::I16(src)) => dst.extend_from_slice(&src),
412            (ColumnData::I8(dst), ColumnData::I8(src)) => dst.extend_from_slice(&src),
413            (ColumnData::U64(dst), ColumnData::U64(src)) => dst.extend_from_slice(&src),
414            (ColumnData::U32(dst), ColumnData::U32(src)) => dst.extend_from_slice(&src),
415            (ColumnData::U16(dst), ColumnData::U16(src)) => dst.extend_from_slice(&src),
416            (ColumnData::U8(dst), ColumnData::U8(src)) => dst.extend_from_slice(&src),
417            (ColumnData::Bool(dst), ColumnData::Bool(src)) => dst.extend_from_slice(&src),
418            _ => {
419                return Err(Error::InvalidData(
420                    "chunk column scalar mismatch during merge".into(),
421                ))
422            }
423        }
424    }
425    Ok(())
426}
427
428pub(crate) fn selected_column_builders(
429    point_format: LasPointFormat,
430    selection: ColumnSelection,
431    capacity: usize,
432) -> Result<Vec<(ColumnSpec, ColumnData)>> {
433    layout_for_las_format(point_format)
434        .into_iter()
435        .filter(|spec| selection.contains(spec.dimension))
436        .map(|spec| empty_column(spec, capacity))
437        .collect()
438}
439
440fn empty_column(spec: ColumnSpec, capacity: usize) -> Result<(ColumnSpec, ColumnData)> {
441    let data = match spec.scalar {
442        copc_core::ScalarType::F64 => ColumnData::F64(Vec::with_capacity(capacity)),
443        copc_core::ScalarType::F32 => ColumnData::F32(Vec::with_capacity(capacity)),
444        copc_core::ScalarType::I64 => ColumnData::I64(Vec::with_capacity(capacity)),
445        copc_core::ScalarType::I32 => ColumnData::I32(Vec::with_capacity(capacity)),
446        copc_core::ScalarType::I16 => ColumnData::I16(Vec::with_capacity(capacity)),
447        copc_core::ScalarType::I8 => ColumnData::I8(Vec::with_capacity(capacity)),
448        copc_core::ScalarType::U64 => ColumnData::U64(Vec::with_capacity(capacity)),
449        copc_core::ScalarType::U32 => ColumnData::U32(Vec::with_capacity(capacity)),
450        copc_core::ScalarType::U16 => ColumnData::U16(Vec::with_capacity(capacity)),
451        copc_core::ScalarType::U8 => {
452            let capacity = if spec.dimension == LasDimension::ExtraBytes {
453                let width = spec.extra_byte_width().ok_or_else(|| {
454                    Error::InvalidInput("ExtraBytes column requires a non-zero byte width".into())
455                })?;
456                capacity.checked_mul(width).ok_or_else(|| {
457                    Error::InvalidInput("ExtraBytes column capacity exceeds usize range".into())
458                })?
459            } else {
460                capacity
461            };
462            ColumnData::U8(Vec::with_capacity(capacity))
463        }
464        copc_core::ScalarType::Bool => ColumnData::Bool(Vec::with_capacity(capacity)),
465    };
466    Ok((spec, data))
467}
468
469/// Reader-side scan angle scale, matching `las::raw::point::ScanAngle`'s
470/// scaled-to-degrees conversion for LAS 1.4 extended formats.
471const LAS_14_SCAN_ANGLE_SCALE: f32 = 0.006;
472/// Overlap classification code; extended points with this class are reported
473/// as `Overlap = true`, `Classification = 1`, matching `las::raw::point::Flags`.
474const OVERLAP_CLASSIFICATION_CODE: u8 = 12;
475
476/// Field offsets within an extended (PDRF 6-10) point record, so columns can
477/// be filled directly from the decompressed record bytes without building a
478/// `las::raw::Point` (and its `extra_bytes` allocation) per point.
479struct ExtendedPointLayout {
480    color_offset: Option<usize>,
481    nir_offset: Option<usize>,
482    waveform_offset: Option<usize>,
483    extra_offset: usize,
484    extra_len: usize,
485}
486
487impl ExtendedPointLayout {
488    fn for_format(format: &LasPointFormat) -> Result<Self> {
489        if !format.is_extended {
490            return Err(Error::Unsupported(
491                "COPC column reads require extended point formats (6-10)".into(),
492            ));
493        }
494        let mut cursor = 30usize;
495        let color_offset = if format.has_color {
496            let offset = cursor;
497            cursor += 6;
498            Some(offset)
499        } else {
500            None
501        };
502        let nir_offset = if format.has_nir {
503            let offset = cursor;
504            cursor += 2;
505            Some(offset)
506        } else {
507            None
508        };
509        let waveform_offset = if format.has_waveform {
510            let offset = cursor;
511            cursor += 29;
512            Some(offset)
513        } else {
514            None
515        };
516        let extra_offset = cursor;
517        let extra_len = usize::from(format.extra_bytes);
518        debug_assert_eq!(extra_offset + extra_len, usize::from(format.len()));
519        Ok(Self {
520            color_offset,
521            nir_offset,
522            waveform_offset,
523            extra_offset,
524            extra_len,
525        })
526    }
527}
528
529fn append_columns(
530    columns: &mut [(ColumnSpec, ColumnData)],
531    buf: &[u8],
532    layout: &ExtendedPointLayout,
533    xyz: (f64, f64, f64),
534) -> Result<()> {
535    let raw_class = buf[16];
536    let context = ColumnAppendContext {
537        buf,
538        layout,
539        xyz,
540        classification: if raw_class == OVERLAP_CLASSIFICATION_CODE {
541            1
542        } else {
543            raw_class
544        },
545        is_overlap: buf[15] & 8 == 8,
546    };
547
548    for (spec, data) in columns {
549        append_column(*spec, data, &context)?;
550    }
551    Ok(())
552}
553
554struct ColumnAppendContext<'a> {
555    buf: &'a [u8],
556    layout: &'a ExtendedPointLayout,
557    xyz: (f64, f64, f64),
558    classification: u8,
559    is_overlap: bool,
560}
561
562#[inline]
563fn i32_at(buf: &[u8], offset: usize) -> i32 {
564    i32::from_le_bytes(buf[offset..offset + 4].try_into().expect("i32 width"))
565}
566
567#[inline]
568fn u16_at(buf: &[u8], offset: usize) -> u16 {
569    u16::from_le_bytes(buf[offset..offset + 2].try_into().expect("u16 width"))
570}
571
572#[inline]
573fn u32_at(buf: &[u8], offset: usize) -> u32 {
574    u32::from_le_bytes(buf[offset..offset + 4].try_into().expect("u32 width"))
575}
576
577#[inline]
578fn u64_at(buf: &[u8], offset: usize) -> u64 {
579    u64::from_le_bytes(buf[offset..offset + 8].try_into().expect("u64 width"))
580}
581
582#[inline]
583fn f32_at(buf: &[u8], offset: usize) -> f32 {
584    f32::from_le_bytes(buf[offset..offset + 4].try_into().expect("f32 width"))
585}
586
587#[inline]
588fn f64_at(buf: &[u8], offset: usize) -> f64 {
589    f64::from_le_bytes(buf[offset..offset + 8].try_into().expect("f64 width"))
590}
591
592#[inline]
593fn i16_at(buf: &[u8], offset: usize) -> i16 {
594    i16::from_le_bytes(buf[offset..offset + 2].try_into().expect("i16 width"))
595}
596
597fn append_column(
598    spec: ColumnSpec,
599    data: &mut ColumnData,
600    context: &ColumnAppendContext<'_>,
601) -> Result<()> {
602    let dimension = spec.dimension;
603    let scalar = data.scalar();
604    let buf = context.buf;
605    let layout = context.layout;
606    match (dimension, data) {
607        (LasDimension::X, ColumnData::F64(values)) => values.push(context.xyz.0),
608        (LasDimension::Y, ColumnData::F64(values)) => values.push(context.xyz.1),
609        (LasDimension::Z, ColumnData::F64(values)) => values.push(context.xyz.2),
610        (LasDimension::Intensity, ColumnData::U16(values)) => {
611            values.push(u16_at(buf, 12));
612        }
613        (LasDimension::ReturnNumber, ColumnData::U8(values)) => {
614            values.push(buf[14] & 15);
615        }
616        (LasDimension::NumberOfReturns, ColumnData::U8(values)) => {
617            values.push((buf[14] >> 4) & 15);
618        }
619        (LasDimension::Classification, ColumnData::U8(values)) => {
620            values.push(context.classification);
621        }
622        (LasDimension::ScanDirectionFlag, ColumnData::Bool(values)) => {
623            values.push((buf[15] >> 6) & 1 == 1);
624        }
625        (LasDimension::EdgeOfFlightLine, ColumnData::Bool(values)) => {
626            values.push((buf[15] >> 7) == 1);
627        }
628        (LasDimension::ScanAngle, ColumnData::F32(values)) => {
629            values.push(f32::from(i16_at(buf, 18)) * LAS_14_SCAN_ANGLE_SCALE);
630        }
631        (LasDimension::UserData, ColumnData::U8(values)) => {
632            values.push(buf[17]);
633        }
634        (LasDimension::PointSourceId, ColumnData::U16(values)) => {
635            values.push(u16_at(buf, 20));
636        }
637        (LasDimension::Synthetic, ColumnData::Bool(values)) => {
638            values.push(buf[15] & 1 == 1);
639        }
640        (LasDimension::KeyPoint, ColumnData::Bool(values)) => {
641            values.push(buf[15] & 2 == 2);
642        }
643        (LasDimension::Withheld, ColumnData::Bool(values)) => {
644            values.push(buf[15] & 4 == 4);
645        }
646        (LasDimension::Overlap, ColumnData::Bool(values)) => values.push(context.is_overlap),
647        (LasDimension::ScanChannel, ColumnData::U8(values)) => {
648            values.push((buf[15] >> 4) & 3);
649        }
650        (LasDimension::GpsTime, ColumnData::F64(values)) => {
651            values.push(f64_at(buf, 22));
652        }
653        (LasDimension::Red, ColumnData::U16(values)) => {
654            values.push(layout.color_offset.map_or(0, |o| u16_at(buf, o)));
655        }
656        (LasDimension::Green, ColumnData::U16(values)) => {
657            values.push(layout.color_offset.map_or(0, |o| u16_at(buf, o + 2)));
658        }
659        (LasDimension::Blue, ColumnData::U16(values)) => {
660            values.push(layout.color_offset.map_or(0, |o| u16_at(buf, o + 4)));
661        }
662        (LasDimension::Nir, ColumnData::U16(values)) => {
663            values.push(layout.nir_offset.map_or(0, |o| u16_at(buf, o)));
664        }
665        (LasDimension::WaveformPacketDescriptorIndex, ColumnData::U8(values)) => {
666            values.push(layout.waveform_offset.map_or(0, |o| buf[o]));
667        }
668        (LasDimension::WaveformPacketByteOffset, ColumnData::U64(values)) => {
669            values.push(layout.waveform_offset.map_or(0, |o| u64_at(buf, o + 1)));
670        }
671        (LasDimension::WaveformPacketSize, ColumnData::U32(values)) => {
672            values.push(layout.waveform_offset.map_or(0, |o| u32_at(buf, o + 9)));
673        }
674        (LasDimension::WavePacketReturnPointWaveformLocation, ColumnData::F32(values)) => {
675            values.push(layout.waveform_offset.map_or(0.0, |o| f32_at(buf, o + 13)));
676        }
677        (LasDimension::ExtraBytes, ColumnData::U8(values)) => {
678            let width = spec.extra_byte_width().ok_or_else(|| {
679                Error::InvalidData("ExtraBytes column requires a non-zero byte width".into())
680            })?;
681            if layout.extra_len != width {
682                return Err(Error::InvalidData(format!(
683                    "ExtraBytes point has {} bytes, expected {width}",
684                    layout.extra_len
685                )));
686            }
687            values.extend_from_slice(&buf[layout.extra_offset..layout.extra_offset + width]);
688        }
689        _ => {
690            return Err(Error::InvalidData(format!(
691                "column {:?} has incompatible data type {:?}",
692                dimension, scalar
693            )));
694        }
695    }
696    Ok(())
697}
698
699/// Iterator over selected COPC point chunks.
700pub struct PointIter<'a, R: Read + Seek + Send> {
701    chunks: Vec<Entry>,
702    next_chunk: usize,
703    current_chunk_points_left: usize,
704    remaining_candidate_points: usize,
705    exact_size: bool,
706    point_format: LasPointFormat,
707    transforms: Vector<Transform>,
708    bounds: Option<Bounds>,
709    decoder: ChunkLazDecoder<'a, R>,
710    point_buf: Vec<u8>,
711    decoded_points: usize,
712    cancel: Option<&'a dyn CancelCheck>,
713    finished: bool,
714}
715
716impl<'a, R: Read + Seek + Send> PointIter<'a, R> {
717    fn new(
718        source: &'a mut R,
719        file: &CopcFile,
720        query: PointQuery,
721        cancel: Option<&'a dyn CancelCheck>,
722    ) -> Result<Self> {
723        let QuerySetup {
724            chunks,
725            point_format,
726            transforms,
727            bounds,
728            decoder,
729            record_size,
730        } = QuerySetup::new(source, file, query)?;
731        let remaining_candidate_points = total_candidate_points(&chunks)?;
732        let point_buf = vec![0u8; record_size];
733        Ok(Self {
734            chunks,
735            next_chunk: 0,
736            current_chunk_points_left: 0,
737            remaining_candidate_points,
738            exact_size: bounds.is_none(),
739            point_format,
740            transforms,
741            bounds,
742            decoder,
743            point_buf,
744            decoded_points: 0,
745            cancel,
746            finished: false,
747        })
748    }
749
750    fn load_next_chunk(&mut self) -> Result<bool> {
751        while self.next_chunk < self.chunks.len() {
752            let entry = self.chunks[self.next_chunk];
753            self.next_chunk += 1;
754            if entry.point_count <= 0 {
755                continue;
756            }
757            let byte_size = u64::try_from(entry.byte_size).map_err(|_| {
758                Error::InvalidData(format!(
759                    "negative byte size {} for {:?}",
760                    entry.byte_size, entry.key
761                ))
762            })?;
763            self.decoder.seek_to_chunk(entry.offset, byte_size)?;
764            self.current_chunk_points_left = usize::try_from(entry.point_count).map_err(|_| {
765                Error::InvalidData(format!(
766                    "negative point count {} for {:?}",
767                    entry.point_count, entry.key
768                ))
769            })?;
770            return Ok(true);
771        }
772        Ok(false)
773    }
774
775    fn next_inner(&mut self) -> Result<Option<Point>> {
776        loop {
777            while self.current_chunk_points_left == 0 {
778                if !self.load_next_chunk()? {
779                    return Ok(None);
780                }
781            }
782
783            if self.decoded_points.is_multiple_of(CANCEL_POLL_STRIDE) {
784                if let Some(cancel) = self.cancel {
785                    cancel.check()?;
786                }
787            }
788
789            self.decoder.decompress_one(&mut self.point_buf)?;
790            self.current_chunk_points_left -= 1;
791            self.remaining_candidate_points -= 1;
792            self.decoded_points += 1;
793
794            let raw_point =
795                las::raw::Point::read_from(self.point_buf.as_slice(), &self.point_format)
796                    .map_err(|e| Error::Las(e.to_string()))?;
797            if let Some(bounds) = self.bounds {
798                let x = self.transforms.x.direct(raw_point.x);
799                let y = self.transforms.y.direct(raw_point.y);
800                let z = self.transforms.z.direct(raw_point.z);
801                if !bounds.contains_xyz(x, y, z) {
802                    continue;
803                }
804            }
805            return Ok(Some(Point::new(raw_point, &self.transforms)));
806        }
807    }
808}
809
810impl<R: Read + Seek + Send> Iterator for PointIter<'_, R> {
811    type Item = Result<Point>;
812
813    fn next(&mut self) -> Option<Self::Item> {
814        if self.finished {
815            return None;
816        }
817        match self.next_inner() {
818            Ok(Some(point)) => Some(Ok(point)),
819            Ok(None) => {
820                self.finished = true;
821                None
822            }
823            Err(error) => {
824                self.finished = true;
825                Some(Err(error))
826            }
827        }
828    }
829
830    fn size_hint(&self) -> (usize, Option<usize>) {
831        if self.exact_size {
832            (
833                self.remaining_candidate_points,
834                Some(self.remaining_candidate_points),
835            )
836        } else {
837            (0, Some(self.remaining_candidate_points))
838        }
839    }
840}
841
842/// Shared setup for chunked point queries: selected chunks, format metadata,
843/// and a configured chunk decoder validated against the LAS record length.
844struct QuerySetup<'a, R: Read + Seek + Send> {
845    chunks: Vec<Entry>,
846    point_format: LasPointFormat,
847    transforms: Vector<Transform>,
848    bounds: Option<Bounds>,
849    decoder: ChunkLazDecoder<'a, R>,
850    record_size: usize,
851}
852
853impl<'a, R: Read + Seek + Send> QuerySetup<'a, R> {
854    fn new(source: &'a mut R, file: &CopcFile, query: PointQuery) -> Result<Self> {
855        let chunks = select_point_chunks(file, query)?;
856        let point_format = file.point_format()?;
857        let transforms = file.transforms();
858        let bounds = match query.bounds {
859            BoundsSelection::All => None,
860            BoundsSelection::Within(bounds) => Some(bounds),
861        };
862        let decoder = ChunkLazDecoder::new(source, file.laszip_vlr().clone())?;
863        let record_size = usize::from(point_format.len());
864        if decoder.record_size() != record_size {
865            return Err(Error::InvalidData(format!(
866                "LASzip item size is {} bytes, but LAS point record length is {} bytes",
867                decoder.record_size(),
868                record_size
869            )));
870        }
871        Ok(Self {
872            chunks,
873            point_format,
874            transforms,
875            bounds,
876            decoder,
877            record_size,
878        })
879    }
880}
881
882struct ChunkLazDecoder<'a, R: Read + Seek + Send> {
883    laz_vlr: LazVlr,
884    decompressor: LayeredPointRecordDecompressor<'a, ChunkReadWindow<'a, R>>,
885    record_size: usize,
886}
887
888impl<'a, R: Read + Seek + Send> ChunkLazDecoder<'a, R> {
889    fn new(source: &'a mut R, laz_vlr: LazVlr) -> Result<Self> {
890        let mut decompressor = LayeredPointRecordDecompressor::new(ChunkReadWindow::new(source));
891        let record_size = configure_layered_decompressor(&mut decompressor, &laz_vlr)?;
892        Ok(Self {
893            laz_vlr,
894            decompressor,
895            record_size,
896        })
897    }
898
899    fn record_size(&self) -> usize {
900        self.record_size
901    }
902
903    fn seek_to_chunk(&mut self, offset: u64, byte_size: u64) -> Result<()> {
904        self.decompressor.get_mut().set_range(offset, byte_size)?;
905        self.decompressor.reset();
906        self.record_size = configure_layered_decompressor(&mut self.decompressor, &self.laz_vlr)?;
907        Ok(())
908    }
909
910    fn decompress_one(&mut self, out: &mut [u8]) -> Result<()> {
911        self.decompressor
912            .decompress_next(out)
913            .map_err(|e| Error::io("decompress COPC point", e))
914    }
915}
916
917/// Restricts the streaming LAZ decoder to the hierarchy entry's declared
918/// compressed byte range. Without this window, malformed chunk metadata can
919/// make the decoder consume bytes from the following chunk or hierarchy.
920struct ChunkReadWindow<'a, R> {
921    source: &'a mut R,
922    start: u64,
923    end: u64,
924    position: u64,
925}
926
927impl<'a, R: Read + Seek> ChunkReadWindow<'a, R> {
928    fn new(source: &'a mut R) -> Self {
929        Self {
930            source,
931            start: 0,
932            end: 0,
933            position: 0,
934        }
935    }
936
937    fn set_range(&mut self, start: u64, byte_size: u64) -> Result<()> {
938        let end = start
939            .checked_add(byte_size)
940            .ok_or_else(|| Error::InvalidData("COPC point chunk range overflows u64".into()))?;
941        self.source
942            .seek(SeekFrom::Start(start))
943            .map_err(|e| Error::io("seek COPC point chunk", e))?;
944        self.start = start;
945        self.end = end;
946        self.position = start;
947        Ok(())
948    }
949}
950
951impl<R: Read + Seek> Read for ChunkReadWindow<'_, R> {
952    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
953        let remaining = self.end.saturating_sub(self.position);
954        if remaining == 0 {
955            return Ok(0);
956        }
957        let allowed = usize::try_from(remaining)
958            .unwrap_or(usize::MAX)
959            .min(buf.len());
960        let read = self.source.read(&mut buf[..allowed])?;
961        self.position = self
962            .position
963            .checked_add(read as u64)
964            .ok_or_else(|| std::io::Error::other("chunk read position overflow"))?;
965        Ok(read)
966    }
967}
968
969impl<R: Read + Seek> Seek for ChunkReadWindow<'_, R> {
970    fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
971        let target = match position {
972            SeekFrom::Start(offset) => Some(offset),
973            SeekFrom::Current(delta) => self.position.checked_add_signed(delta),
974            SeekFrom::End(delta) => self.end.checked_add_signed(delta),
975        }
976        .filter(|target| (self.start..=self.end).contains(target))
977        .ok_or_else(|| {
978            std::io::Error::new(
979                std::io::ErrorKind::InvalidInput,
980                "seek outside COPC point chunk",
981            )
982        })?;
983        self.source.seek(SeekFrom::Start(target))?;
984        self.position = target;
985        Ok(target)
986    }
987}
988
989fn configure_layered_decompressor<R: Read + Seek>(
990    decompressor: &mut LayeredPointRecordDecompressor<'_, R>,
991    laz_vlr: &LazVlr,
992) -> Result<usize> {
993    decompressor
994        .set_fields_from(laz_vlr.items())
995        .map_err(|e| Error::Las(e.to_string()))?;
996    let record_size = decompressor.record_size();
997    if record_size == 0 {
998        return Err(Error::Unsupported(
999            "COPC point iteration requires layered LAZ point records".into(),
1000        ));
1001    }
1002    Ok(record_size)
1003}
1004
1005fn select_point_chunks(file: &CopcFile, query: PointQuery) -> Result<Vec<Entry>> {
1006    select_point_chunks_from(file.hierarchy_entries(), file.copc_info(), query)
1007}
1008
1009pub(crate) fn select_point_chunks_from<'a, I>(
1010    entries: I,
1011    info: &CopcInfo,
1012    query: PointQuery,
1013) -> Result<Vec<Entry>>
1014where
1015    I: IntoIterator<Item = &'a Entry>,
1016{
1017    validate_query(query)?;
1018    let (level_min, level_max) = level_range(query.lod, info)?;
1019    let query_bounds = match query.bounds {
1020        BoundsSelection::All => None,
1021        BoundsSelection::Within(bounds) => Some(bounds),
1022    };
1023
1024    let mut chunks = Vec::new();
1025    for entry in entries {
1026        if !entry.has_point_data() {
1027            continue;
1028        }
1029        if entry.byte_size <= 0 {
1030            return Err(Error::InvalidData(format!(
1031                "point chunk {:?} has invalid byte size {}",
1032                entry.key, entry.byte_size
1033            )));
1034        }
1035        if !(level_min..level_max).contains(&entry.key.level) {
1036            continue;
1037        }
1038        if let Some(bounds) = query_bounds {
1039            let node_bounds = voxel_bounds(entry.key, info)?;
1040            if !node_bounds.intersects(bounds) {
1041                continue;
1042            }
1043        }
1044        chunks.push(*entry);
1045    }
1046    chunks.sort_by_key(|entry| (entry.offset, entry.key));
1047    Ok(chunks)
1048}
1049
1050pub(crate) fn validate_query(query: PointQuery) -> Result<()> {
1051    if let BoundsSelection::Within(bounds) = query.bounds {
1052        for (name, value) in [
1053            ("min x", bounds.min.0),
1054            ("min y", bounds.min.1),
1055            ("min z", bounds.min.2),
1056            ("max x", bounds.max.0),
1057            ("max y", bounds.max.1),
1058            ("max z", bounds.max.2),
1059        ] {
1060            if !value.is_finite() {
1061                return Err(Error::InvalidInput(format!(
1062                    "query bounds {name} must be finite, got {value}"
1063                )));
1064            }
1065        }
1066        for (axis, min, max) in [
1067            ("x", bounds.min.0, bounds.max.0),
1068            ("y", bounds.min.1, bounds.max.1),
1069            ("z", bounds.min.2, bounds.max.2),
1070        ] {
1071            if min > max {
1072                return Err(Error::InvalidInput(format!(
1073                    "query bounds {axis} minimum {min} exceeds maximum {max}"
1074                )));
1075            }
1076        }
1077    }
1078    Ok(())
1079}
1080
1081pub(crate) fn level_range(selection: LodSelection, info: &CopcInfo) -> Result<(i32, i32)> {
1082    match selection {
1083        LodSelection::All => Ok((0, i32::MAX)),
1084        LodSelection::Resolution(resolution) => {
1085            if !resolution.is_finite() || resolution <= 0.0 {
1086                return Err(Error::InvalidInput(format!(
1087                    "resolution must be finite and positive, got {resolution}"
1088                )));
1089            }
1090            if !info.spacing.is_finite() || info.spacing <= 0.0 {
1091                return Err(Error::InvalidData(format!(
1092                    "COPC spacing must be finite and positive, got {}",
1093                    info.spacing
1094                )));
1095            }
1096            let level_max = ((info.spacing / resolution).log2().ceil() as i64 + 1)
1097                .max(1)
1098                .min(i64::from(i32::MAX)) as i32;
1099            Ok((0, level_max))
1100        }
1101        LodSelection::Level(level) => {
1102            validate_level(level)?;
1103            let max = level
1104                .checked_add(1)
1105                .ok_or_else(|| Error::InvalidInput(format!("LOD level {level} is too large")))?;
1106            Ok((level, max))
1107        }
1108        LodSelection::LevelMinMax(min, max) => {
1109            validate_level(min)?;
1110            validate_level(max)?;
1111            if max < min {
1112                return Err(Error::InvalidInput(format!(
1113                    "LOD max {max} is smaller than min {min}"
1114                )));
1115            }
1116            Ok((min, max))
1117        }
1118    }
1119}
1120
1121fn validate_level(level: i32) -> Result<()> {
1122    if level < 0 {
1123        return Err(Error::InvalidInput(format!(
1124            "LOD level must be non-negative, got {level}"
1125        )));
1126    }
1127    Ok(())
1128}
1129
1130pub(crate) fn total_candidate_points(entries: &[Entry]) -> Result<usize> {
1131    entries.iter().try_fold(0usize, |total, entry| {
1132        let count = usize::try_from(entry.point_count).map_err(|_| {
1133            Error::InvalidData(format!(
1134                "negative point count {} for {:?}",
1135                entry.point_count, entry.key
1136            ))
1137        })?;
1138        total
1139            .checked_add(count)
1140            .ok_or_else(|| Error::InvalidData("selected point count overflows usize".into()))
1141    })
1142}
1143
1144pub(crate) fn voxel_bounds(key: copc_core::VoxelKey, info: &CopcInfo) -> Result<Bounds> {
1145    key.validate()?;
1146    let side = (info.halfsize * 2.0) / 2.0_f64.powi(key.level);
1147    let root_min = (
1148        info.center.0 - info.halfsize,
1149        info.center.1 - info.halfsize,
1150        info.center.2 - info.halfsize,
1151    );
1152    let min = (
1153        root_min.0 + f64::from(key.x) * side,
1154        root_min.1 + f64::from(key.y) * side,
1155        root_min.2 + f64::from(key.z) * side,
1156    );
1157    Ok(Bounds::new(min, (min.0 + side, min.1 + side, min.2 + side)))
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    #[test]
1165    fn selected_column_builders_include_extra_bytes_width() {
1166        let mut format = LasPointFormat::new(6).unwrap();
1167        format.extra_bytes = 3;
1168
1169        let columns = selected_column_builders(format, ColumnSelection::all(), 2).unwrap();
1170
1171        let extra_spec = columns
1172            .iter()
1173            .map(|(spec, _)| *spec)
1174            .find(|spec| spec.dimension == LasDimension::ExtraBytes)
1175            .expect("ExtraBytes column spec");
1176        assert_eq!(Some(3), extra_spec.extra_byte_width());
1177        assert_eq!(copc_core::ScalarType::U8, extra_spec.scalar);
1178    }
1179
1180    fn record_bytes(format: &LasPointFormat, raw_point: &las::raw::Point) -> Vec<u8> {
1181        let mut buf = Vec::with_capacity(usize::from(format.len()));
1182        raw_point.write_to(&mut buf, format).unwrap();
1183        buf
1184    }
1185
1186    #[test]
1187    fn append_columns_preserves_fixed_width_extra_bytes() {
1188        let mut format = LasPointFormat::new(6).unwrap();
1189        format.extra_bytes = 3;
1190        let mut columns = selected_column_builders(
1191            format,
1192            ColumnSelection::from_dimensions([LasDimension::X, LasDimension::ExtraBytes]),
1193            1,
1194        )
1195        .unwrap();
1196        let raw_point = las::raw::Point {
1197            x: 10,
1198            y: 20,
1199            z: 30,
1200            flags: las::raw::point::Flags::ThreeByte(1 | (1 << 4), 0, 2),
1201            scan_angle: las::raw::point::ScanAngle::from(0.0),
1202            extra_bytes: vec![9, 8, 7],
1203            ..Default::default()
1204        };
1205        let buf = record_bytes(&format, &raw_point);
1206        let layout = ExtendedPointLayout::for_format(&format).unwrap();
1207
1208        append_columns(&mut columns, &buf, &layout, (1.0, 2.0, 3.0)).unwrap();
1209        let batch = LasColumnBatch::new(columns).unwrap();
1210
1211        assert_eq!(1, batch.len());
1212        assert_eq!(
1213            Some(&ColumnData::U8(vec![9, 8, 7])),
1214            batch.column(LasDimension::ExtraBytes)
1215        );
1216    }
1217
1218    #[test]
1219    fn append_columns_rejects_layout_and_spec_width_mismatch() {
1220        // Spec widths come from the same format as the layout, so a mismatch
1221        // is unreachable through the public API; guard the internal error.
1222        let mut spec_format = LasPointFormat::new(6).unwrap();
1223        spec_format.extra_bytes = 3;
1224        let mut columns = selected_column_builders(
1225            spec_format,
1226            ColumnSelection::from_dimensions([LasDimension::ExtraBytes]),
1227            1,
1228        )
1229        .unwrap();
1230        let mut buf_format = LasPointFormat::new(6).unwrap();
1231        buf_format.extra_bytes = 2;
1232        let raw_point = las::raw::Point {
1233            flags: las::raw::point::Flags::ThreeByte(1 | (1 << 4), 0, 2),
1234            extra_bytes: vec![9, 8],
1235            ..Default::default()
1236        };
1237        let buf = record_bytes(&buf_format, &raw_point);
1238        let layout = ExtendedPointLayout::for_format(&buf_format).unwrap();
1239
1240        let err = append_columns(&mut columns, &buf, &layout, (1.0, 2.0, 3.0)).unwrap_err();
1241
1242        assert!(err.to_string().contains("expected 3"));
1243    }
1244
1245    /// Every column decoded directly from record bytes must match the values
1246    /// the `las` crate parses from the same bytes.
1247    #[test]
1248    fn direct_column_decode_matches_las_raw_point() {
1249        for format_id in [6u8, 7, 8] {
1250            let mut format = LasPointFormat::new(format_id).unwrap();
1251            format.extra_bytes = 2;
1252            let raw_point = las::raw::Point {
1253                x: -1234,
1254                y: 5678,
1255                z: 91011,
1256                intensity: 0xBEEF,
1257                flags: las::raw::point::Flags::ThreeByte(
1258                    3 | (5 << 4),
1259                    1 | (1 << 2) | (2 << 4) | (1 << 6) | (1 << 7),
1260                    6,
1261                ),
1262                scan_angle: las::raw::point::ScanAngle::Scaled(-5042),
1263                user_data: 0x42,
1264                point_source_id: 0xCAFE,
1265                gps_time: Some(1.234e9),
1266                color: format
1267                    .has_color
1268                    .then_some(las::Color::new(1000, 2000, 3000)),
1269                nir: format.has_nir.then_some(0xCDCD),
1270                waveform: None,
1271                extra_bytes: vec![7, 9],
1272            };
1273            let buf = record_bytes(&format, &raw_point);
1274            let layout = ExtendedPointLayout::for_format(&format).unwrap();
1275            let mut columns = selected_column_builders(format, ColumnSelection::all(), 1).unwrap();
1276
1277            append_columns(&mut columns, &buf, &layout, (1.0, 2.0, 3.0)).unwrap();
1278            let batch = LasColumnBatch::new(columns).unwrap();
1279
1280            let mut flags = raw_point.flags;
1281            let expected_overlap = flags.is_overlap();
1282            flags.clear_overlap_class();
1283            assert_eq!(
1284                Some(&ColumnData::U16(vec![raw_point.intensity])),
1285                batch.column(LasDimension::Intensity)
1286            );
1287            assert_eq!(
1288                Some(&ColumnData::U8(vec![flags.return_number()])),
1289                batch.column(LasDimension::ReturnNumber)
1290            );
1291            assert_eq!(
1292                Some(&ColumnData::U8(vec![flags.number_of_returns()])),
1293                batch.column(LasDimension::NumberOfReturns)
1294            );
1295            assert_eq!(
1296                Some(&ColumnData::U8(vec![u8::from(
1297                    flags.to_classification().unwrap()
1298                )])),
1299                batch.column(LasDimension::Classification)
1300            );
1301            assert_eq!(
1302                Some(&ColumnData::Bool(vec![matches!(
1303                    flags.scan_direction(),
1304                    las::point::ScanDirection::LeftToRight
1305                )])),
1306                batch.column(LasDimension::ScanDirectionFlag)
1307            );
1308            assert_eq!(
1309                Some(&ColumnData::Bool(vec![flags.is_edge_of_flight_line()])),
1310                batch.column(LasDimension::EdgeOfFlightLine)
1311            );
1312            assert_eq!(
1313                Some(&ColumnData::F32(vec![f32::from(raw_point.scan_angle)])),
1314                batch.column(LasDimension::ScanAngle)
1315            );
1316            assert_eq!(
1317                Some(&ColumnData::U8(vec![raw_point.user_data])),
1318                batch.column(LasDimension::UserData)
1319            );
1320            assert_eq!(
1321                Some(&ColumnData::U16(vec![raw_point.point_source_id])),
1322                batch.column(LasDimension::PointSourceId)
1323            );
1324            assert_eq!(
1325                Some(&ColumnData::Bool(vec![flags.is_synthetic()])),
1326                batch.column(LasDimension::Synthetic)
1327            );
1328            assert_eq!(
1329                Some(&ColumnData::Bool(vec![flags.is_key_point()])),
1330                batch.column(LasDimension::KeyPoint)
1331            );
1332            assert_eq!(
1333                Some(&ColumnData::Bool(vec![flags.is_withheld()])),
1334                batch.column(LasDimension::Withheld)
1335            );
1336            assert_eq!(
1337                Some(&ColumnData::Bool(vec![expected_overlap])),
1338                batch.column(LasDimension::Overlap)
1339            );
1340            assert_eq!(
1341                Some(&ColumnData::U8(vec![flags.scanner_channel()])),
1342                batch.column(LasDimension::ScanChannel)
1343            );
1344            assert_eq!(
1345                Some(&ColumnData::F64(vec![raw_point.gps_time.unwrap()])),
1346                batch.column(LasDimension::GpsTime)
1347            );
1348            if format.has_color {
1349                assert_eq!(
1350                    Some(&ColumnData::U16(vec![raw_point.color.unwrap().red])),
1351                    batch.column(LasDimension::Red)
1352                );
1353                assert_eq!(
1354                    Some(&ColumnData::U16(vec![raw_point.color.unwrap().blue])),
1355                    batch.column(LasDimension::Blue)
1356                );
1357            }
1358            if format.has_nir {
1359                assert_eq!(
1360                    Some(&ColumnData::U16(vec![raw_point.nir.unwrap()])),
1361                    batch.column(LasDimension::Nir)
1362                );
1363            }
1364            assert_eq!(
1365                Some(&ColumnData::U8(vec![7, 9])),
1366                batch.column(LasDimension::ExtraBytes)
1367            );
1368        }
1369    }
1370}