Skip to main content

copc_streaming/
chunk.rs

1//! Point chunk fetching and LAZ decompression.
2
3use std::io::Cursor;
4
5use las::{PointData, PointDataBuilder};
6use laz::LazVlr;
7use laz::record::{LayeredPointRecordDecompressor, RecordDecompressor};
8
9use crate::byte_source::ByteSource;
10use crate::error::CopcError;
11use crate::fields::Fields;
12use crate::hierarchy::HierarchyEntry;
13use crate::types::{Aabb, VoxelKey};
14
15/// A decompressed point data chunk.
16///
17/// A `Chunk` wraps a [`las::PointData`] along with the [`VoxelKey`] of the
18/// octree node it came from and the [`Fields`] mask that was used to decode
19/// it. Column accessors (`intensity`, `gps_time`, `rgb`, …) are guarded by
20/// the mask and return `None` for fields that were not decoded.
21///
22/// Construct via [`CopcStreamingReader::fetch_chunk`](crate::CopcStreamingReader::fetch_chunk)
23/// or [`CopcStreamingReader::query_chunks`](crate::CopcStreamingReader::query_chunks).
24#[non_exhaustive]
25pub struct Chunk {
26    /// The octree node this chunk belongs to.
27    pub key: VoxelKey,
28    /// Which fields were actually decompressed into this chunk.
29    pub fields: Fields,
30    cloud: PointData,
31}
32
33impl Chunk {
34    /// Construct a `Chunk` from a [`VoxelKey`], a [`Fields`] mask, and a
35    /// [`las::PointData`].
36    ///
37    /// Normally you'll get chunks from
38    /// [`CopcStreamingReader::fetch_chunk`](crate::CopcStreamingReader::fetch_chunk);
39    /// this constructor exists for callers that drive their own
40    /// decompression pipeline (and for tests). The caller is responsible
41    /// for ensuring that `fields` accurately describes which LAZ layers
42    /// were decoded into `cloud` — otherwise the chunk's field guards will
43    /// hide columns that are actually valid, or (worse) expose columns
44    /// that contain zero'd bytes for skipped layers.
45    pub fn new(key: VoxelKey, fields: Fields, cloud: PointData) -> Self {
46        Self { key, fields, cloud }
47    }
48
49    /// Borrow the underlying [`las::PointData`] — an **unchecked** escape
50    /// hatch to the raw byte-level accessors in `las`.
51    ///
52    /// Use this when [`Chunk`]'s higher-level methods don't expose what
53    /// you need: `cloud.x_raw()`, `cloud.record_len()`, `cloud.raw_bytes()`,
54    /// `cloud.iter()` for zero-copy `PointRef` walks, etc.
55    ///
56    /// # ⚠ Field guards are bypassed
57    ///
58    /// Calling `cloud.rgb()`, `cloud.gps_time()`, `cloud.intensity()`, or
59    /// any `PointRef` accessor on bytes from this cloud does **not** check
60    /// whether the underlying layer was actually decoded. If you call one
61    /// of those on a chunk that was fetched without the corresponding
62    /// [`Fields`] flag, you get a valid-looking iterator of zeros.
63    ///
64    /// Prefer the [`Chunk`] methods ([`rgb`](Self::rgb),
65    /// [`gps_time`](Self::gps_time), etc.) — they return `None` when the
66    /// field is absent, making the hazard impossible to hit accidentally.
67    pub fn cloud(&self) -> &PointData {
68        &self.cloud
69    }
70
71    /// Number of points in this chunk.
72    pub fn point_count(&self) -> usize {
73        self.cloud.len()
74    }
75
76    /// Whether this chunk contains zero points.
77    pub fn is_empty(&self) -> bool {
78        self.cloud.is_empty()
79    }
80
81    /// Materialize every point in this chunk as an owned [`las::Point`].
82    ///
83    /// Returns [`CopcError::PartialDecode`] if this chunk was decoded with a
84    /// partial field mask — otherwise the resulting `las::Point`s would have
85    /// silently-zero values for skipped fields.
86    ///
87    /// This is the bridge from the column-oriented [`Chunk`] API back to
88    /// the simple `Vec<las::Point>` API. Prefer the column accessors
89    /// ([`intensity`](Self::intensity), [`gps_time`](Self::gps_time), …)
90    /// or `chunk.cloud().iter()` when you don't need owned values.
91    pub fn to_points(&self) -> Result<Vec<las::Point>, CopcError> {
92        if self.fields != Fields::ALL {
93            return Err(CopcError::PartialDecode(self.fields));
94        }
95        self.cloud
96            .points()
97            .collect::<std::result::Result<Vec<_>, _>>()
98            .map_err(CopcError::Las)
99    }
100
101    /// Materialize `las::Point` values only at the given indices.
102    ///
103    /// Pair with [`indices_in_bounds`](Self::indices_in_bounds) or any
104    /// other index-producing filter to skip the materialization cost for
105    /// rejected points entirely:
106    ///
107    /// ```rust,ignore
108    /// let chunk = reader.fetch_chunk(&key, Fields::ALL).await?;
109    /// let indices = chunk.indices_in_bounds(&bounds).unwrap();
110    /// let points = chunk.points_at(&indices)?;
111    /// ```
112    ///
113    /// Returns [`CopcError::PartialDecode`] if the chunk was decoded with
114    /// a partial field mask, same as [`to_points`](Self::to_points).
115    pub fn points_at(&self, indices: &[u32]) -> Result<Vec<las::Point>, CopcError> {
116        if self.fields != Fields::ALL {
117            return Err(CopcError::PartialDecode(self.fields));
118        }
119        let record_len = self.cloud.record_len();
120        let format = self.cloud.format();
121        let transforms = self.cloud.transforms();
122        let bytes = self.cloud.raw_bytes();
123        indices
124            .iter()
125            .map(|&i| {
126                let start = i as usize * record_len;
127                let end = start + record_len;
128                let mut cursor = Cursor::new(&bytes[start..end]);
129                let raw = las::raw::Point::read_from(&mut cursor, format)?;
130                Ok(las::Point::new(raw, transforms))
131            })
132            .collect()
133    }
134
135    /// Iterate `(x, y, z)` world coordinates as `[f64; 3]`, or `None` if
136    /// [`Fields::Z`] was not set.
137    ///
138    /// `x` and `y` are always decoded on LAS 1.4 layered formats (they
139    /// share the always-on base layer with `return_number`,
140    /// `number_of_returns` and `scanner_channel`), but `z` is its own
141    /// skippable layer. A chunk without `Fields::Z` has zero bytes in the
142    /// `z` slots; returning `None` here keeps the footgun out of reach.
143    pub fn positions(&self) -> Option<impl Iterator<Item = [f64; 3]> + '_> {
144        if !self.fields.contains(Fields::Z) {
145            return None;
146        }
147        Some(
148            self.cloud
149                .x()
150                .zip(self.cloud.y())
151                .zip(self.cloud.z())
152                .map(|((x, y), z)| [x, y, z]),
153        )
154    }
155
156    /// Intensity column, or `None` if [`Fields::INTENSITY`] was not set
157    /// or the format does not include intensity.
158    pub fn intensity(&self) -> Option<impl Iterator<Item = u16> + '_> {
159        if !self.fields.contains(Fields::INTENSITY) {
160            return None;
161        }
162        Some(self.cloud.intensity())
163    }
164
165    /// Classification byte column, or `None` if [`Fields::CLASSIFICATION`]
166    /// was not set.
167    pub fn classification(&self) -> Option<impl Iterator<Item = u8> + '_> {
168        if !self.fields.contains(Fields::CLASSIFICATION) {
169            return None;
170        }
171        Some(self.cloud.classification())
172    }
173
174    /// Scan angle column in degrees, or `None` if [`Fields::SCAN_ANGLE`]
175    /// was not set.
176    pub fn scan_angle(&self) -> Option<impl Iterator<Item = f32> + '_> {
177        if !self.fields.contains(Fields::SCAN_ANGLE) {
178            return None;
179        }
180        Some(self.cloud.scan_angle_degrees())
181    }
182
183    /// User data byte column, or `None` if [`Fields::USER_DATA`] was not set.
184    pub fn user_data(&self) -> Option<impl Iterator<Item = u8> + '_> {
185        if !self.fields.contains(Fields::USER_DATA) {
186            return None;
187        }
188        Some(self.cloud.user_data())
189    }
190
191    /// Point source ID column, or `None` if [`Fields::POINT_SOURCE_ID`]
192    /// was not set.
193    pub fn point_source_id(&self) -> Option<impl Iterator<Item = u16> + '_> {
194        if !self.fields.contains(Fields::POINT_SOURCE_ID) {
195            return None;
196        }
197        Some(self.cloud.point_source_id())
198    }
199
200    /// GPS time column, or `None` if [`Fields::GPS_TIME`] was not set or
201    /// the format does not include GPS time.
202    pub fn gps_time(&self) -> Option<impl Iterator<Item = f64> + '_> {
203        if !self.fields.contains(Fields::GPS_TIME) {
204            return None;
205        }
206        self.cloud.gps_time()
207    }
208
209    /// RGB column, or `None` if [`Fields::RGB`] was not set or the format
210    /// does not include color.
211    pub fn rgb(&self) -> Option<impl Iterator<Item = (u16, u16, u16)> + '_> {
212        if !self.fields.contains(Fields::RGB) {
213            return None;
214        }
215        self.cloud.rgb()
216    }
217
218    /// NIR column, or `None` if [`Fields::NIR`] was not set or the format
219    /// does not include NIR.
220    pub fn nir(&self) -> Option<impl Iterator<Item = u16> + '_> {
221        if !self.fields.contains(Fields::NIR) {
222            return None;
223        }
224        self.cloud.nir()
225    }
226
227    /// Indices of points inside `bounds`, or `None` if [`Fields::Z`] was
228    /// not set.
229    ///
230    /// A 3D bounding-box intersection requires z, so a chunk without
231    /// `Fields::Z` can't produce meaningful results — `None` makes that
232    /// impossible to misuse. Pair the returned indices with any column
233    /// iterator on the chunk to produce a filtered view without
234    /// materializing `las::Point` values.
235    pub fn indices_in_bounds(&self, bounds: &Aabb) -> Option<Vec<u32>> {
236        let positions = self.positions()?;
237        Some(
238            positions
239                .enumerate()
240                .filter_map(|(i, [x, y, z])| {
241                    let inside = x >= bounds.min[0]
242                        && x <= bounds.max[0]
243                        && y >= bounds.min[1]
244                        && y <= bounds.max[1]
245                        && z >= bounds.min[2]
246                        && z <= bounds.max[2];
247                    inside.then_some(i as u32)
248                })
249                .collect(),
250        )
251    }
252
253    /// Decompress already-fetched compressed bytes into a [`Chunk`].
254    ///
255    /// Public counterpart to the sync inner that
256    /// [`CopcStreamingReader::fetch_chunks`](crate::CopcStreamingReader::fetch_chunks)
257    /// uses internally. Useful when the caller wants to issue the
258    /// [`ByteSource::read_range`](crate::ByteSource::read_range) on one
259    /// executor (e.g. the browser main thread) and run the CPU-bound
260    /// LAZ decompression on another (e.g. a Rayon worker pool).
261    ///
262    /// `compressed` must be exactly `entry.byte_size` bytes for the
263    /// chunk referenced by `entry.key`. `laz_vlr` and `header` come
264    /// from [`CopcStreamingReader::header`](crate::CopcStreamingReader::header)
265    /// and are immutable for the lifetime of the dataset, so a caller
266    /// can clone them out of the reader once and reuse them.
267    ///
268    /// # Example
269    ///
270    /// ```rust,ignore
271    /// // Fetch on one thread, hand bytes to a worker for decode:
272    /// let entry = reader.get(&key).unwrap().clone();
273    /// let bytes = reader
274    ///     .source()
275    ///     .read_range(entry.offset, entry.byte_size as u64)
276    ///     .await?;
277    /// // ... move bytes to a worker thread ...
278    /// let chunk = Chunk::decompress(
279    ///     &bytes,
280    ///     &entry,
281    ///     reader.header().laz_vlr(),
282    ///     reader.header().las_header(),
283    ///     Fields::Z | Fields::RGB,
284    /// )?;
285    /// ```
286    pub fn decompress(
287        compressed: &[u8],
288        entry: &HierarchyEntry,
289        laz_vlr: &LazVlr,
290        header: &las::Header,
291        fields: Fields,
292    ) -> Result<Self, CopcError> {
293        decompress_chunk(compressed, entry, laz_vlr, header, fields)
294    }
295}
296
297/// Decompress already-fetched compressed bytes into a [`Chunk`].
298///
299/// This is the sync counterpart to [`fetch_and_decompress`] — it skips
300/// the I/O step and works directly on a `&[u8]` buffer. Used by
301/// [`CopcStreamingReader::fetch_chunks`](crate::CopcStreamingReader::fetch_chunks)
302/// to decompress a batch of chunks that were fetched in a single
303/// [`ByteSource::read_ranges`] call.
304pub(crate) fn decompress_chunk(
305    compressed: &[u8],
306    entry: &HierarchyEntry,
307    laz_vlr: &LazVlr,
308    header: &las::Header,
309    fields: Fields,
310) -> Result<Chunk, CopcError> {
311    let format = *header.point_format();
312    let transforms = *header.transforms();
313
314    // `Format::len()` already includes `extra_bytes`, so the on-disk record
315    // length is exactly `format.len()`. Adding `extra_bytes` again double-counts
316    // them: for files with extra dimensions the output buffer is oversized, the
317    // layered LAZ decoder keeps reading to fill the surplus, overruns the chunk,
318    // and `read_exact` fails with "failed to fill whole buffer". Files with
319    // `extra_bytes == 0` were unaffected, which is why only some COPC failed.
320    let record_len = format.len() as usize;
321    let decompressed_size = entry.point_count as usize * record_len;
322    let mut decompressed = vec![0u8; decompressed_size];
323
324    decompress_copc_chunk(compressed, &mut decompressed, laz_vlr, fields)?;
325
326    let cloud = PointDataBuilder::new()
327        .with_format(format)
328        .with_transforms(transforms)
329        .build_from_bytes(decompressed)?;
330
331    Ok(Chunk {
332        key: entry.key,
333        fields,
334        cloud,
335    })
336}
337
338/// Fetch and decompress a single chunk, decoding only the layers requested
339/// by `fields`.
340pub(crate) async fn fetch_and_decompress(
341    source: &impl ByteSource,
342    entry: &HierarchyEntry,
343    laz_vlr: &LazVlr,
344    header: &las::Header,
345    fields: Fields,
346) -> Result<Chunk, CopcError> {
347    let compressed = source
348        .read_range(entry.offset, entry.byte_size as u64)
349        .await?;
350    decompress_chunk(&compressed, entry, laz_vlr, header, fields)
351}
352
353/// Decompress a single COPC chunk.
354///
355/// COPC chunks are independently compressed and do NOT start with the 8-byte
356/// chunk table offset that standard LAZ files have. We use
357/// `LayeredPointRecordDecompressor` directly (the same approach as copc-rs)
358/// to bypass `LasZipDecompressor`'s chunk table handling.
359///
360/// `fields` is wired through `set_selection` so that LAZ skips arithmetic
361/// decoding of omitted layers. On LAS 1.4 layered formats (6/7/8 — the
362/// formats COPC mandates) this is a real CPU saving; on pre-1.4 formats
363/// the layered decompressor ignores the selection and decodes everything,
364/// but those aren't valid COPC point formats anyway.
365fn decompress_copc_chunk(
366    compressed: &[u8],
367    decompressed: &mut [u8],
368    laz_vlr: &LazVlr,
369    fields: Fields,
370) -> Result<(), CopcError> {
371    let src = Cursor::new(compressed);
372    let mut decompressor = LayeredPointRecordDecompressor::new(src);
373    decompressor.set_fields_from(laz_vlr.items())?;
374    decompressor.set_selection(fields.to_laz_selection());
375    decompressor.decompress_many(decompressed)?;
376    Ok(())
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use las::point::Format;
383    use las::raw::point::{Flags, ScanAngle};
384
385    /// Build a `PointData` in format 7 (has gps_time + rgb) with `n` points
386    /// whose fields are a function of their index: `x = i, y = i + 1,
387    /// z = i + 2, intensity = 100 + i, gps_time = 1000 + i, rgb = (i, i, i)`.
388    /// Unit transforms (scale=1, offset=0) so raw == world for easy asserts.
389    fn build_test_cloud(n: i32) -> las::PointData {
390        let format = Format::new(7).unwrap();
391        let unit = las::Transform {
392            scale: 1.0,
393            offset: 0.0,
394        };
395        let transforms = las::Vector {
396            x: unit,
397            y: unit,
398            z: unit,
399        };
400        let mut buf = Vec::new();
401        for i in 0..n {
402            let rp = las::raw::Point {
403                x: i,
404                y: i + 1,
405                z: i + 2,
406                intensity: 100 + i as u16,
407                // Format 7 is extended -> ThreeByte flags.
408                flags: Flags::ThreeByte(0, 0, 2),
409                scan_angle: ScanAngle::Scaled(0),
410                user_data: 0,
411                point_source_id: 0,
412                gps_time: Some(1000.0 + f64::from(i)),
413                color: Some(las::Color {
414                    red: i as u16,
415                    green: i as u16,
416                    blue: i as u16,
417                }),
418                waveform: None,
419                nir: None,
420                extra_bytes: Vec::new(),
421            };
422            rp.write_to(&mut buf, &format).unwrap();
423        }
424        PointDataBuilder::new()
425            .with_format(format)
426            .with_transforms(transforms)
427            .build_from_bytes(buf)
428            .unwrap()
429    }
430
431    fn make_chunk(cloud: las::PointData, fields: Fields) -> Chunk {
432        Chunk {
433            key: VoxelKey::ROOT,
434            fields,
435            cloud,
436        }
437    }
438
439    #[test]
440    fn point_count_and_empty() {
441        let cloud = build_test_cloud(5);
442        let chunk = make_chunk(cloud, Fields::ALL);
443        assert_eq!(chunk.point_count(), 5);
444        assert!(!chunk.is_empty());
445    }
446
447    #[test]
448    fn positions_iterate_correctly() {
449        let chunk = make_chunk(build_test_cloud(3), Fields::ALL);
450        let positions: Vec<_> = chunk.positions().unwrap().collect();
451        assert_eq!(positions.len(), 3);
452        assert_eq!(positions[0], [0.0, 1.0, 2.0]);
453        assert_eq!(positions[2], [2.0, 3.0, 4.0]);
454    }
455
456    #[test]
457    fn positions_returns_none_without_z_field() {
458        let chunk = make_chunk(build_test_cloud(3), Fields::empty());
459        assert!(chunk.positions().is_none());
460    }
461
462    #[test]
463    fn gps_time_column_guarded_by_fields() {
464        let chunk = make_chunk(build_test_cloud(3), Fields::Z);
465        assert!(
466            chunk.gps_time().is_none(),
467            "GPS_TIME not in mask -> column should be None"
468        );
469    }
470
471    #[test]
472    fn gps_time_column_present_when_fields_allow() {
473        let chunk = make_chunk(build_test_cloud(3), Fields::ALL);
474        let times: Vec<_> = chunk.gps_time().unwrap().collect();
475        assert_eq!(times, vec![1000.0, 1001.0, 1002.0]);
476    }
477
478    #[test]
479    fn rgb_column_guarded_by_fields() {
480        let chunk = make_chunk(build_test_cloud(3), Fields::Z | Fields::GPS_TIME);
481        assert!(chunk.rgb().is_none());
482    }
483
484    #[test]
485    fn rgb_column_present_when_fields_allow() {
486        let chunk = make_chunk(build_test_cloud(3), Fields::ALL);
487        let rgb: Vec<_> = chunk.rgb().unwrap().collect();
488        assert_eq!(rgb, vec![(0, 0, 0), (1, 1, 1), (2, 2, 2)]);
489    }
490
491    #[test]
492    fn intensity_column_guarded() {
493        let chunk = make_chunk(build_test_cloud(3), Fields::Z);
494        assert!(chunk.intensity().is_none());
495        let chunk = make_chunk(build_test_cloud(3), Fields::Z | Fields::INTENSITY);
496        let intensities: Vec<_> = chunk.intensity().unwrap().collect();
497        assert_eq!(intensities, vec![100, 101, 102]);
498    }
499
500    #[test]
501    fn to_points_refuses_partial_fields() {
502        let chunk = make_chunk(build_test_cloud(3), Fields::Z);
503        let r = chunk.to_points();
504        assert!(matches!(r, Err(CopcError::PartialDecode(_))));
505    }
506
507    #[test]
508    fn to_points_succeeds_with_all_fields() {
509        let chunk = make_chunk(build_test_cloud(3), Fields::ALL);
510        let pts = chunk.to_points().unwrap();
511        assert_eq!(pts.len(), 3);
512        assert_eq!(pts[0].intensity, 100);
513        assert_eq!(pts[1].gps_time, Some(1001.0));
514    }
515
516    #[test]
517    fn points_at_materializes_subset() {
518        let chunk = make_chunk(build_test_cloud(5), Fields::ALL);
519        let pts = chunk.points_at(&[0, 2, 4]).unwrap();
520        assert_eq!(pts.len(), 3);
521        assert_eq!(pts[0].x, 0.0);
522        assert_eq!(pts[1].x, 2.0);
523        assert_eq!(pts[2].x, 4.0);
524        assert_eq!(pts[0].intensity, 100);
525        assert_eq!(pts[2].intensity, 104);
526    }
527
528    #[test]
529    fn points_at_refuses_partial_fields() {
530        let chunk = make_chunk(build_test_cloud(3), Fields::Z);
531        let r = chunk.points_at(&[0, 1]);
532        assert!(matches!(r, Err(CopcError::PartialDecode(_))));
533    }
534
535    #[test]
536    fn points_at_empty_slice_returns_empty_vec() {
537        let chunk = make_chunk(build_test_cloud(3), Fields::ALL);
538        let pts = chunk.points_at(&[]).unwrap();
539        assert!(pts.is_empty());
540    }
541
542    #[test]
543    fn indices_in_bounds_filters_correctly() {
544        // Points: (0,1,2), (1,2,3), (2,3,4), (3,4,5), (4,5,6)
545        let chunk = make_chunk(build_test_cloud(5), Fields::ALL);
546        let bounds = Aabb {
547            min: [1.0, 0.0, 0.0],
548            max: [2.0, 10.0, 10.0],
549        };
550        // x in [1, 2]: indices 1 and 2.
551        assert_eq!(chunk.indices_in_bounds(&bounds).unwrap(), vec![1, 2]);
552    }
553
554    #[test]
555    fn indices_in_bounds_empty_when_outside() {
556        let chunk = make_chunk(build_test_cloud(5), Fields::ALL);
557        let bounds = Aabb {
558            min: [100.0, 100.0, 100.0],
559            max: [200.0, 200.0, 200.0],
560        };
561        assert!(chunk.indices_in_bounds(&bounds).unwrap().is_empty());
562    }
563
564    #[test]
565    fn indices_in_bounds_returns_none_without_z_field() {
566        let chunk = make_chunk(build_test_cloud(5), Fields::empty());
567        let bounds = Aabb {
568            min: [0.0, 0.0, 0.0],
569            max: [10.0, 10.0, 10.0],
570        };
571        assert!(chunk.indices_in_bounds(&bounds).is_none());
572    }
573}