Skip to main content

oxigdal_copc/
copc_reader.rs

1//! High-level COPC file reader that ties together header parsing, VLR chain
2//! walking, hierarchy traversal and point deserialization.
3//!
4//! [`CopcReader`] provides a single entry-point for loading a COPC file from
5//! an in-memory byte slice and running spatial queries against its octree
6//! index.
7
8use crate::copc_vlr::CopcInfo;
9use crate::error::CopcError;
10use crate::hierarchy::{HierarchyEntry, query_hierarchy_with_page_pointers};
11use crate::las_header::LasHeader;
12use crate::point::{BoundingBox3D, Point3D};
13use crate::point_format;
14use crate::vlr_chain;
15
16/// A parsed COPC file ready for spatial queries.
17///
18/// # Example
19/// ```ignore
20/// use oxigdal_copc::copc_reader::CopcReader;
21/// use oxigdal_copc::point::BoundingBox3D;
22///
23/// let data = std::fs::read("my_file.copc.laz").expect("read file");
24/// let reader = CopcReader::from_bytes(&data).expect("parse COPC");
25/// let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 100.0, 100.0, 50.0).expect("valid bbox");
26/// let points = reader.query_points_in_bbox(&bbox).expect("query");
27/// ```
28pub struct CopcReader {
29    /// Parsed LAS public header.
30    header: LasHeader,
31    /// COPC info VLR body.
32    copc_info: CopcInfo,
33    /// All VLRs from the file.
34    vlrs: Vec<crate::copc_vlr::Vlr>,
35    /// The raw file bytes, kept for hierarchy + point data access.
36    data: Vec<u8>,
37}
38
39impl CopcReader {
40    /// Parse a COPC file from an in-memory byte slice.
41    ///
42    /// Parses the LAS header, walks the VLR chain to extract the COPC info
43    /// and hierarchy VLR, and stores the raw data for later queries.
44    ///
45    /// # Errors
46    /// Returns [`CopcError`] when the data is not a valid COPC file (bad
47    /// magic, missing VLRs, truncated data, etc.).
48    pub fn from_bytes(data: &[u8]) -> Result<Self, CopcError> {
49        let header = LasHeader::parse(data)?;
50
51        let vlrs = vlr_chain::parse_vlrs(data, &header)?;
52
53        let copc_info = vlr_chain::find_copc_info(&vlrs)?;
54
55        // Verify the hierarchy VLR exists (we don't need its payload yet).
56        let _hierarchy_vlr = vlr_chain::find_copc_hierarchy_vlr(&vlrs)?;
57
58        Ok(Self {
59            header,
60            copc_info,
61            vlrs,
62            data: data.to_vec(),
63        })
64    }
65
66    /// Return a reference to the parsed LAS header.
67    pub fn header(&self) -> &LasHeader {
68        &self.header
69    }
70
71    /// Return a reference to the parsed COPC info.
72    pub fn copc_info(&self) -> &CopcInfo {
73        &self.copc_info
74    }
75
76    /// Return a reference to the VLR list.
77    pub fn vlrs(&self) -> &[crate::copc_vlr::Vlr] {
78        &self.vlrs
79    }
80
81    /// Return the full octree bounding box as a [`BoundingBox3D`].
82    pub fn octree_bounds(&self) -> BoundingBox3D {
83        let (min, max) = self.copc_info.bounds();
84        BoundingBox3D {
85            min_x: min[0],
86            min_y: min[1],
87            min_z: min[2],
88            max_x: max[0],
89            max_y: max[1],
90            max_z: max[2],
91        }
92    }
93
94    /// Query all points whose positions fall within the given 3D bounding box.
95    ///
96    /// Traverses the COPC hierarchy to find octree nodes that intersect
97    /// `bbox`, reads the corresponding point data chunks and deserializes
98    /// them, then filters to only those points geometrically inside `bbox`.
99    ///
100    /// # Errors
101    /// Returns [`CopcError`] on hierarchy traversal or point deserialization
102    /// errors.
103    pub fn query_points_in_bbox(&self, bbox: &BoundingBox3D) -> Result<Vec<Point3D>, CopcError> {
104        let entries = query_hierarchy_with_page_pointers(
105            &self.data,
106            &self.copc_info,
107            self.copc_info.root_hier_offset,
108            self.copc_info.root_hier_size,
109            bbox,
110        )?;
111
112        let scale = [
113            self.header.scale_x,
114            self.header.scale_y,
115            self.header.scale_z,
116        ];
117        let offset = [
118            self.header.offset_x,
119            self.header.offset_y,
120            self.header.offset_z,
121        ];
122        let format_id = self.header.point_data_format_id;
123        let record_length = self.header.point_data_record_length as usize;
124
125        let mut result: Vec<Point3D> = Vec::new();
126
127        for entry in &entries {
128            let chunk_offset = entry.offset as usize;
129            let chunk_size = entry.byte_count as usize;
130            let point_count = entry.point_count as usize;
131
132            if chunk_offset + chunk_size > self.data.len() {
133                return Err(CopcError::InvalidFormat(format!(
134                    "Point data chunk at offset {chunk_offset} + size {chunk_size} \
135                     exceeds file length {}",
136                    self.data.len()
137                )));
138            }
139
140            let chunk_data = &self.data[chunk_offset..chunk_offset + chunk_size];
141
142            let points = point_format::deserialize_points(
143                chunk_data,
144                point_count,
145                record_length,
146                format_id,
147                scale,
148                offset,
149            )?;
150
151            // Filter to only points inside the bbox
152            for pt in points {
153                if bbox.contains(&pt) {
154                    result.push(pt);
155                }
156            }
157        }
158
159        Ok(result)
160    }
161
162    /// Query all points within the entire file (no spatial filter).
163    ///
164    /// Walks the hierarchy and reads all point data chunks.
165    ///
166    /// # Errors
167    /// Returns [`CopcError`] on hierarchy or point deserialization errors.
168    pub fn all_points(&self) -> Result<Vec<Point3D>, CopcError> {
169        let full_bbox = self.octree_bounds();
170        // Use a slightly expanded bbox to ensure all points are captured
171        let expanded = full_bbox.expand_by(1.0);
172        self.query_points_in_bbox(&expanded)
173    }
174
175    /// Return the total number of hierarchy entries (data chunks) that
176    /// intersect the given bounding box, without reading point data.
177    ///
178    /// Useful for estimating query cost before fetching actual points.
179    pub fn count_intersecting_chunks(
180        &self,
181        bbox: &BoundingBox3D,
182    ) -> Result<Vec<HierarchyEntry>, CopcError> {
183        query_hierarchy_with_page_pointers(
184            &self.data,
185            &self.copc_info,
186            self.copc_info.root_hier_offset,
187            self.copc_info.root_hier_size,
188            bbox,
189        )
190    }
191
192    /// Return all hierarchy entries at a given depth level.
193    ///
194    /// This traverses the full hierarchy and filters by depth.
195    pub fn entries_at_depth(&self, depth: i32) -> Result<Vec<HierarchyEntry>, CopcError> {
196        let full_bbox = self.octree_bounds().expand_by(1.0);
197        let all_entries = query_hierarchy_with_page_pointers(
198            &self.data,
199            &self.copc_info,
200            self.copc_info.root_hier_offset,
201            self.copc_info.root_hier_size,
202            &full_bbox,
203        )?;
204        Ok(all_entries
205            .into_iter()
206            .filter(|e| e.key.depth == depth)
207            .collect())
208    }
209}
210
211impl std::fmt::Debug for CopcReader {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("CopcReader")
214            .field("version", &self.header.version)
215            .field("point_format", &self.header.point_data_format_id)
216            .field("record_length", &self.header.point_data_record_length)
217            .field("num_vlrs", &self.vlrs.len())
218            .field("octree_center_x", &self.copc_info.center_x)
219            .field("octree_center_y", &self.copc_info.center_y)
220            .field("octree_center_z", &self.copc_info.center_z)
221            .field("octree_halfsize", &self.copc_info.halfsize)
222            .field("file_size", &self.data.len())
223            .finish()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    /// Build a complete synthetic COPC file with the given point format.
232    ///
233    /// Layout:
234    ///   [0..227]    LAS header
235    ///   [227..441]  VLR #0: COPC info (54 hdr + 160 payload)
236    ///   [441..509]  VLR #1: COPC hierarchy (54 hdr + 14 payload = placeholder)
237    ///   [509..]     Point data
238    ///   [....]      Hierarchy page
239    fn build_synthetic_copc(format_id: u8, record_length: u16, points: &[Vec<u8>]) -> Vec<u8> {
240        // We'll build the file in stages, then patch offsets at the end.
241
242        // 1. LAS header (227 bytes minimum for LAS 1.4)
243        let header_size: u16 = 227;
244        let mut file = vec![0u8; header_size as usize];
245        file[0..4].copy_from_slice(b"LASF");
246        file[24] = 1; // major
247        file[25] = 4; // minor
248        file[94..96].copy_from_slice(&header_size.to_le_bytes());
249        file[100..104].copy_from_slice(&2u32.to_le_bytes()); // 2 VLRs
250        file[104] = format_id;
251        file[105..107].copy_from_slice(&record_length.to_le_bytes());
252        // Legacy point count
253        file[107..111].copy_from_slice(&(points.len() as u32).to_le_bytes());
254        // Scale
255        let scale = 0.001f64.to_le_bytes();
256        file[131..139].copy_from_slice(&scale);
257        file[139..147].copy_from_slice(&scale);
258        file[147..155].copy_from_slice(&scale);
259        // Offset = 0
260        // Bounds: compute from actual point values would be complex, use generous values
261        file[179..187].copy_from_slice(&1000.0f64.to_le_bytes()); // max_x
262        file[187..195].copy_from_slice(&(-1000.0f64).to_le_bytes()); // min_x
263        file[195..203].copy_from_slice(&1000.0f64.to_le_bytes()); // max_y
264        file[203..211].copy_from_slice(&(-1000.0f64).to_le_bytes()); // min_y
265        file[211..219].copy_from_slice(&1000.0f64.to_le_bytes()); // max_z
266        file[219..227].copy_from_slice(&(-1000.0f64).to_le_bytes()); // min_z
267
268        // 2. VLR #0: COPC info (user_id="copc", record_id=1, 160-byte body)
269        let copc_info_body = {
270            let mut body = vec![0u8; 160];
271            body[0..8].copy_from_slice(&500.0f64.to_le_bytes()); // center_x
272            body[8..16].copy_from_slice(&500.0f64.to_le_bytes()); // center_y
273            body[16..24].copy_from_slice(&500.0f64.to_le_bytes()); // center_z
274            body[24..32].copy_from_slice(&500.0f64.to_le_bytes()); // halfsize
275            body[32..40].copy_from_slice(&1.0f64.to_le_bytes()); // spacing
276            // root_hier_offset and root_hier_size will be patched later
277            body
278        };
279        append_vlr_to_file(&mut file, "copc", 1, &copc_info_body);
280
281        // 3. VLR #1: COPC hierarchy (user_id="copc", record_id=1000, empty for now)
282        // The actual hierarchy data is placed after point data.
283        append_vlr_to_file(&mut file, "copc", 1000, b"");
284
285        // 4. Patch offset_to_point_data
286        let point_data_offset = file.len() as u32;
287        file[96..100].copy_from_slice(&point_data_offset.to_le_bytes());
288
289        // 5. Write point data
290        let point_data_start = file.len();
291        for p in points {
292            file.extend_from_slice(p);
293        }
294        let point_data_end = file.len();
295        let point_data_size = point_data_end - point_data_start;
296
297        // 6. Write hierarchy page: single root entry pointing to all points
298        let hier_offset = file.len();
299        let hier_entry = crate::hierarchy::HierarchyEntry {
300            key: crate::hierarchy::VoxelKey {
301                depth: 0,
302                x: 0,
303                y: 0,
304                z: 0,
305            },
306            offset: point_data_start as u64,
307            byte_count: point_data_size as i32,
308            point_count: points.len() as i32,
309        };
310        file.extend_from_slice(&hier_entry.to_bytes());
311        let hier_size = 32u64; // one entry
312
313        // 7. Patch CopcInfo: root_hier_offset at body offset 40, root_hier_size at 48
314        // CopcInfo VLR body starts at header_size + 54 (VLR header for first VLR)
315        let copc_body_offset = header_size as usize + 54;
316        file[copc_body_offset + 40..copc_body_offset + 48]
317            .copy_from_slice(&(hier_offset as u64).to_le_bytes());
318        file[copc_body_offset + 48..copc_body_offset + 56]
319            .copy_from_slice(&hier_size.to_le_bytes());
320
321        file
322    }
323
324    fn append_vlr_to_file(file: &mut Vec<u8>, user_id: &str, record_id: u16, payload: &[u8]) {
325        // reserved (2 bytes)
326        file.extend_from_slice(&[0u8; 2]);
327        // user_id (16 bytes)
328        let uid_bytes = user_id.as_bytes();
329        let mut uid_buf = [0u8; 16];
330        let len = uid_bytes.len().min(16);
331        uid_buf[..len].copy_from_slice(&uid_bytes[..len]);
332        file.extend_from_slice(&uid_buf);
333        // record_id
334        file.extend_from_slice(&record_id.to_le_bytes());
335        // record_length_after_header
336        file.extend_from_slice(&(payload.len() as u16).to_le_bytes());
337        // description (32 bytes)
338        file.extend_from_slice(&[0u8; 32]);
339        // payload
340        file.extend_from_slice(payload);
341    }
342
343    /// Build a format-6 point record (30 bytes).
344    fn make_format6_point(raw_x: i32, raw_y: i32, raw_z: i32) -> Vec<u8> {
345        let mut rec = vec![0u8; 30];
346        rec[0..4].copy_from_slice(&raw_x.to_le_bytes());
347        rec[4..8].copy_from_slice(&raw_y.to_le_bytes());
348        rec[8..12].copy_from_slice(&raw_z.to_le_bytes());
349        rec[12..14].copy_from_slice(&100u16.to_le_bytes()); // intensity
350        rec[14] = 1 | (1 << 4); // return_number=1, number_of_returns=1
351        rec[16] = 2; // classification = Ground
352        rec[22..30].copy_from_slice(&1.0f64.to_le_bytes()); // GPS time
353        rec
354    }
355
356    /// Build a format-0 point record (20 bytes).
357    fn make_format0_point(raw_x: i32, raw_y: i32, raw_z: i32) -> Vec<u8> {
358        let mut rec = vec![0u8; 20];
359        rec[0..4].copy_from_slice(&raw_x.to_le_bytes());
360        rec[4..8].copy_from_slice(&raw_y.to_le_bytes());
361        rec[8..12].copy_from_slice(&raw_z.to_le_bytes());
362        rec[12..14].copy_from_slice(&50u16.to_le_bytes()); // intensity
363        rec[14] = 1 | (1 << 3); // return=1, num_returns=1
364        rec[15] = 2; // classification = Ground
365        rec
366    }
367
368    // ---- CopcReader construction tests ----
369
370    #[test]
371    fn test_copc_reader_from_bytes_format6() {
372        let points = vec![
373            make_format6_point(100_000, 200_000, 50_000), // (100, 200, 50)
374            make_format6_point(300_000, 400_000, 100_000), // (300, 400, 100)
375        ];
376        let file_data = build_synthetic_copc(6, 30, &points);
377        let reader = CopcReader::from_bytes(&file_data).expect("should parse synthetic COPC");
378
379        assert_eq!(reader.header().point_data_format_id, 6);
380        assert_eq!(reader.vlrs().len(), 2);
381        assert!((reader.copc_info().center_x - 500.0).abs() < f64::EPSILON);
382        assert!((reader.copc_info().halfsize - 500.0).abs() < f64::EPSILON);
383    }
384
385    #[test]
386    fn test_copc_reader_from_bytes_format0() {
387        let points = vec![
388            make_format0_point(50_000, 50_000, 10_000), // (50, 50, 10)
389        ];
390        let file_data = build_synthetic_copc(0, 20, &points);
391        let reader = CopcReader::from_bytes(&file_data).expect("should parse format-0 COPC");
392        assert_eq!(reader.header().point_data_format_id, 0);
393    }
394
395    #[test]
396    fn test_copc_reader_octree_bounds() {
397        let points = vec![make_format6_point(0, 0, 0)];
398        let file_data = build_synthetic_copc(6, 30, &points);
399        let reader = CopcReader::from_bytes(&file_data).expect("parse");
400        let bounds = reader.octree_bounds();
401        // center=500, halfsize=500 -> min=0, max=1000
402        assert!((bounds.min_x - 0.0).abs() < 1e-9);
403        assert!((bounds.max_x - 1000.0).abs() < 1e-9);
404    }
405
406    // ---- Spatial query tests ----
407
408    #[test]
409    fn test_query_points_all_inside_bbox() {
410        let points = vec![
411            make_format6_point(100_000, 200_000, 50_000),
412            make_format6_point(300_000, 400_000, 100_000),
413        ];
414        let file_data = build_synthetic_copc(6, 30, &points);
415        let reader = CopcReader::from_bytes(&file_data).expect("parse");
416
417        // Query that covers all points
418        let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 500.0, 500.0, 500.0).expect("valid bbox");
419        let result = reader.query_points_in_bbox(&bbox).expect("query");
420        assert_eq!(result.len(), 2);
421    }
422
423    #[test]
424    fn test_query_points_partial_bbox() {
425        let points = vec![
426            make_format6_point(100_000, 100_000, 10_000), // (100, 100, 10)
427            make_format6_point(800_000, 800_000, 10_000), // (800, 800, 10)
428        ];
429        let file_data = build_synthetic_copc(6, 30, &points);
430        let reader = CopcReader::from_bytes(&file_data).expect("parse");
431
432        // Query only covers the first point
433        let bbox = BoundingBox3D::new(50.0, 50.0, 0.0, 200.0, 200.0, 50.0).expect("valid bbox");
434        let result = reader.query_points_in_bbox(&bbox).expect("query");
435        assert_eq!(result.len(), 1);
436        assert!((result[0].x - 100.0).abs() < 1e-6);
437    }
438
439    #[test]
440    fn test_query_points_none_in_bbox() {
441        let points = vec![
442            make_format6_point(100_000, 100_000, 10_000), // (100, 100, 10)
443        ];
444        let file_data = build_synthetic_copc(6, 30, &points);
445        let reader = CopcReader::from_bytes(&file_data).expect("parse");
446
447        // Query bbox that doesn't contain any points
448        let bbox =
449            BoundingBox3D::new(500.0, 500.0, 500.0, 600.0, 600.0, 600.0).expect("valid bbox");
450        let result = reader.query_points_in_bbox(&bbox).expect("query");
451        assert!(result.is_empty());
452    }
453
454    #[test]
455    fn test_query_points_coordinates_correct() {
456        let points = vec![make_format6_point(123_456, 789_012, 345_678)];
457        let file_data = build_synthetic_copc(6, 30, &points);
458        let reader = CopcReader::from_bytes(&file_data).expect("parse");
459
460        let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 1000.0, 1000.0, 1000.0).expect("valid bbox");
461        let result = reader.query_points_in_bbox(&bbox).expect("query");
462        assert_eq!(result.len(), 1);
463        // 123456 * 0.001 = 123.456
464        assert!((result[0].x - 123.456).abs() < 1e-6);
465        assert!((result[0].y - 789.012).abs() < 1e-6);
466        assert!((result[0].z - 345.678).abs() < 1e-6);
467    }
468
469    #[test]
470    fn test_all_points() {
471        let points = vec![
472            make_format6_point(100_000, 100_000, 100_000),
473            make_format6_point(200_000, 200_000, 200_000),
474            make_format6_point(300_000, 300_000, 300_000),
475        ];
476        let file_data = build_synthetic_copc(6, 30, &points);
477        let reader = CopcReader::from_bytes(&file_data).expect("parse");
478
479        let all = reader.all_points().expect("all_points");
480        assert_eq!(all.len(), 3);
481    }
482
483    #[test]
484    fn test_count_intersecting_chunks() {
485        let points = vec![make_format6_point(500_000, 500_000, 500_000)];
486        let file_data = build_synthetic_copc(6, 30, &points);
487        let reader = CopcReader::from_bytes(&file_data).expect("parse");
488
489        let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 1000.0, 1000.0, 1000.0).expect("valid bbox");
490        let entries = reader.count_intersecting_chunks(&bbox).expect("count");
491        assert_eq!(entries.len(), 1);
492        assert_eq!(entries[0].point_count, 1);
493    }
494
495    #[test]
496    fn test_entries_at_depth() {
497        let points = vec![make_format6_point(500_000, 500_000, 500_000)];
498        let file_data = build_synthetic_copc(6, 30, &points);
499        let reader = CopcReader::from_bytes(&file_data).expect("parse");
500
501        let depth0 = reader.entries_at_depth(0).expect("depth 0");
502        assert_eq!(depth0.len(), 1);
503
504        let depth1 = reader.entries_at_depth(1).expect("depth 1");
505        assert!(depth1.is_empty());
506    }
507
508    // ---- Error handling tests ----
509
510    #[test]
511    fn test_copc_reader_bad_magic() {
512        let mut file_data = build_synthetic_copc(6, 30, &[]);
513        file_data[0] = b'X'; // corrupt magic
514        assert!(CopcReader::from_bytes(&file_data).is_err());
515    }
516
517    #[test]
518    fn test_copc_reader_missing_copc_vlr() {
519        // Build a file with only a non-COPC VLR
520        let mut file = vec![0u8; 227];
521        file[0..4].copy_from_slice(b"LASF");
522        file[24] = 1;
523        file[25] = 4;
524        file[94..96].copy_from_slice(&227u16.to_le_bytes());
525        file[96..100].copy_from_slice(&227u32.to_le_bytes());
526        file[100..104].copy_from_slice(&1u32.to_le_bytes()); // 1 VLR
527        file[104] = 6;
528        file[105..107].copy_from_slice(&30u16.to_le_bytes());
529        let scale = 0.001f64.to_le_bytes();
530        file[131..139].copy_from_slice(&scale);
531        file[139..147].copy_from_slice(&scale);
532        file[147..155].copy_from_slice(&scale);
533        append_vlr_to_file(&mut file, "laszip", 22204, b"data");
534        assert!(CopcReader::from_bytes(&file).is_err());
535    }
536
537    #[test]
538    fn test_copc_reader_debug_format() {
539        let points = vec![make_format6_point(0, 0, 0)];
540        let file_data = build_synthetic_copc(6, 30, &points);
541        let reader = CopcReader::from_bytes(&file_data).expect("parse");
542        let debug_str = format!("{reader:?}");
543        assert!(debug_str.contains("CopcReader"));
544        assert!(debug_str.contains("point_format"));
545    }
546
547    #[test]
548    fn test_copc_reader_format0_query() {
549        let points = vec![
550            make_format0_point(250_000, 250_000, 50_000), // (250, 250, 50)
551            make_format0_point(750_000, 750_000, 50_000), // (750, 750, 50)
552        ];
553        let file_data = build_synthetic_copc(0, 20, &points);
554        let reader = CopcReader::from_bytes(&file_data).expect("parse");
555
556        let bbox = BoundingBox3D::new(200.0, 200.0, 0.0, 300.0, 300.0, 100.0).expect("valid bbox");
557        let result = reader.query_points_in_bbox(&bbox).expect("query");
558        assert_eq!(result.len(), 1);
559        assert!((result[0].x - 250.0).abs() < 1e-6);
560    }
561
562    #[test]
563    fn test_copc_reader_multiple_points_ordering() {
564        let points = vec![
565            make_format6_point(100_000, 100_000, 10_000),
566            make_format6_point(200_000, 200_000, 20_000),
567            make_format6_point(300_000, 300_000, 30_000),
568            make_format6_point(400_000, 400_000, 40_000),
569            make_format6_point(500_000, 500_000, 50_000),
570        ];
571        let file_data = build_synthetic_copc(6, 30, &points);
572        let reader = CopcReader::from_bytes(&file_data).expect("parse");
573
574        let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 600.0, 600.0, 100.0).expect("valid bbox");
575        let result = reader.query_points_in_bbox(&bbox).expect("query");
576        assert_eq!(result.len(), 5);
577        // Points should appear in order
578        assert!((result[0].x - 100.0).abs() < 1e-6);
579        assert!((result[4].x - 500.0).abs() < 1e-6);
580    }
581
582    #[test]
583    fn test_copc_reader_point_attributes_preserved() {
584        let mut rec = make_format6_point(100_000, 200_000, 50_000);
585        rec[12..14].copy_from_slice(&42u16.to_le_bytes()); // intensity = 42
586        rec[14] = 2 | (3 << 4); // return=2, num_returns=3
587        rec[16] = 5; // classification = High Vegetation
588
589        let points = vec![rec];
590        let file_data = build_synthetic_copc(6, 30, &points);
591        let reader = CopcReader::from_bytes(&file_data).expect("parse");
592
593        let bbox = BoundingBox3D::new(0.0, 0.0, 0.0, 200.0, 300.0, 100.0).expect("valid bbox");
594        let result = reader.query_points_in_bbox(&bbox).expect("query");
595        assert_eq!(result.len(), 1);
596        assert_eq!(result[0].intensity, 42);
597        assert_eq!(result[0].return_number, 2);
598        assert_eq!(result[0].number_of_returns, 3);
599        assert_eq!(result[0].classification, 5);
600    }
601}