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}