Skip to main content

copc_writer/
writer.rs

1use std::fs::File;
2use std::io::{BufReader, BufWriter, Cursor, Seek, SeekFrom, Write};
3use std::path::Path;
4
5use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
6use copc_core::{
7    Bounds, CancelCheck, ColumnData, CopcInfo, Entry, Error, LasColumnBatch, LasDimension,
8    LasPointRecord, NeverCancel, Result, StreamingLayout, VoxelKey, HIERARCHY_ENTRY_BYTES,
9};
10use las::{point::Format as LasFormat, raw, Color, Read as _};
11use laz::{LasZipCompressor, LazVlrBuilder};
12use tempfile::{NamedTempFile, TempPath};
13
14use crate::spill::{SpillReader, SpillWriter};
15
16const CANCEL_POLL_STRIDE: usize = 4_096;
17const HIERARCHY_PAGE_MAX_ENTRIES: usize = 4_096;
18const INDEX_RECORD_BYTES: u64 = 4;
19/// Depth bounds for the LOD octree. The layered LAZ compressor buffers an
20/// entire COPC chunk (one octree node) in memory before flushing, so a node
21/// holding far more than `max_points_per_node` points costs proportional
22/// memory. A too-shallow `max_depth` over a dense cluster stops subdivision
23/// while a node is still huge — the multi-gigabyte failure mode on real clouds.
24/// Clamping `max_depth` up to `MIN_LEAF_DEPTH` keeps nodes subdividing until
25/// they fit in a chunk; realistic clouds reach that far shallower, so it only
26/// affects pathologically dense input. `MAX_LEAF_DEPTH` keeps voxel keys in
27/// range.
28const MIN_LEAF_DEPTH: u32 = 21;
29const MAX_LEAF_DEPTH: u32 = 30;
30const LAS_14_SCAN_ANGLE_SCALE: f32 = 0.006;
31const LAS_VLR_HEADER_BYTES: u32 = 54;
32const LAS_EVLR_HEADER_BYTES: u64 = 60;
33const LASZIP_VLR_USER_ID: &str = "laszip encoded";
34const LASZIP_VLR_RECORD_ID: u16 = 22204;
35const LASF_PROJECTION_USER_ID: &str = "LASF_Projection";
36const WKT_CRS_RECORD_ID: u16 = 2112;
37const GEOTIFF_GEO_KEY_DIRECTORY_RECORD_ID: u16 = 34735;
38const GEOTIFF_DOUBLE_PARAMS_RECORD_ID: u16 = 34736;
39const GEOTIFF_ASCII_PARAMS_RECORD_ID: u16 = 34737;
40const LASF_SPEC_USER_ID: &str = "LASF_Spec";
41const EXTRA_BYTES_RECORD_ID: u16 = 4;
42const WKT_GLOBAL_ENCODING_BIT: u16 = 16;
43
44/// Normalized point fields consumed by the COPC writer.
45#[derive(Clone, Debug, PartialEq)]
46pub struct CopcPointFields {
47    pub x: f64,
48    pub y: f64,
49    pub z: f64,
50    pub intensity: u16,
51    pub return_number: u8,
52    pub number_of_returns: u8,
53    pub synthetic: u8,
54    pub key_point: u8,
55    pub withheld: u8,
56    pub overlap: u8,
57    pub scan_channel: u8,
58    pub scan_direction_flag: u8,
59    pub edge_of_flight_line: u8,
60    pub classification: u8,
61    pub user_data: u8,
62    /// Scan angle in degrees; encoded as LAS 1.4 scaled scan angle on write.
63    pub scan_angle: f32,
64    pub point_source_id: u16,
65    pub gps_time: f64,
66    pub red: u16,
67    pub green: u16,
68    pub blue: u16,
69    pub extra_bytes: Vec<u8>,
70}
71
72/// Abstract point-data source for COPC emission.
73pub trait CopcPointSource {
74    fn len(&self) -> usize;
75    fn xyz(&self, index: usize) -> (f64, f64, f64);
76    fn fields(&self, index: usize) -> Result<CopcPointFields>;
77    fn extra_byte_count(&self) -> u16 {
78        0
79    }
80    fn extra_bytes_vlrs(&self) -> &[las::Vlr] {
81        &[]
82    }
83
84    fn is_empty(&self) -> bool {
85        self.len() == 0
86    }
87}
88
89/// COPC writer source backed directly by a neutral LAS column batch.
90pub struct ColumnBatchSource<'a> {
91    batch: &'a LasColumnBatch,
92    x: &'a [f64],
93    y: &'a [f64],
94    z: &'a [f64],
95    intensity: Option<&'a [u16]>,
96    return_number: Option<&'a [u8]>,
97    number_of_returns: Option<&'a [u8]>,
98    synthetic: Option<&'a [bool]>,
99    key_point: Option<&'a [bool]>,
100    withheld: Option<&'a [bool]>,
101    overlap: Option<&'a [bool]>,
102    scan_channel: Option<&'a [u8]>,
103    scan_direction_flag: Option<&'a [bool]>,
104    edge_of_flight_line: Option<&'a [bool]>,
105    classification: Option<&'a [u8]>,
106    user_data: Option<&'a [u8]>,
107    scan_angle_rank: Option<&'a [i16]>,
108    point_source_id: Option<&'a [u16]>,
109    gps_time: Option<&'a [f64]>,
110    red: Option<&'a [u16]>,
111    green: Option<&'a [u16]>,
112    blue: Option<&'a [u16]>,
113    extra_bytes: Option<(&'a [u8], usize)>,
114}
115
116impl<'a> ColumnBatchSource<'a> {
117    pub fn new(batch: &'a LasColumnBatch) -> Result<Self> {
118        batch.validate()?;
119        validate_column_batch_writer_support(batch)?;
120
121        let x = required_f64_column(batch, LasDimension::X)?;
122        let y = required_f64_column(batch, LasDimension::Y)?;
123        let z = required_f64_column(batch, LasDimension::Z)?;
124        let red = optional_u16_column(batch, LasDimension::Red)?;
125        let green = optional_u16_column(batch, LasDimension::Green)?;
126        let blue = optional_u16_column(batch, LasDimension::Blue)?;
127        let extra_bytes = optional_extra_bytes_column(batch)?;
128        validate_color_columns(red, green, blue)?;
129
130        Ok(Self {
131            batch,
132            x,
133            y,
134            z,
135            intensity: optional_u16_column(batch, LasDimension::Intensity)?,
136            return_number: optional_u8_column(batch, LasDimension::ReturnNumber)?,
137            number_of_returns: optional_u8_column(batch, LasDimension::NumberOfReturns)?,
138            synthetic: optional_bool_column(batch, LasDimension::Synthetic)?,
139            key_point: optional_bool_column(batch, LasDimension::KeyPoint)?,
140            withheld: optional_bool_column(batch, LasDimension::Withheld)?,
141            overlap: optional_bool_column(batch, LasDimension::Overlap)?,
142            scan_channel: optional_u8_column(batch, LasDimension::ScanChannel)?,
143            scan_direction_flag: optional_bool_column(batch, LasDimension::ScanDirectionFlag)?,
144            edge_of_flight_line: optional_bool_column(batch, LasDimension::EdgeOfFlightLine)?,
145            classification: optional_u8_column(batch, LasDimension::Classification)?,
146            user_data: optional_u8_column(batch, LasDimension::UserData)?,
147            scan_angle_rank: optional_i16_column(batch, LasDimension::ScanAngleRank)?,
148            point_source_id: optional_u16_column(batch, LasDimension::PointSourceId)?,
149            gps_time: optional_f64_column(batch, LasDimension::GpsTime)?,
150            red,
151            green,
152            blue,
153            extra_bytes,
154        })
155    }
156
157    pub fn batch(&self) -> &LasColumnBatch {
158        self.batch
159    }
160
161    pub fn has_color(&self) -> bool {
162        self.red.is_some() && self.green.is_some() && self.blue.is_some()
163    }
164
165    pub fn extra_byte_width(&self) -> usize {
166        self.extra_bytes.map(|(_, width)| width).unwrap_or(0)
167    }
168
169    pub fn bounds(&self) -> Result<Bounds> {
170        if self.is_empty() {
171            return Err(Error::InvalidInput(
172                "cannot compute bounds for empty column batch".into(),
173            ));
174        }
175        let mut bounds = Bounds::point(self.x[0], self.y[0], self.z[0]);
176        for index in 1..self.len() {
177            bounds.extend(self.x[index], self.y[index], self.z[index]);
178        }
179        Ok(bounds)
180    }
181}
182
183impl CopcPointSource for ColumnBatchSource<'_> {
184    fn len(&self) -> usize {
185        self.batch.len()
186    }
187
188    #[inline]
189    fn xyz(&self, index: usize) -> (f64, f64, f64) {
190        (self.x[index], self.y[index], self.z[index])
191    }
192
193    fn fields(&self, index: usize) -> Result<CopcPointFields> {
194        Ok(CopcPointFields {
195            x: self.x[index],
196            y: self.y[index],
197            z: self.z[index],
198            intensity: at_u16(self.intensity, index),
199            return_number: at_u8(self.return_number, index),
200            number_of_returns: at_u8(self.number_of_returns, index),
201            synthetic: at_bool_u8(self.synthetic, index),
202            key_point: at_bool_u8(self.key_point, index),
203            withheld: at_bool_u8(self.withheld, index),
204            overlap: at_bool_u8(self.overlap, index),
205            scan_channel: at_u8(self.scan_channel, index),
206            scan_direction_flag: at_bool_u8(self.scan_direction_flag, index),
207            edge_of_flight_line: at_bool_u8(self.edge_of_flight_line, index),
208            classification: at_u8(self.classification, index),
209            user_data: at_u8(self.user_data, index),
210            scan_angle: self
211                .scan_angle_rank
212                .map(|column| column[index] as f32 * 90.0 / 180.0)
213                .unwrap_or(0.0),
214            point_source_id: at_u16(self.point_source_id, index),
215            gps_time: self.gps_time.map(|column| column[index]).unwrap_or(0.0),
216            red: at_u16(self.red, index),
217            green: at_u16(self.green, index),
218            blue: at_u16(self.blue, index),
219            extra_bytes: extra_bytes_at(self.extra_bytes, index),
220        })
221    }
222
223    fn extra_byte_count(&self) -> u16 {
224        self.extra_byte_width() as u16
225    }
226}
227
228fn at_bool_u8(column: Option<&[bool]>, index: usize) -> u8 {
229    column.map(|values| u8::from(values[index])).unwrap_or(0)
230}
231
232fn at_u8(column: Option<&[u8]>, index: usize) -> u8 {
233    column.map(|values| values[index]).unwrap_or(0)
234}
235
236fn at_u16(column: Option<&[u16]>, index: usize) -> u16 {
237    column.map(|values| values[index]).unwrap_or(0)
238}
239
240fn validate_column_batch_writer_support(batch: &LasColumnBatch) -> Result<()> {
241    let unsupported: Vec<_> = batch
242        .columns
243        .iter()
244        .filter_map(|(spec, _)| match spec.dimension {
245            LasDimension::Nir => Some("NIR point data"),
246            LasDimension::WaveformPacketDescriptorIndex
247            | LasDimension::WaveformPacketByteOffset
248            | LasDimension::WaveformPacketSize
249            | LasDimension::WavePacketReturnPointWaveformLocation => Some("waveform point data"),
250            _ => None,
251        })
252        .collect();
253    if unsupported.is_empty() {
254        Ok(())
255    } else {
256        Err(Error::Unsupported(format!(
257            "COPC writer cannot preserve {}",
258            unsupported.join(", ")
259        )))
260    }
261}
262
263fn validate_color_columns(
264    red: Option<&[u16]>,
265    green: Option<&[u16]>,
266    blue: Option<&[u16]>,
267) -> Result<()> {
268    let present =
269        usize::from(red.is_some()) + usize::from(green.is_some()) + usize::from(blue.is_some());
270    if present == 0 || present == 3 {
271        Ok(())
272    } else {
273        Err(Error::InvalidInput(
274            "Red, Green, and Blue columns must be supplied together".into(),
275        ))
276    }
277}
278
279fn required_f64_column(batch: &LasColumnBatch, dimension: LasDimension) -> Result<&[f64]> {
280    match batch.column(dimension) {
281        Some(ColumnData::F64(values)) => Ok(values),
282        Some(other) => Err(unexpected_column_type(dimension, "F64", other)),
283        None => Err(Error::InvalidInput(format!(
284            "ColumnBatchSource requires {dimension:?} column"
285        ))),
286    }
287}
288
289fn optional_f64_column(batch: &LasColumnBatch, dimension: LasDimension) -> Result<Option<&[f64]>> {
290    match batch.column(dimension) {
291        Some(ColumnData::F64(values)) => Ok(Some(values)),
292        Some(other) => Err(unexpected_column_type(dimension, "F64", other)),
293        None => Ok(None),
294    }
295}
296
297fn optional_i16_column(batch: &LasColumnBatch, dimension: LasDimension) -> Result<Option<&[i16]>> {
298    match batch.column(dimension) {
299        Some(ColumnData::I16(values)) => Ok(Some(values)),
300        Some(other) => Err(unexpected_column_type(dimension, "I16", other)),
301        None => Ok(None),
302    }
303}
304
305fn optional_u16_column(batch: &LasColumnBatch, dimension: LasDimension) -> Result<Option<&[u16]>> {
306    match batch.column(dimension) {
307        Some(ColumnData::U16(values)) => Ok(Some(values)),
308        Some(other) => Err(unexpected_column_type(dimension, "U16", other)),
309        None => Ok(None),
310    }
311}
312
313fn optional_u8_column(batch: &LasColumnBatch, dimension: LasDimension) -> Result<Option<&[u8]>> {
314    match batch.column(dimension) {
315        Some(ColumnData::U8(values)) => Ok(Some(values)),
316        Some(other) => Err(unexpected_column_type(dimension, "U8", other)),
317        None => Ok(None),
318    }
319}
320
321fn optional_extra_bytes_column(batch: &LasColumnBatch) -> Result<Option<(&[u8], usize)>> {
322    let mut extra_bytes = None;
323    for (spec, data) in &batch.columns {
324        if spec.dimension != LasDimension::ExtraBytes {
325            continue;
326        }
327        let width = spec.extra_byte_width().ok_or_else(|| {
328            Error::InvalidInput("ExtraBytes column requires a non-zero byte width".into())
329        })?;
330        if width > usize::from(u16::MAX) {
331            return Err(Error::InvalidInput(format!(
332                "ExtraBytes column width {width} exceeds LAS u16 range"
333            )));
334        }
335        let values = match data {
336            ColumnData::U8(values) => values.as_slice(),
337            other => {
338                return Err(unexpected_column_type(
339                    LasDimension::ExtraBytes,
340                    "U8",
341                    other,
342                ))
343            }
344        };
345        if extra_bytes.replace((values, width)).is_some() {
346            return Err(Error::InvalidInput(
347                "ColumnBatchSource supports at most one ExtraBytes column".into(),
348            ));
349        }
350    }
351    Ok(extra_bytes)
352}
353
354fn extra_bytes_at(column: Option<(&[u8], usize)>, index: usize) -> Vec<u8> {
355    match column {
356        Some((values, width)) => {
357            let start = index * width;
358            values[start..start + width].to_vec()
359        }
360        None => Vec::new(),
361    }
362}
363
364fn optional_bool_column(
365    batch: &LasColumnBatch,
366    dimension: LasDimension,
367) -> Result<Option<&[bool]>> {
368    match batch.column(dimension) {
369        Some(ColumnData::Bool(values)) => Ok(Some(values)),
370        Some(other) => Err(unexpected_column_type(dimension, "Bool", other)),
371        None => Ok(None),
372    }
373}
374
375fn unexpected_column_type(dimension: LasDimension, expected: &str, actual: &ColumnData) -> Error {
376    Error::InvalidInput(format!(
377        "{dimension:?} column must be {expected}, found {:?}",
378        actual.scalar()
379    ))
380}
381
382struct SpillSource<'a> {
383    reader: &'a SpillReader,
384}
385
386impl CopcPointSource for SpillSource<'_> {
387    fn len(&self) -> usize {
388        self.reader.len()
389    }
390
391    #[inline]
392    fn xyz(&self, index: usize) -> (f64, f64, f64) {
393        self.reader.xyz_at(index)
394    }
395
396    fn fields(&self, index: usize) -> Result<CopcPointFields> {
397        let record = self.reader.record_at(index)?;
398        Ok(CopcPointFields {
399            x: record.x,
400            y: record.y,
401            z: record.z,
402            intensity: record.intensity,
403            return_number: record.return_number,
404            number_of_returns: record.number_of_returns,
405            synthetic: u8::from(record.synthetic),
406            key_point: u8::from(record.key_point),
407            withheld: u8::from(record.withheld),
408            overlap: u8::from(record.overlap),
409            scan_channel: record.scan_channel,
410            scan_direction_flag: u8::from(record.scan_direction_flag),
411            edge_of_flight_line: u8::from(record.edge_of_flight_line),
412            classification: record.classification,
413            user_data: record.user_data,
414            scan_angle: record.scan_angle,
415            point_source_id: record.point_source_id,
416            gps_time: record.gps_time,
417            red: record.red,
418            green: record.green,
419            blue: record.blue,
420            extra_bytes: record.extra_bytes,
421        })
422    }
423
424    fn extra_byte_count(&self) -> u16 {
425        self.reader.layout().extra_bytes
426    }
427
428    fn extra_bytes_vlrs(&self) -> &[las::Vlr] {
429        &self.reader.layout().extra_bytes_descriptors
430    }
431}
432
433#[derive(Clone, Debug)]
434struct OutputCrsRecord {
435    vlr: las::Vlr,
436    is_extended: bool,
437}
438
439#[derive(Clone, Debug)]
440struct OutputLasMetadata {
441    file_source_id: u16,
442    global_encoding: u16,
443    guid: [u8; 16],
444    system_identifier: String,
445    generating_software: String,
446    creation_day_of_year: u16,
447    creation_year: u16,
448    scale: (f64, f64, f64),
449    offset: Option<(f64, f64, f64)>,
450    crs_records: Vec<OutputCrsRecord>,
451    pass_through_vlrs: Vec<las::Vlr>,
452    pass_through_evlrs: Vec<las::Vlr>,
453}
454
455impl Default for OutputLasMetadata {
456    fn default() -> Self {
457        Self {
458            file_source_id: 0,
459            global_encoding: 0,
460            guid: [0; 16],
461            system_identifier: "copc-rust".to_string(),
462            generating_software: "copc-writer".to_string(),
463            creation_day_of_year: 0,
464            creation_year: 2026,
465            scale: (0.001, 0.001, 0.001),
466            offset: None,
467            crs_records: Vec::new(),
468            pass_through_vlrs: Vec::new(),
469            pass_through_evlrs: Vec::new(),
470        }
471    }
472}
473
474impl OutputLasMetadata {
475    fn from_las_header(header: &las::Header) -> Self {
476        let mut global_encoding = u16::from(header.gps_time_type());
477        if header.has_synthetic_return_numbers() {
478            global_encoding |= 8;
479        }
480        let crs_records = extract_source_wkt_crs_records(header);
481        if !crs_records.is_empty() {
482            global_encoding |= WKT_GLOBAL_ENCODING_BIT;
483        }
484        let pass_through_vlrs = extract_pass_through_vlrs(header);
485        let pass_through_evlrs = extract_pass_through_evlrs(header);
486        let transforms = header.transforms();
487        let (creation_day_of_year, creation_year) = header
488            .date()
489            .map(|date| {
490                let year = date.format("%Y").to_string().parse().unwrap_or(0);
491                let day = date.format("%j").to_string().parse().unwrap_or(0);
492                (day, year)
493            })
494            .unwrap_or((0, 0));
495
496        Self {
497            file_source_id: header.file_source_id(),
498            global_encoding,
499            guid: *header.guid().as_bytes(),
500            system_identifier: header.system_identifier().to_string(),
501            generating_software: header.generating_software().to_string(),
502            creation_day_of_year,
503            creation_year,
504            scale: (transforms.x.scale, transforms.y.scale, transforms.z.scale),
505            offset: Some((
506                transforms.x.offset,
507                transforms.y.offset,
508                transforms.z.offset,
509            )),
510            crs_records,
511            pass_through_vlrs,
512            pass_through_evlrs,
513        }
514    }
515
516    fn regular_crs_vlrs(&self) -> impl Iterator<Item = &las::Vlr> {
517        self.crs_records
518            .iter()
519            .filter(|record| !record.is_extended)
520            .map(|record| &record.vlr)
521    }
522
523    fn extended_crs_evlrs(&self) -> impl Iterator<Item = &las::Vlr> {
524        self.crs_records
525            .iter()
526            .filter(|record| record.is_extended)
527            .map(|record| &record.vlr)
528    }
529
530    fn regular_crs_vlr_count(&self) -> usize {
531        self.crs_records
532            .iter()
533            .filter(|record| !record.is_extended)
534            .count()
535    }
536
537    fn extended_crs_evlr_count(&self) -> usize {
538        self.crs_records
539            .iter()
540            .filter(|record| record.is_extended)
541            .count()
542    }
543
544    fn regular_crs_vlr_bytes(&self) -> Result<u32> {
545        self.regular_crs_vlrs().try_fold(0u32, |total, vlr| {
546            let data_len = u16::try_from(vlr.data.len()).map_err(|_| {
547                Error::InvalidInput(format!(
548                    "regular WKT CRS VLR is too large: {} byte(s)",
549                    vlr.data.len()
550                ))
551            })?;
552            total
553                .checked_add(LAS_VLR_HEADER_BYTES + u32::from(data_len))
554                .ok_or_else(|| Error::InvalidInput("CRS VLR byte size overflow".into()))
555        })
556    }
557
558    fn source_evlrs_after_hierarchy(&self) -> impl Iterator<Item = &las::Vlr> {
559        self.extended_crs_evlrs()
560            .chain(self.pass_through_evlrs.iter())
561    }
562
563    fn source_evlr_count_after_hierarchy(&self) -> usize {
564        self.extended_crs_evlr_count() + self.pass_through_evlrs.len()
565    }
566}
567
568#[derive(Clone, Copy, Debug, PartialEq)]
569struct PointStats {
570    gpstime_min: f64,
571    gpstime_max: f64,
572    extended_return_counts: [u64; 15],
573}
574
575impl PointStats {
576    fn new() -> Self {
577        Self {
578            gpstime_min: f64::INFINITY,
579            gpstime_max: f64::NEG_INFINITY,
580            extended_return_counts: [0; 15],
581        }
582    }
583
584    fn record(&mut self, index: usize, fields: &CopcPointFields) -> Result<()> {
585        validate_finite_value(&format!("point {index} GPS time"), fields.gps_time)?;
586        self.gpstime_min = self.gpstime_min.min(fields.gps_time);
587        self.gpstime_max = self.gpstime_max.max(fields.gps_time);
588        if (1..=15).contains(&fields.return_number) {
589            self.extended_return_counts[usize::from(fields.return_number - 1)] += 1;
590        }
591        Ok(())
592    }
593}
594
595#[derive(Debug, Clone, Copy)]
596pub struct CopcWriterParams {
597    pub max_points_per_node: u32,
598    pub max_depth: u32,
599}
600
601impl Default for CopcWriterParams {
602    fn default() -> Self {
603        Self {
604            max_points_per_node: 100_000,
605            max_depth: 8,
606        }
607    }
608}
609
610pub fn write_source<S: CopcPointSource>(
611    path: &Path,
612    source: &S,
613    has_color: bool,
614    bounds: Bounds,
615    params: &CopcWriterParams,
616) -> Result<()> {
617    write_source_with_cancel(path, source, has_color, bounds, params, &NeverCancel)
618}
619
620pub fn write_source_with_cancel<S: CopcPointSource>(
621    path: &Path,
622    source: &S,
623    has_color: bool,
624    bounds: Bounds,
625    params: &CopcWriterParams,
626    cancel: &dyn CancelCheck,
627) -> Result<()> {
628    cancel.check()?;
629    if source.is_empty() {
630        return Err(Error::InvalidInput(
631            "cannot write empty cloud to COPC".into(),
632        ));
633    }
634    write_copc_inner(
635        path,
636        source,
637        has_color,
638        bounds,
639        params,
640        cancel,
641        &OutputLasMetadata::default(),
642    )
643}
644
645pub fn write_streaming_with_cancel<I>(
646    path: &Path,
647    layout: StreamingLayout,
648    points: I,
649    params: &CopcWriterParams,
650    spill_dir: &Path,
651    cancel: &dyn CancelCheck,
652) -> Result<()>
653where
654    I: IntoIterator<Item = Result<LasPointRecord>>,
655{
656    cancel.check()?;
657    validate_streaming_layout_supported(&layout)?;
658    let mut spill = SpillWriter::create(spill_dir, layout)?;
659    for (index, item) in points.into_iter().enumerate() {
660        if index % CANCEL_POLL_STRIDE == 0 {
661            cancel.check()?;
662        }
663        let record = item?;
664        validate_record_coordinates(&record, index)?;
665        spill.push(&record)?;
666    }
667    cancel.check()?;
668    let reader = spill.finalize()?;
669    write_copc_from_spill(path, reader, params, cancel, &OutputLasMetadata::default())
670}
671
672pub fn convert_las_to_copc_streaming(
673    las_path: &Path,
674    copc_path: &Path,
675    params: &CopcWriterParams,
676    spill_dir: &Path,
677    cancel: &dyn CancelCheck,
678) -> Result<()> {
679    cancel.check()?;
680    let mut reader = las::Reader::from_path(las_path).map_err(|e| Error::Las(e.to_string()))?;
681    validate_las_conversion_supported(reader.header())?;
682    let output_metadata = OutputLasMetadata::from_las_header(reader.header());
683    let layout = StreamingLayout::from_las_header(reader.header());
684    let mut spill = SpillWriter::create(spill_dir, layout)?;
685    for (index, result) in reader.points().enumerate() {
686        if index % CANCEL_POLL_STRIDE == 0 {
687            cancel.check()?;
688        }
689        let point = result.map_err(|e| Error::Las(e.to_string()))?;
690        let record = LasPointRecord::from_las_point(&point);
691        validate_record_coordinates(&record, index)?;
692        spill.push(&record)?;
693    }
694    cancel.check()?;
695    let reader = spill.finalize()?;
696    write_copc_from_spill(copc_path, reader, params, cancel, &output_metadata)
697}
698
699fn validate_streaming_layout_supported(layout: &StreamingLayout) -> Result<()> {
700    let mut unsupported = Vec::new();
701    if layout.has_nir {
702        unsupported.push("NIR point data".to_string());
703    }
704    if layout.has_waveform {
705        unsupported.push("waveform point data".to_string());
706    }
707    if unsupported.is_empty() {
708        Ok(())
709    } else {
710        Err(Error::Unsupported(format!(
711            "COPC writer cannot preserve {}",
712            unsupported.join(", ")
713        )))
714    }
715}
716
717fn validate_las_conversion_supported(header: &las::Header) -> Result<()> {
718    let mut unsupported = Vec::new();
719    let format = header.point_format();
720    if format.has_nir {
721        unsupported.push("NIR point data".to_string());
722    }
723    if format.has_waveform {
724        unsupported.push("waveform point data".to_string());
725    }
726    let source_has_wkt_crs_record = has_wkt_crs_record(header);
727    let mut geotiff_crs_record_count = 0usize;
728    for vlr in header.vlrs() {
729        if is_geotiff_crs_vlr(vlr) && !source_has_wkt_crs_record {
730            geotiff_crs_record_count += 1;
731        }
732    }
733    for evlr in header.evlrs() {
734        if is_geotiff_crs_vlr(evlr) && !source_has_wkt_crs_record {
735            geotiff_crs_record_count += 1;
736        }
737    }
738    if geotiff_crs_record_count > 0 {
739        unsupported.push(format!(
740            "{geotiff_crs_record_count} GeoTIFF CRS VLR/EVLR(s); GeoTIFF-to-WKT CRS conversion is not implemented in copc-writer"
741        ));
742    }
743
744    if unsupported.is_empty() {
745        Ok(())
746    } else {
747        Err(Error::Unsupported(format!(
748            "LAS-to-COPC streaming conversion cannot preserve {}",
749            unsupported.join(", ")
750        )))
751    }
752}
753
754fn is_laszip_vlr(vlr: &las::Vlr) -> bool {
755    vlr.user_id == LASZIP_VLR_USER_ID && vlr.record_id == LASZIP_VLR_RECORD_ID
756}
757
758fn is_copc_info_vlr(vlr: &las::Vlr) -> bool {
759    vlr.user_id == "copc" && vlr.record_id == 1
760}
761
762fn is_copc_hierarchy_evlr(vlr: &las::Vlr) -> bool {
763    vlr.user_id == "copc" && vlr.record_id == 1000
764}
765
766fn is_wkt_crs_vlr(vlr: &las::Vlr) -> bool {
767    vlr.user_id == LASF_PROJECTION_USER_ID && vlr.record_id == WKT_CRS_RECORD_ID
768}
769
770fn is_geotiff_crs_vlr(vlr: &las::Vlr) -> bool {
771    vlr.user_id == LASF_PROJECTION_USER_ID
772        && matches!(
773            vlr.record_id,
774            GEOTIFF_GEO_KEY_DIRECTORY_RECORD_ID
775                | GEOTIFF_DOUBLE_PARAMS_RECORD_ID
776                | GEOTIFF_ASCII_PARAMS_RECORD_ID
777        )
778}
779
780fn is_extra_bytes_descriptor_vlr(vlr: &las::Vlr) -> bool {
781    vlr.user_id == LASF_SPEC_USER_ID && vlr.record_id == EXTRA_BYTES_RECORD_ID
782}
783
784fn has_wkt_crs_record(header: &las::Header) -> bool {
785    header.vlrs().iter().any(is_wkt_crs_vlr) || header.evlrs().iter().any(is_wkt_crs_vlr)
786}
787
788fn extract_source_wkt_crs_records(header: &las::Header) -> Vec<OutputCrsRecord> {
789    let mut records = Vec::new();
790    for vlr in header.vlrs() {
791        if is_wkt_crs_vlr(vlr) {
792            records.push(OutputCrsRecord {
793                vlr: vlr.clone(),
794                is_extended: false,
795            });
796        }
797    }
798    for evlr in header.evlrs() {
799        if is_wkt_crs_vlr(evlr) {
800            records.push(OutputCrsRecord {
801                vlr: evlr.clone(),
802                is_extended: true,
803            });
804        }
805    }
806    records
807}
808
809fn extract_pass_through_vlrs(header: &las::Header) -> Vec<las::Vlr> {
810    header
811        .vlrs()
812        .iter()
813        .filter(|vlr| !is_laszip_vlr(vlr))
814        .filter(|vlr| !is_copc_info_vlr(vlr))
815        .filter(|vlr| !is_copc_hierarchy_evlr(vlr))
816        .filter(|vlr| !is_wkt_crs_vlr(vlr))
817        .filter(|vlr| !is_geotiff_crs_vlr(vlr))
818        .filter(|vlr| !is_extra_bytes_descriptor_vlr(vlr))
819        .cloned()
820        .collect()
821}
822
823fn extract_pass_through_evlrs(header: &las::Header) -> Vec<las::Vlr> {
824    header
825        .evlrs()
826        .iter()
827        .filter(|evlr| !is_laszip_vlr(evlr))
828        .filter(|evlr| !is_copc_info_vlr(evlr))
829        .filter(|evlr| !is_copc_hierarchy_evlr(evlr))
830        .filter(|evlr| !is_wkt_crs_vlr(evlr))
831        .filter(|evlr| !is_geotiff_crs_vlr(evlr))
832        .cloned()
833        .collect()
834}
835
836fn validate_record_coordinates(record: &LasPointRecord, index: usize) -> Result<()> {
837    validate_xyz_finite(index, record.x, record.y, record.z)
838}
839
840fn validate_coordinate_inputs<S: CopcPointSource>(
841    source: &S,
842    bounds: Bounds,
843    scale: (f64, f64, f64),
844    offset: (f64, f64, f64),
845    cancel: &dyn CancelCheck,
846) -> Result<PointStats> {
847    validate_bounds(bounds)?;
848    validate_transform(scale, offset)?;
849    let extra_byte_count = usize::from(source.extra_byte_count());
850    let mut stats = PointStats::new();
851    for index in 0..source.len() {
852        if index % CANCEL_POLL_STRIDE == 0 {
853            cancel.check()?;
854        }
855        let (x, y, z) = source.xyz(index);
856        validate_xyz_finite(index, x, y, z)?;
857        quantize_xyz(index, x, y, z, scale, offset)?;
858
859        let fields = source.fields(index)?;
860        validate_xyz_finite(index, fields.x, fields.y, fields.z)?;
861        quantize_xyz(index, fields.x, fields.y, fields.z, scale, offset)?;
862        validate_scan_angle(index, fields.scan_angle)?;
863        validate_point_flags(index, &fields)?;
864        if fields.extra_bytes.len() != extra_byte_count {
865            return Err(Error::InvalidInput(format!(
866                "point {index} has {} extra byte(s), expected {extra_byte_count}",
867                fields.extra_bytes.len()
868            )));
869        }
870        stats.record(index, &fields)?;
871    }
872    Ok(stats)
873}
874
875fn validate_point_flags(index: usize, fields: &CopcPointFields) -> Result<()> {
876    validate_point_field_range(index, "return_number", fields.return_number, 0, 15)?;
877    validate_point_field_range(index, "number_of_returns", fields.number_of_returns, 0, 15)?;
878    validate_point_field_range(index, "synthetic", fields.synthetic, 0, 1)?;
879    validate_point_field_range(index, "key_point", fields.key_point, 0, 1)?;
880    validate_point_field_range(index, "withheld", fields.withheld, 0, 1)?;
881    validate_point_field_range(index, "overlap", fields.overlap, 0, 1)?;
882    validate_point_field_range(index, "scan_channel", fields.scan_channel, 0, 3)?;
883    validate_point_field_range(
884        index,
885        "scan_direction_flag",
886        fields.scan_direction_flag,
887        0,
888        1,
889    )?;
890    validate_point_field_range(
891        index,
892        "edge_of_flight_line",
893        fields.edge_of_flight_line,
894        0,
895        1,
896    )
897}
898
899fn validate_scan_angle(index: usize, value: f32) -> Result<()> {
900    if !value.is_finite() {
901        return Err(Error::InvalidInput(format!(
902            "point {index} scan_angle must be finite, got {value}"
903        )));
904    }
905    let scaled = value / LAS_14_SCAN_ANGLE_SCALE;
906    if scaled < f32::from(i16::MIN) || scaled > f32::from(i16::MAX) {
907        return Err(Error::InvalidInput(format!(
908            "point {index} scan_angle {value} encodes to {scaled}, outside LAS i16 range"
909        )));
910    }
911    Ok(())
912}
913
914fn validate_point_field_range(index: usize, name: &str, value: u8, min: u8, max: u8) -> Result<()> {
915    if (min..=max).contains(&value) {
916        Ok(())
917    } else {
918        Err(Error::InvalidInput(format!(
919            "point {index} {name} must be in {min}..={max}, got {value}"
920        )))
921    }
922}
923
924fn validate_bounds(bounds: Bounds) -> Result<()> {
925    validate_finite_value("bounds min x", bounds.min.0)?;
926    validate_finite_value("bounds min y", bounds.min.1)?;
927    validate_finite_value("bounds min z", bounds.min.2)?;
928    validate_finite_value("bounds max x", bounds.max.0)?;
929    validate_finite_value("bounds max y", bounds.max.1)?;
930    validate_finite_value("bounds max z", bounds.max.2)?;
931    for (axis, min, max) in [
932        ("x", bounds.min.0, bounds.max.0),
933        ("y", bounds.min.1, bounds.max.1),
934        ("z", bounds.min.2, bounds.max.2),
935    ] {
936        if min > max {
937            return Err(Error::InvalidInput(format!(
938                "bounds {axis} min {min} exceeds max {max}"
939            )));
940        }
941        validate_finite_value(&format!("bounds {axis} span"), max - min)?;
942    }
943    Ok(())
944}
945
946fn validate_transform(scale: (f64, f64, f64), offset: (f64, f64, f64)) -> Result<()> {
947    for (axis, value) in [("x", scale.0), ("y", scale.1), ("z", scale.2)] {
948        if !value.is_finite() || value <= 0.0 {
949            return Err(Error::InvalidInput(format!(
950                "LAS {axis} scale must be finite and positive, got {value}"
951            )));
952        }
953    }
954    validate_finite_value("LAS x offset", offset.0)?;
955    validate_finite_value("LAS y offset", offset.1)?;
956    validate_finite_value("LAS z offset", offset.2)?;
957    Ok(())
958}
959
960fn validate_xyz_finite(index: usize, x: f64, y: f64, z: f64) -> Result<()> {
961    validate_point_axis_finite(index, "x", x)?;
962    validate_point_axis_finite(index, "y", y)?;
963    validate_point_axis_finite(index, "z", z)
964}
965
966fn validate_point_axis_finite(index: usize, axis: &str, value: f64) -> Result<()> {
967    if value.is_finite() {
968        Ok(())
969    } else {
970        Err(Error::InvalidInput(format!(
971            "point {index} {axis} coordinate must be finite, got {value}"
972        )))
973    }
974}
975
976fn validate_finite_value(name: &str, value: f64) -> Result<()> {
977    if value.is_finite() {
978        Ok(())
979    } else {
980        Err(Error::InvalidInput(format!(
981            "{name} must be finite, got {value}"
982        )))
983    }
984}
985
986fn quantize_xyz(
987    index: usize,
988    x: f64,
989    y: f64,
990    z: f64,
991    scale: (f64, f64, f64),
992    offset: (f64, f64, f64),
993) -> Result<(i32, i32, i32)> {
994    Ok((
995        quantize_axis(index, "x", x, scale.0, offset.0)?,
996        quantize_axis(index, "y", y, scale.1, offset.1)?,
997        quantize_axis(index, "z", z, scale.2, offset.2)?,
998    ))
999}
1000
1001fn quantize_axis(index: usize, axis: &str, value: f64, scale: f64, offset: f64) -> Result<i32> {
1002    let scaled = ((value - offset) / scale).round();
1003    if !scaled.is_finite() {
1004        return Err(Error::InvalidInput(format!(
1005            "point {index} {axis} coordinate cannot be encoded with scale {scale} and offset {offset}"
1006        )));
1007    }
1008    if scaled < f64::from(i32::MIN) || scaled > f64::from(i32::MAX) {
1009        return Err(Error::InvalidInput(format!(
1010            "point {index} {axis} coordinate {value} encodes to {scaled}, outside LAS i32 range"
1011        )));
1012    }
1013    Ok(scaled as i32)
1014}
1015
1016fn write_copc_from_spill(
1017    path: &Path,
1018    reader: SpillReader,
1019    params: &CopcWriterParams,
1020    cancel: &dyn CancelCheck,
1021    metadata: &OutputLasMetadata,
1022) -> Result<()> {
1023    cancel.check()?;
1024    validate_streaming_layout_supported(reader.layout())?;
1025    if reader.is_empty() {
1026        return Err(Error::InvalidInput(
1027            "cannot write empty cloud to COPC".into(),
1028        ));
1029    }
1030    let has_color = reader.layout().has_color;
1031    let bounds = reader.bounds();
1032    let source = SpillSource { reader: &reader };
1033    write_copc_inner(path, &source, has_color, bounds, params, cancel, metadata)
1034}
1035
1036fn write_copc_inner<S: CopcPointSource>(
1037    path: &Path,
1038    source: &S,
1039    has_color: bool,
1040    bounds: Bounds,
1041    params: &CopcWriterParams,
1042    cancel: &dyn CancelCheck,
1043    metadata: &OutputLasMetadata,
1044) -> Result<()> {
1045    cancel.check()?;
1046    let point_format_id = if has_color { 7u8 } else { 6u8 };
1047    let mut point_format =
1048        LasFormat::new(point_format_id).map_err(|e| Error::Las(format!("point format: {e}")))?;
1049    let extra_byte_count = source.extra_byte_count();
1050    point_format.extra_bytes = extra_byte_count;
1051    let point_record_length = point_format.len();
1052
1053    let (scale_x, scale_y, scale_z) = metadata.scale;
1054    let (offset_x, offset_y, offset_z) =
1055        metadata
1056            .offset
1057            .unwrap_or((bounds.min.0, bounds.min.1, bounds.min.2));
1058    let point_stats = validate_coordinate_inputs(
1059        source,
1060        bounds,
1061        (scale_x, scale_y, scale_z),
1062        (offset_x, offset_y, offset_z),
1063        cancel,
1064    )?;
1065    let (center, halfsize) = cube_from_bounds(&bounds);
1066
1067    let lod_index = build_lod_index(source, center, halfsize, params, cancel)?;
1068    cancel.check()?;
1069
1070    let var_vlr = LazVlrBuilder::default()
1071        .with_point_format(point_format_id, extra_byte_count)
1072        .map_err(|e| Error::Las(format!("laz items: {e}")))?
1073        .with_variable_chunk_size()
1074        .build();
1075    let mut var_vlr_bytes = Vec::new();
1076    var_vlr
1077        .write_to(&mut var_vlr_bytes)
1078        .map_err(|e| Error::Las(format!("variable chunk LAZ VLR: {e}")))?;
1079
1080    let copc_info_vlr_size = 160u16;
1081    let las_header_size = 375u32;
1082    let regular_crs_vlr_count = metadata.regular_crs_vlr_count();
1083    let regular_crs_vlr_bytes = metadata.regular_crs_vlr_bytes()?;
1084    let extra_bytes_vlrs = source.extra_bytes_vlrs();
1085    let extra_bytes_vlr_bytes = regular_las_vlrs_bytes(extra_bytes_vlrs)?;
1086    let pass_through_vlr_bytes = regular_las_vlrs_bytes(&metadata.pass_through_vlrs)?;
1087    let number_of_vlrs = u32::try_from(
1088        2usize
1089            .checked_add(regular_crs_vlr_count)
1090            .and_then(|count| count.checked_add(extra_bytes_vlrs.len()))
1091            .and_then(|count| count.checked_add(metadata.pass_through_vlrs.len()))
1092            .ok_or_else(|| Error::InvalidInput("VLR count overflow".into()))?,
1093    )
1094    .map_err(|_| Error::InvalidInput("VLR count overflow".into()))?;
1095    let number_of_evlrs = u32::try_from(
1096        1usize
1097            .checked_add(metadata.source_evlr_count_after_hierarchy())
1098            .ok_or_else(|| Error::InvalidInput("EVLR count overflow".into()))?,
1099    )
1100    .map_err(|_| Error::InvalidInput("EVLR count overflow".into()))?;
1101    let var_vlr_body_size = u16::try_from(var_vlr_bytes.len())
1102        .map_err(|_| Error::InvalidInput("LAZ VLR byte size exceeds LAS VLR limit".into()))?;
1103    let var_vlr_storage_bytes = LAS_VLR_HEADER_BYTES
1104        .checked_add(u32::from(var_vlr_body_size))
1105        .ok_or_else(|| Error::InvalidInput("LAZ VLR byte size overflow".into()))?;
1106    let total_vlr_bytes = LAS_VLR_HEADER_BYTES
1107        .checked_add(u32::from(copc_info_vlr_size))
1108        .and_then(|total| total.checked_add(var_vlr_storage_bytes))
1109        .and_then(|total| total.checked_add(regular_crs_vlr_bytes))
1110        .and_then(|total| total.checked_add(extra_bytes_vlr_bytes))
1111        .and_then(|total| total.checked_add(pass_through_vlr_bytes))
1112        .ok_or_else(|| Error::InvalidInput("VLR byte size overflow".into()))?;
1113    let offset_to_point_data = las_header_size
1114        .checked_add(total_vlr_bytes)
1115        .ok_or_else(|| Error::InvalidInput("point data offset overflow".into()))?;
1116
1117    let file = File::create(path).map_err(|e| Error::io("create COPC file", e))?;
1118    let mut writer = BufWriter::new(file);
1119
1120    let header = LasHeader {
1121        point_data_format: point_format_id | 0x80,
1122        point_record_length,
1123        offset_to_point_data,
1124        number_of_vlrs,
1125        file_source_id: metadata.file_source_id,
1126        global_encoding: metadata.global_encoding,
1127        guid: metadata.guid,
1128        system_identifier: metadata.system_identifier.clone(),
1129        generating_software: metadata.generating_software.clone(),
1130        creation_day_of_year: metadata.creation_day_of_year,
1131        creation_year: metadata.creation_year,
1132        scale: (scale_x, scale_y, scale_z),
1133        offset: (offset_x, offset_y, offset_z),
1134        bounds,
1135        legacy_point_count: 0,
1136        total_point_count: source.len() as u64,
1137        offset_to_first_evlr: 0,
1138        number_of_evlrs,
1139        extended_return_counts: point_stats.extended_return_counts,
1140    };
1141    header.write(&mut writer)?;
1142
1143    write_vlr_header(&mut writer, "copc", 1, copc_info_vlr_size, "COPC info")?;
1144    let copc_info_payload_start = writer
1145        .stream_position()
1146        .map_err(|e| Error::io("record COPC info payload offset", e))?;
1147    writer
1148        .write_all(&[0u8; 160])
1149        .map_err(|e| Error::io("write COPC info placeholder", e))?;
1150
1151    write_vlr_header(
1152        &mut writer,
1153        LASZIP_VLR_USER_ID,
1154        LASZIP_VLR_RECORD_ID,
1155        var_vlr_body_size,
1156        "http://laszip.org",
1157    )?;
1158    writer
1159        .write_all(&var_vlr_bytes)
1160        .map_err(|e| Error::io("write LAZ VLR", e))?;
1161
1162    for vlr in metadata.regular_crs_vlrs() {
1163        write_las_vlr(&mut writer, vlr)?;
1164    }
1165    for vlr in extra_bytes_vlrs {
1166        write_las_vlr(&mut writer, vlr)?;
1167    }
1168    for vlr in &metadata.pass_through_vlrs {
1169        write_las_vlr(&mut writer, vlr)?;
1170    }
1171
1172    let point_data_actual_start = writer
1173        .stream_position()
1174        .map_err(|e| Error::io("record point data offset", e))?;
1175    if point_data_actual_start as u32 != offset_to_point_data {
1176        return Err(Error::InvalidInput(format!(
1177            "VLR size accounting mismatch: at {point_data_actual_start}, expected {offset_to_point_data}"
1178        )));
1179    }
1180
1181    let mut compressor = LasZipCompressor::new(&mut writer, var_vlr.clone())
1182        .map_err(|e| Error::Las(format!("compressor: {e}")))?;
1183    let mut hierarchy: Vec<Entry> = Vec::with_capacity(lod_index.nodes.len());
1184    let order_path: &Path = lod_index.order_path.as_ref();
1185    let mut index_reader =
1186        BufReader::new(File::open(order_path).map_err(|e| Error::io("open LOD order", e))?);
1187    let mut point_buf = vec![0u8; point_record_length as usize];
1188    let mut chunk_start_file_offset = compressor
1189        .get_mut()
1190        .stream_position()
1191        .map_err(|e| Error::io("record chunk start", e))?;
1192    chunk_start_file_offset += 8;
1193
1194    for node in &lod_index.nodes {
1195        cancel.check()?;
1196        index_reader
1197            .seek(SeekFrom::Start(node.start))
1198            .map_err(|e| Error::io("seek LOD order", e))?;
1199        for point_index in 0..node.count {
1200            if point_index % CANCEL_POLL_STRIDE == 0 {
1201                cancel.check()?;
1202            }
1203            let source_index = index_reader
1204                .read_u32::<LittleEndian>()
1205                .map_err(|e| Error::io("read LOD order", e))?
1206                as usize;
1207            let fields = source.fields(source_index as usize)?;
1208            encode_point_record(
1209                &mut point_buf,
1210                &fields,
1211                (scale_x, scale_y, scale_z),
1212                (offset_x, offset_y, offset_z),
1213                source_index as usize,
1214                &point_format,
1215            )?;
1216            compressor
1217                .compress_one(&point_buf)
1218                .map_err(|e| Error::Las(format!("compress point: {e}")))?;
1219        }
1220        compressor
1221            .finish_current_chunk()
1222            .map_err(|e| Error::Las(format!("finish chunk: {e}")))?;
1223        let after = compressor
1224            .get_mut()
1225            .stream_position()
1226            .map_err(|e| Error::io("record chunk end", e))?;
1227        let byte_size = i32::try_from(after - chunk_start_file_offset)
1228            .map_err(|_| Error::InvalidInput("LAZ chunk exceeds COPC i32 byte size".into()))?;
1229        let point_count = i32::try_from(node.count)
1230            .map_err(|_| Error::InvalidInput("node point count exceeds COPC i32 range".into()))?;
1231        hierarchy.push(Entry {
1232            key: node.key,
1233            offset: chunk_start_file_offset,
1234            byte_size,
1235            point_count,
1236        });
1237        chunk_start_file_offset = after;
1238    }
1239
1240    cancel.check()?;
1241    compressor
1242        .done()
1243        .map_err(|e| Error::Las(format!("finish compressor: {e}")))?;
1244    drop(compressor);
1245
1246    let hierarchy_evlr_start = writer
1247        .stream_position()
1248        .map_err(|e| Error::io("record hierarchy EVLR start", e))?;
1249    let root_hier_offset = hierarchy_evlr_start
1250        .checked_add(LAS_EVLR_HEADER_BYTES)
1251        .ok_or_else(|| Error::InvalidInput("hierarchy EVLR offset overflow".into()))?;
1252    let mut hierarchy_pages = plan_hierarchy_pages(&hierarchy, VoxelKey::root())?;
1253    let hierarchy_end = assign_hierarchy_page_offsets(&mut hierarchy_pages, root_hier_offset)?;
1254    let hierarchy_body_size = hierarchy_end
1255        .checked_sub(root_hier_offset)
1256        .ok_or_else(|| Error::InvalidInput("hierarchy size overflow".into()))?;
1257    write_evlr_header(
1258        &mut writer,
1259        "copc",
1260        1000,
1261        hierarchy_body_size,
1262        "COPC hierarchy",
1263    )?;
1264    let actual_root_hier_offset = writer
1265        .stream_position()
1266        .map_err(|e| Error::io("record root hierarchy offset", e))?;
1267    if actual_root_hier_offset != root_hier_offset {
1268        return Err(Error::InvalidInput(format!(
1269            "hierarchy offset accounting mismatch: at {actual_root_hier_offset}, expected {root_hier_offset}"
1270        )));
1271    }
1272    write_hierarchy_page_tree(&mut writer, &hierarchy_pages)?;
1273    for evlr in metadata.source_evlrs_after_hierarchy() {
1274        write_las_evlr(&mut writer, evlr)?;
1275    }
1276
1277    writer
1278        .seek(SeekFrom::Start(copc_info_payload_start))
1279        .map_err(|e| Error::io("seek COPC info payload", e))?;
1280    let info = CopcInfo {
1281        center,
1282        halfsize,
1283        spacing: halfsize / 128.0,
1284        root_hier_offset,
1285        root_hier_size: hierarchy_pages.byte_size,
1286        gpstime_min: point_stats.gpstime_min,
1287        gpstime_max: point_stats.gpstime_max,
1288    };
1289    writer
1290        .write_all(&info.write_le_bytes())
1291        .map_err(|e| Error::io("patch COPC info", e))?;
1292
1293    writer
1294        .seek(SeekFrom::Start(235))
1295        .map_err(|e| Error::io("seek first EVLR offset", e))?;
1296    writer
1297        .write_u64::<LittleEndian>(hierarchy_evlr_start)
1298        .map_err(|e| Error::io("patch first EVLR offset", e))?;
1299
1300    writer
1301        .flush()
1302        .map_err(|e| Error::io("flush COPC file", e))?;
1303    Ok(())
1304}
1305
1306#[derive(Debug)]
1307struct HierarchyPagePlan {
1308    key: VoxelKey,
1309    items: Vec<HierarchyPageItem>,
1310    offset: u64,
1311    byte_size: u64,
1312}
1313
1314#[derive(Debug)]
1315enum HierarchyPageItem {
1316    Point(Entry),
1317    Child(Box<HierarchyPagePlan>),
1318}
1319
1320fn plan_hierarchy_pages(entries: &[Entry], key: VoxelKey) -> Result<HierarchyPagePlan> {
1321    if entries.is_empty() {
1322        return Err(Error::InvalidInput(
1323            "cannot write empty hierarchy page".into(),
1324        ));
1325    }
1326    if entries.len() <= HIERARCHY_PAGE_MAX_ENTRIES {
1327        return Ok(HierarchyPagePlan {
1328            key,
1329            items: entries
1330                .iter()
1331                .copied()
1332                .map(HierarchyPageItem::Point)
1333                .collect(),
1334            offset: 0,
1335            byte_size: 0,
1336        });
1337    }
1338
1339    let mut point_entry = None;
1340    let mut child_entries: [Vec<Entry>; 8] = std::array::from_fn(|_| Vec::new());
1341    for entry in entries.iter().copied() {
1342        if entry.key == key {
1343            point_entry = Some(entry);
1344            continue;
1345        }
1346        let mut matched = false;
1347        for (octant, child_entries) in child_entries.iter_mut().enumerate() {
1348            let child_key = key.child(octant as u8);
1349            if key_contains(child_key, entry.key) {
1350                child_entries.push(entry);
1351                matched = true;
1352                break;
1353            }
1354        }
1355        if !matched {
1356            return Err(Error::InvalidInput(format!(
1357                "hierarchy entry {:?} is not under page key {:?}",
1358                entry.key, key
1359            )));
1360        }
1361    }
1362
1363    let mut items = Vec::new();
1364    if let Some(entry) = point_entry {
1365        items.push(HierarchyPageItem::Point(entry));
1366    }
1367    for (octant, child_entries) in child_entries.into_iter().enumerate() {
1368        if child_entries.is_empty() {
1369            continue;
1370        }
1371        items.push(HierarchyPageItem::Child(Box::new(plan_hierarchy_pages(
1372            &child_entries,
1373            key.child(octant as u8),
1374        )?)));
1375    }
1376    if items.len() > HIERARCHY_PAGE_MAX_ENTRIES {
1377        return Err(Error::InvalidInput(format!(
1378            "hierarchy page for {:?} has {} entries, max is {}",
1379            key,
1380            items.len(),
1381            HIERARCHY_PAGE_MAX_ENTRIES
1382        )));
1383    }
1384    Ok(HierarchyPagePlan {
1385        key,
1386        items,
1387        offset: 0,
1388        byte_size: 0,
1389    })
1390}
1391
1392fn assign_hierarchy_page_offsets(page: &mut HierarchyPagePlan, offset: u64) -> Result<u64> {
1393    page.offset = offset;
1394    page.byte_size = hierarchy_page_byte_size(page.items.len())?;
1395    let mut next = offset
1396        .checked_add(page.byte_size)
1397        .ok_or_else(|| Error::InvalidInput("hierarchy page offset overflow".into()))?;
1398    for item in &mut page.items {
1399        if let HierarchyPageItem::Child(child) = item {
1400            next = assign_hierarchy_page_offsets(child, next)?;
1401        }
1402    }
1403    Ok(next)
1404}
1405
1406fn hierarchy_page_byte_size(entry_count: usize) -> Result<u64> {
1407    let bytes = entry_count
1408        .checked_mul(HIERARCHY_ENTRY_BYTES)
1409        .ok_or_else(|| Error::InvalidInput("hierarchy page size overflow".into()))?;
1410    u64::try_from(bytes).map_err(|_| Error::InvalidInput("hierarchy page is too large".into()))
1411}
1412
1413fn write_hierarchy_page_tree<W: Write + Seek>(
1414    writer: &mut W,
1415    page: &HierarchyPagePlan,
1416) -> Result<()> {
1417    let position = writer
1418        .stream_position()
1419        .map_err(|e| Error::io("record hierarchy page offset", e))?;
1420    if position != page.offset {
1421        return Err(Error::InvalidInput(format!(
1422            "hierarchy page offset mismatch: at {position}, expected {}",
1423            page.offset
1424        )));
1425    }
1426    let mut entry_buf = [0u8; HIERARCHY_ENTRY_BYTES];
1427    for item in &page.items {
1428        hierarchy_page_item_entry(item)?.write_le(&mut entry_buf)?;
1429        writer
1430            .write_all(&entry_buf)
1431            .map_err(|e| Error::io("write hierarchy entry", e))?;
1432    }
1433    for item in &page.items {
1434        if let HierarchyPageItem::Child(child) = item {
1435            write_hierarchy_page_tree(writer, child)?;
1436        }
1437    }
1438    Ok(())
1439}
1440
1441fn hierarchy_page_item_entry(item: &HierarchyPageItem) -> Result<Entry> {
1442    match item {
1443        HierarchyPageItem::Point(entry) => Ok(*entry),
1444        HierarchyPageItem::Child(child) => Ok(Entry {
1445            key: child.key,
1446            offset: child.offset,
1447            byte_size: i32::try_from(child.byte_size).map_err(|_| {
1448                Error::InvalidInput("child hierarchy page exceeds COPC i32 byte size".into())
1449            })?,
1450            point_count: -1,
1451        }),
1452    }
1453}
1454
1455fn key_contains(ancestor: VoxelKey, key: VoxelKey) -> bool {
1456    if key.level < ancestor.level {
1457        return false;
1458    }
1459    let shift = (key.level - ancestor.level) as u32;
1460    (key.x >> shift) == ancestor.x
1461        && (key.y >> shift) == ancestor.y
1462        && (key.z >> shift) == ancestor.z
1463}
1464
1465struct LodIndex {
1466    nodes: Vec<LodNodeRange>,
1467    order_path: TempPath,
1468}
1469
1470#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1471struct LodNodeRange {
1472    key: VoxelKey,
1473    start: u64,
1474    count: usize,
1475}
1476
1477struct IndexRun {
1478    path: TempPath,
1479    start: u64,
1480    count: usize,
1481}
1482
1483fn build_lod_index<S: CopcPointSource>(
1484    source: &S,
1485    center: (f64, f64, f64),
1486    halfsize: f64,
1487    params: &CopcWriterParams,
1488    cancel: &dyn CancelCheck,
1489) -> Result<LodIndex> {
1490    cancel.check()?;
1491    let total_points = u32::try_from(source.len()).map_err(|_| {
1492        Error::InvalidInput("COPC writer supports at most u32::MAX points per file".into())
1493    })?;
1494    let max_points_per_node = params.max_points_per_node.max(1) as usize;
1495    let max_depth = params.max_depth.clamp(MIN_LEAF_DEPTH, MAX_LEAF_DEPTH);
1496    let root_run = write_root_index_run(total_points, cancel)?;
1497    let mut order_file = new_index_tempfile("order")?;
1498    let mut order_offset = 0;
1499    let mut nodes = Vec::new();
1500    {
1501        let mut order_writer = BufWriter::new(order_file.as_file_mut());
1502        let mut builder = LodIndexBuilder {
1503            source,
1504            max_points_per_node,
1505            max_depth,
1506            cancel,
1507            order_writer: &mut order_writer,
1508            order_offset: &mut order_offset,
1509            nodes: &mut nodes,
1510        };
1511        builder.assign(VoxelKey::root(), root_run, Bounds::cube(center, halfsize))?;
1512        order_writer
1513            .flush()
1514            .map_err(|e| Error::io("flush LOD index order", e))?;
1515    }
1516    nodes.sort_by_key(|node| node.key);
1517    Ok(LodIndex {
1518        nodes,
1519        order_path: order_file.into_temp_path(),
1520    })
1521}
1522
1523struct LodIndexBuilder<'a, S: CopcPointSource, W: Write> {
1524    source: &'a S,
1525    max_points_per_node: usize,
1526    max_depth: u32,
1527    cancel: &'a dyn CancelCheck,
1528    order_writer: &'a mut W,
1529    order_offset: &'a mut u64,
1530    nodes: &'a mut Vec<LodNodeRange>,
1531}
1532
1533impl<S: CopcPointSource, W: Write> LodIndexBuilder<'_, S, W> {
1534    fn assign(&mut self, key: VoxelKey, run: IndexRun, bounds: Bounds) -> Result<()> {
1535        self.cancel.check()?;
1536        if run.count == 0 {
1537            return Ok(());
1538        }
1539        if run.count <= self.max_points_per_node || key.level as u32 >= self.max_depth {
1540            let start = *self.order_offset;
1541            append_index_run_to_order(&run, self.order_writer, self.order_offset, self.cancel)?;
1542            self.nodes.push(LodNodeRange {
1543                key,
1544                start,
1545                count: run.count,
1546            });
1547            return Ok(());
1548        }
1549
1550        let mut children = partition_index_run(self.source, &run, bounds, self.cancel)?;
1551        let start = *self.order_offset;
1552        let selected_counts = append_lod_selection_to_order(
1553            &children,
1554            self.max_points_per_node,
1555            self.order_writer,
1556            self.order_offset,
1557            self.cancel,
1558        )?;
1559        let selected_total = selected_counts.iter().sum();
1560        self.nodes.push(LodNodeRange {
1561            key,
1562            start,
1563            count: selected_total,
1564        });
1565
1566        for (octant, child) in children.iter_mut().enumerate() {
1567            let Some(mut child_run) = child.take() else {
1568                continue;
1569            };
1570            let selected = selected_counts[octant];
1571            if selected >= child_run.count {
1572                continue;
1573            }
1574            child_run.start += selected as u64 * INDEX_RECORD_BYTES;
1575            child_run.count -= selected;
1576            self.assign(
1577                key.child(octant as u8),
1578                child_run,
1579                bounds.octant(octant as u8),
1580            )?;
1581        }
1582        Ok(())
1583    }
1584}
1585
1586fn write_root_index_run(total_points: u32, cancel: &dyn CancelCheck) -> Result<IndexRun> {
1587    let mut writer = BufWriter::new(new_index_tempfile("root")?);
1588    for index in 0..total_points {
1589        if index as usize % CANCEL_POLL_STRIDE == 0 {
1590            cancel.check()?;
1591        }
1592        writer
1593            .write_u32::<LittleEndian>(index)
1594            .map_err(|e| Error::io("write root LOD index", e))?;
1595    }
1596    let file = writer
1597        .into_inner()
1598        .map_err(|e| Error::io("flush root LOD index", e.into_error()))?;
1599    Ok(IndexRun {
1600        path: file.into_temp_path(),
1601        start: 0,
1602        count: total_points as usize,
1603    })
1604}
1605
1606fn partition_index_run<S: CopcPointSource>(
1607    source: &S,
1608    run: &IndexRun,
1609    bounds: Bounds,
1610    cancel: &dyn CancelCheck,
1611) -> Result<[Option<IndexRun>; 8]> {
1612    let mut reader = open_index_run(run)?;
1613    let mut writers: [Option<BufWriter<NamedTempFile>>; 8] = std::array::from_fn(|_| None);
1614    let mut counts = [0usize; 8];
1615    for read_index in 0..run.count {
1616        if read_index % CANCEL_POLL_STRIDE == 0 {
1617            cancel.check()?;
1618        }
1619        let index = reader
1620            .read_u32::<LittleEndian>()
1621            .map_err(|e| Error::io("read LOD partition index", e))?;
1622        let (x, y, z) = source.xyz(index as usize);
1623        let octant = child_octant(bounds, x, y, z);
1624        if writers[octant].is_none() {
1625            writers[octant] = Some(BufWriter::new(new_index_tempfile("partition")?));
1626        }
1627        writers[octant]
1628            .as_mut()
1629            .expect("partition writer exists")
1630            .write_u32::<LittleEndian>(index)
1631            .map_err(|e| Error::io("write LOD partition index", e))?;
1632        counts[octant] += 1;
1633    }
1634
1635    let mut children: [Option<IndexRun>; 8] = std::array::from_fn(|_| None);
1636    for octant in 0..8 {
1637        let Some(writer) = writers[octant].take() else {
1638            continue;
1639        };
1640        let file = writer
1641            .into_inner()
1642            .map_err(|e| Error::io("flush LOD partition index", e.into_error()))?;
1643        children[octant] = Some(IndexRun {
1644            path: file.into_temp_path(),
1645            start: 0,
1646            count: counts[octant],
1647        });
1648    }
1649    Ok(children)
1650}
1651
1652fn append_lod_selection_to_order<W: Write>(
1653    children: &[Option<IndexRun>; 8],
1654    max_points_per_node: usize,
1655    order_writer: &mut W,
1656    order_offset: &mut u64,
1657    cancel: &dyn CancelCheck,
1658) -> Result<[usize; 8]> {
1659    let mut readers: [Option<BufReader<File>>; 8] = std::array::from_fn(|_| None);
1660    for octant in 0..8 {
1661        if let Some(child) = &children[octant] {
1662            readers[octant] = Some(open_index_run(child)?);
1663        }
1664    }
1665
1666    let mut selected_counts = [0usize; 8];
1667    let mut selected_total = 0usize;
1668    while selected_total < max_points_per_node {
1669        cancel.check()?;
1670        let mut progressed = false;
1671        for octant in 0..8 {
1672            let Some(child) = &children[octant] else {
1673                continue;
1674            };
1675            if selected_counts[octant] >= child.count {
1676                continue;
1677            }
1678            let index = readers[octant]
1679                .as_mut()
1680                .expect("partition reader exists")
1681                .read_u32::<LittleEndian>()
1682                .map_err(|e| Error::io("read selected LOD index", e))?;
1683            append_index_to_order(order_writer, order_offset, index)?;
1684            selected_counts[octant] += 1;
1685            selected_total += 1;
1686            progressed = true;
1687            if selected_total == max_points_per_node {
1688                break;
1689            }
1690        }
1691        if !progressed {
1692            break;
1693        }
1694    }
1695    Ok(selected_counts)
1696}
1697
1698fn append_index_run_to_order<W: Write>(
1699    run: &IndexRun,
1700    order_writer: &mut W,
1701    order_offset: &mut u64,
1702    cancel: &dyn CancelCheck,
1703) -> Result<()> {
1704    let mut reader = open_index_run(run)?;
1705    for read_index in 0..run.count {
1706        if read_index % CANCEL_POLL_STRIDE == 0 {
1707            cancel.check()?;
1708        }
1709        let index = reader
1710            .read_u32::<LittleEndian>()
1711            .map_err(|e| Error::io("read LOD index", e))?;
1712        append_index_to_order(order_writer, order_offset, index)?;
1713    }
1714    Ok(())
1715}
1716
1717fn append_index_to_order<W: Write>(
1718    order_writer: &mut W,
1719    order_offset: &mut u64,
1720    index: u32,
1721) -> Result<()> {
1722    order_writer
1723        .write_u32::<LittleEndian>(index)
1724        .map_err(|e| Error::io("write LOD index order", e))?;
1725    *order_offset = order_offset
1726        .checked_add(INDEX_RECORD_BYTES)
1727        .ok_or_else(|| Error::InvalidInput("LOD index order exceeds u64 range".into()))?;
1728    Ok(())
1729}
1730
1731fn open_index_run(run: &IndexRun) -> Result<BufReader<File>> {
1732    let path: &Path = run.path.as_ref();
1733    let mut file = File::open(path).map_err(|e| Error::io("open LOD index", e))?;
1734    file.seek(SeekFrom::Start(run.start))
1735        .map_err(|e| Error::io("seek LOD index", e))?;
1736    Ok(BufReader::new(file))
1737}
1738
1739fn new_index_tempfile(label: &str) -> Result<NamedTempFile> {
1740    let prefix = format!(".copc-writer-{label}.");
1741    tempfile::Builder::new()
1742        .prefix(&prefix)
1743        .suffix(".idx")
1744        .tempfile()
1745        .map_err(|e| Error::io("create LOD index file", e))
1746}
1747
1748fn child_octant(bounds: Bounds, x: f64, y: f64, z: f64) -> usize {
1749    let center = bounds.center();
1750    usize::from(x >= center.0)
1751        | (usize::from(y >= center.1) << 1)
1752        | (usize::from(z >= center.2) << 2)
1753}
1754
1755fn cube_from_bounds(bounds: &Bounds) -> ((f64, f64, f64), f64) {
1756    let dx = bounds.max.0 - bounds.min.0;
1757    let dy = bounds.max.1 - bounds.min.1;
1758    let dz = bounds.max.2 - bounds.min.2;
1759    let center = (
1760        bounds.min.0 + dx * 0.5,
1761        bounds.min.1 + dy * 0.5,
1762        bounds.min.2 + dz * 0.5,
1763    );
1764    let halfsize = (dx.max(dy).max(dz) * 0.5).max(1e-6);
1765    (center, halfsize)
1766}
1767
1768struct LasHeader {
1769    point_data_format: u8,
1770    point_record_length: u16,
1771    offset_to_point_data: u32,
1772    number_of_vlrs: u32,
1773    file_source_id: u16,
1774    global_encoding: u16,
1775    guid: [u8; 16],
1776    system_identifier: String,
1777    generating_software: String,
1778    creation_day_of_year: u16,
1779    creation_year: u16,
1780    scale: (f64, f64, f64),
1781    offset: (f64, f64, f64),
1782    bounds: Bounds,
1783    legacy_point_count: u32,
1784    total_point_count: u64,
1785    offset_to_first_evlr: u64,
1786    number_of_evlrs: u32,
1787    extended_return_counts: [u64; 15],
1788}
1789
1790impl LasHeader {
1791    fn write<W: Write>(&self, writer: &mut W) -> Result<()> {
1792        writer
1793            .write_all(b"LASF")
1794            .map_err(|e| Error::io("write LAS signature", e))?;
1795        writer
1796            .write_u16::<LittleEndian>(self.file_source_id)
1797            .map_err(|e| Error::io("write file source id", e))?;
1798        writer
1799            .write_u16::<LittleEndian>(self.global_encoding)
1800            .map_err(|e| Error::io("write global encoding", e))?;
1801        writer
1802            .write_all(&self.guid)
1803            .map_err(|e| Error::io("write GUID", e))?;
1804        writer
1805            .write_u8(1)
1806            .map_err(|e| Error::io("write version major", e))?;
1807        writer
1808            .write_u8(4)
1809            .map_err(|e| Error::io("write version minor", e))?;
1810        writer
1811            .write_all(&pad(self.system_identifier.as_bytes(), 32))
1812            .map_err(|e| Error::io("write system id", e))?;
1813        writer
1814            .write_all(&pad(self.generating_software.as_bytes(), 32))
1815            .map_err(|e| Error::io("write generating software", e))?;
1816        writer
1817            .write_u16::<LittleEndian>(self.creation_day_of_year)
1818            .map_err(|e| Error::io("write creation day", e))?;
1819        writer
1820            .write_u16::<LittleEndian>(self.creation_year)
1821            .map_err(|e| Error::io("write creation year", e))?;
1822        writer
1823            .write_u16::<LittleEndian>(375)
1824            .map_err(|e| Error::io("write header size", e))?;
1825        writer
1826            .write_u32::<LittleEndian>(self.offset_to_point_data)
1827            .map_err(|e| Error::io("write point data offset", e))?;
1828        writer
1829            .write_u32::<LittleEndian>(self.number_of_vlrs)
1830            .map_err(|e| Error::io("write VLR count", e))?;
1831        writer
1832            .write_u8(self.point_data_format)
1833            .map_err(|e| Error::io("write point format", e))?;
1834        writer
1835            .write_u16::<LittleEndian>(self.point_record_length)
1836            .map_err(|e| Error::io("write point record length", e))?;
1837        writer
1838            .write_u32::<LittleEndian>(self.legacy_point_count)
1839            .map_err(|e| Error::io("write legacy point count", e))?;
1840        for _ in 0..5 {
1841            writer
1842                .write_u32::<LittleEndian>(0)
1843                .map_err(|e| Error::io("write legacy returns", e))?;
1844        }
1845        writer
1846            .write_f64::<LittleEndian>(self.scale.0)
1847            .map_err(|e| Error::io("write x scale", e))?;
1848        writer
1849            .write_f64::<LittleEndian>(self.scale.1)
1850            .map_err(|e| Error::io("write y scale", e))?;
1851        writer
1852            .write_f64::<LittleEndian>(self.scale.2)
1853            .map_err(|e| Error::io("write z scale", e))?;
1854        writer
1855            .write_f64::<LittleEndian>(self.offset.0)
1856            .map_err(|e| Error::io("write x offset", e))?;
1857        writer
1858            .write_f64::<LittleEndian>(self.offset.1)
1859            .map_err(|e| Error::io("write y offset", e))?;
1860        writer
1861            .write_f64::<LittleEndian>(self.offset.2)
1862            .map_err(|e| Error::io("write z offset", e))?;
1863        writer
1864            .write_f64::<LittleEndian>(self.bounds.max.0)
1865            .map_err(|e| Error::io("write max x", e))?;
1866        writer
1867            .write_f64::<LittleEndian>(self.bounds.min.0)
1868            .map_err(|e| Error::io("write min x", e))?;
1869        writer
1870            .write_f64::<LittleEndian>(self.bounds.max.1)
1871            .map_err(|e| Error::io("write max y", e))?;
1872        writer
1873            .write_f64::<LittleEndian>(self.bounds.min.1)
1874            .map_err(|e| Error::io("write min y", e))?;
1875        writer
1876            .write_f64::<LittleEndian>(self.bounds.max.2)
1877            .map_err(|e| Error::io("write max z", e))?;
1878        writer
1879            .write_f64::<LittleEndian>(self.bounds.min.2)
1880            .map_err(|e| Error::io("write min z", e))?;
1881        writer
1882            .write_u64::<LittleEndian>(0)
1883            .map_err(|e| Error::io("write waveform packet start", e))?;
1884        writer
1885            .write_u64::<LittleEndian>(self.offset_to_first_evlr)
1886            .map_err(|e| Error::io("write first EVLR offset", e))?;
1887        writer
1888            .write_u32::<LittleEndian>(self.number_of_evlrs)
1889            .map_err(|e| Error::io("write EVLR count", e))?;
1890        writer
1891            .write_u64::<LittleEndian>(self.total_point_count)
1892            .map_err(|e| Error::io("write total point count", e))?;
1893        for count in self.extended_return_counts {
1894            writer
1895                .write_u64::<LittleEndian>(count)
1896                .map_err(|e| Error::io("write extended returns", e))?;
1897        }
1898        Ok(())
1899    }
1900}
1901
1902fn pad(value: &[u8], len: usize) -> Vec<u8> {
1903    let mut out = Vec::with_capacity(len);
1904    let take = value.len().min(len);
1905    out.extend_from_slice(&value[..take]);
1906    out.resize(len, 0);
1907    out
1908}
1909
1910fn write_vlr_header<W: Write>(
1911    writer: &mut W,
1912    user_id: &str,
1913    record_id: u16,
1914    body_size: u16,
1915    description: &str,
1916) -> Result<()> {
1917    writer
1918        .write_u16::<LittleEndian>(0)
1919        .map_err(|e| Error::io("write VLR reserved", e))?;
1920    writer
1921        .write_all(&pad(user_id.as_bytes(), 16))
1922        .map_err(|e| Error::io("write VLR user id", e))?;
1923    writer
1924        .write_u16::<LittleEndian>(record_id)
1925        .map_err(|e| Error::io("write VLR record id", e))?;
1926    writer
1927        .write_u16::<LittleEndian>(body_size)
1928        .map_err(|e| Error::io("write VLR body size", e))?;
1929    writer
1930        .write_all(&pad(description.as_bytes(), 32))
1931        .map_err(|e| Error::io("write VLR description", e))?;
1932    Ok(())
1933}
1934
1935fn write_las_vlr<W: Write>(writer: &mut W, vlr: &las::Vlr) -> Result<()> {
1936    let body_size = u16::try_from(vlr.data.len()).map_err(|_| {
1937        Error::InvalidInput(format!(
1938            "regular VLR {}:{} is too large: {} byte(s)",
1939            vlr.user_id,
1940            vlr.record_id,
1941            vlr.data.len()
1942        ))
1943    })?;
1944    write_vlr_header(
1945        writer,
1946        &vlr.user_id,
1947        vlr.record_id,
1948        body_size,
1949        &vlr.description,
1950    )?;
1951    writer
1952        .write_all(&vlr.data)
1953        .map_err(|e| Error::io("write VLR body", e))?;
1954    Ok(())
1955}
1956
1957fn regular_las_vlrs_bytes(vlrs: &[las::Vlr]) -> Result<u32> {
1958    vlrs.iter().try_fold(0u32, |total, vlr| {
1959        let data_len = u16::try_from(vlr.data.len()).map_err(|_| {
1960            Error::InvalidInput(format!(
1961                "regular VLR {}:{} is too large: {} byte(s)",
1962                vlr.user_id,
1963                vlr.record_id,
1964                vlr.data.len()
1965            ))
1966        })?;
1967        total
1968            .checked_add(LAS_VLR_HEADER_BYTES + u32::from(data_len))
1969            .ok_or_else(|| Error::InvalidInput("VLR byte size overflow".into()))
1970    })
1971}
1972
1973fn write_evlr_header<W: Write>(
1974    writer: &mut W,
1975    user_id: &str,
1976    record_id: u16,
1977    body_size: u64,
1978    description: &str,
1979) -> Result<()> {
1980    writer
1981        .write_u16::<LittleEndian>(0)
1982        .map_err(|e| Error::io("write EVLR reserved", e))?;
1983    writer
1984        .write_all(&pad(user_id.as_bytes(), 16))
1985        .map_err(|e| Error::io("write EVLR user id", e))?;
1986    writer
1987        .write_u16::<LittleEndian>(record_id)
1988        .map_err(|e| Error::io("write EVLR record id", e))?;
1989    writer
1990        .write_u64::<LittleEndian>(body_size)
1991        .map_err(|e| Error::io("write EVLR body size", e))?;
1992    writer
1993        .write_all(&pad(description.as_bytes(), 32))
1994        .map_err(|e| Error::io("write EVLR description", e))?;
1995    Ok(())
1996}
1997
1998fn write_las_evlr<W: Write>(writer: &mut W, vlr: &las::Vlr) -> Result<()> {
1999    let body_size = u64::try_from(vlr.data.len()).map_err(|_| {
2000        Error::InvalidInput(format!(
2001            "EVLR {}:{} is too large: {} byte(s)",
2002            vlr.user_id,
2003            vlr.record_id,
2004            vlr.data.len()
2005        ))
2006    })?;
2007    write_evlr_header(
2008        writer,
2009        &vlr.user_id,
2010        vlr.record_id,
2011        body_size,
2012        &vlr.description,
2013    )?;
2014    writer
2015        .write_all(&vlr.data)
2016        .map_err(|e| Error::io("write EVLR body", e))?;
2017    Ok(())
2018}
2019
2020fn encode_point_record(
2021    buf: &mut [u8],
2022    fields: &CopcPointFields,
2023    scale: (f64, f64, f64),
2024    offset: (f64, f64, f64),
2025    point_index: usize,
2026    format: &LasFormat,
2027) -> Result<()> {
2028    let mut cursor = Cursor::new(buf);
2029    let (ix, iy, iz) = quantize_xyz(point_index, fields.x, fields.y, fields.z, scale, offset)?;
2030    let flags =
2031        fields.synthetic | (fields.key_point << 1) | (fields.withheld << 2) | (fields.overlap << 3);
2032    let point = raw::Point {
2033        x: ix,
2034        y: iy,
2035        z: iz,
2036        intensity: fields.intensity,
2037        flags: raw::point::Flags::ThreeByte(
2038            fields.return_number | (fields.number_of_returns << 4),
2039            flags
2040                | (fields.scan_channel << 4)
2041                | (fields.scan_direction_flag << 6)
2042                | (fields.edge_of_flight_line << 7),
2043            fields.classification,
2044        ),
2045        scan_angle: raw::point::ScanAngle::from(fields.scan_angle),
2046        user_data: fields.user_data,
2047        point_source_id: fields.point_source_id,
2048        gps_time: Some(fields.gps_time),
2049        color: format
2050            .has_color
2051            .then_some(Color::new(fields.red, fields.green, fields.blue)),
2052        waveform: None,
2053        nir: None,
2054        extra_bytes: fields.extra_bytes.clone(),
2055    };
2056    point
2057        .write_to(&mut cursor, format)
2058        .map_err(|e| Error::Las(format!("write point record: {e}")))?;
2059    Ok(())
2060}
2061
2062#[cfg(test)]
2063mod tests {
2064    use super::*;
2065
2066    struct VecSource {
2067        points: Vec<CopcPointFields>,
2068    }
2069
2070    impl CopcPointSource for VecSource {
2071        fn len(&self) -> usize {
2072            self.points.len()
2073        }
2074
2075        fn xyz(&self, index: usize) -> (f64, f64, f64) {
2076            let point = &self.points[index];
2077            (point.x, point.y, point.z)
2078        }
2079
2080        fn fields(&self, index: usize) -> Result<CopcPointFields> {
2081            Ok(self.points[index].clone())
2082        }
2083    }
2084
2085    #[test]
2086    fn spooled_lod_index_covers_each_point_once() {
2087        let points = (0..257)
2088            .map(|i| CopcPointFields {
2089                x: f64::from((i * 37) % 101),
2090                y: f64::from((i * 53) % 103),
2091                z: f64::from((i * 71) % 107),
2092                intensity: 0,
2093                return_number: 1,
2094                number_of_returns: 1,
2095                synthetic: 0,
2096                key_point: 0,
2097                withheld: 0,
2098                overlap: 0,
2099                scan_channel: 0,
2100                scan_direction_flag: 0,
2101                edge_of_flight_line: 0,
2102                classification: 0,
2103                user_data: 0,
2104                scan_angle: 0.0,
2105                point_source_id: 0,
2106                gps_time: f64::from(i),
2107                red: 0,
2108                green: 0,
2109                blue: 0,
2110                extra_bytes: Vec::new(),
2111            })
2112            .collect();
2113        let source = VecSource { points };
2114        let bounds = source_bounds(&source);
2115        let (center, halfsize) = cube_from_bounds(&bounds);
2116        let params = CopcWriterParams {
2117            max_points_per_node: 7,
2118            max_depth: 5,
2119        };
2120
2121        let spooled = build_lod_index(&source, center, halfsize, &params, &NeverCancel).unwrap();
2122        let ranges = read_lod_index(&spooled).unwrap();
2123
2124        let mut seen = vec![false; source.len()];
2125        let mut total = 0usize;
2126        for (key, indices) in ranges {
2127            if key.level < params.max_depth as i32 {
2128                assert!(indices.len() <= params.max_points_per_node as usize);
2129            }
2130            for index in indices {
2131                let seen = &mut seen[index as usize];
2132                assert!(!*seen, "point index {index} was assigned more than once");
2133                *seen = true;
2134                total += 1;
2135            }
2136        }
2137        assert_eq!(source.len(), total);
2138        assert!(seen.into_iter().all(|value| value));
2139    }
2140
2141    #[test]
2142    fn dense_cluster_stays_bounded_below_giant_chunks() {
2143        // A dense cluster inside large bounds: at a shallow `max_depth` the whole
2144        // cluster would collapse into one oversized leaf, forcing the layered LAZ
2145        // compressor to buffer that entire chunk in memory (the multi-GB failure
2146        // mode on real clouds). The writer must keep subdividing dense nodes past
2147        // `max_depth` so no COPC chunk exceeds `max_points_per_node`.
2148        let field = |x: f64, y: f64, z: f64, i: u32| CopcPointFields {
2149            x,
2150            y,
2151            z,
2152            intensity: 0,
2153            return_number: 1,
2154            number_of_returns: 1,
2155            synthetic: 0,
2156            key_point: 0,
2157            withheld: 0,
2158            overlap: 0,
2159            scan_channel: 0,
2160            scan_direction_flag: 0,
2161            edge_of_flight_line: 0,
2162            classification: 0,
2163            user_data: 0,
2164            scan_angle: 0.0,
2165            point_source_id: 0,
2166            gps_time: f64::from(i),
2167            red: 0,
2168            green: 0,
2169            blue: 0,
2170            extra_bytes: Vec::new(),
2171        };
2172        // 4000 distinct points packed into a ~0.4-unit cluster ...
2173        let mut points: Vec<CopcPointFields> = (0..4_000u32)
2174            .map(|i| {
2175                let f = f64::from(i);
2176                field(
2177                    f * 1e-4,
2178                    (f * 1.7).fract() * 0.4,
2179                    (f * 2.3).fract() * 0.4,
2180                    i,
2181                )
2182            })
2183            .collect();
2184        // ... plus a few points spread wide to set large bounds around it.
2185        for i in 0..8u32 {
2186            points.push(field(
2187                f64::from(i) * 1000.0,
2188                f64::from(i) * 1000.0,
2189                f64::from(i) * 100.0,
2190                100_000 + i,
2191            ));
2192        }
2193        let max_points = 100usize;
2194        let source = VecSource { points };
2195        let bounds = source_bounds(&source);
2196        let (center, halfsize) = cube_from_bounds(&bounds);
2197        // Deliberately shallow — the writer must override it for the dense cluster.
2198        let params = CopcWriterParams {
2199            max_points_per_node: max_points as u32,
2200            max_depth: 3,
2201        };
2202
2203        let lod = build_lod_index(&source, center, halfsize, &params, &NeverCancel).unwrap();
2204        for (key, indices) in read_lod_index(&lod).unwrap() {
2205            assert!(
2206                indices.len() <= max_points,
2207                "node {key:?} holds {} points, exceeding max_points_per_node {max_points}",
2208                indices.len(),
2209            );
2210        }
2211    }
2212
2213    #[test]
2214    fn hierarchy_plan_splits_large_root_page() {
2215        let mut entries = vec![Entry {
2216            key: VoxelKey::root(),
2217            offset: 1,
2218            byte_size: 1,
2219            point_count: 1,
2220        }];
2221        let mut offset = 2;
2222        for z in 0..16 {
2223            for y in 0..16 {
2224                for x in 0..16 {
2225                    entries.push(Entry {
2226                        key: VoxelKey { level: 4, x, y, z },
2227                        offset,
2228                        byte_size: 1,
2229                        point_count: 1,
2230                    });
2231                    offset += 1;
2232                }
2233            }
2234        }
2235        entries.sort_by_key(|entry| entry.key);
2236
2237        let mut plan = plan_hierarchy_pages(&entries, VoxelKey::root()).unwrap();
2238        let start = 1024;
2239        let end = assign_hierarchy_page_offsets(&mut plan, start).unwrap();
2240
2241        assert!(plan.byte_size < hierarchy_page_byte_size(entries.len()).unwrap());
2242        assert!(plan
2243            .items
2244            .iter()
2245            .any(|item| matches!(item, HierarchyPageItem::Child(_))));
2246
2247        let mut out = Cursor::new(vec![0; start as usize]);
2248        out.seek(SeekFrom::Start(start)).unwrap();
2249        write_hierarchy_page_tree(&mut out, &plan).unwrap();
2250        assert_eq!(end, out.get_ref().len() as u64);
2251    }
2252
2253    fn source_bounds(source: &VecSource) -> Bounds {
2254        source.points.iter().fold(
2255            Bounds::point(source.points[0].x, source.points[0].y, source.points[0].z),
2256            |mut bounds, point| {
2257                bounds.extend(point.x, point.y, point.z);
2258                bounds
2259            },
2260        )
2261    }
2262
2263    fn read_lod_index(index: &LodIndex) -> Result<Vec<(VoxelKey, Vec<u32>)>> {
2264        let path: &Path = index.order_path.as_ref();
2265        let mut reader =
2266            BufReader::new(File::open(path).map_err(|e| Error::io("open LOD order", e))?);
2267        let mut out = Vec::new();
2268        for node in &index.nodes {
2269            reader
2270                .seek(SeekFrom::Start(node.start))
2271                .map_err(|e| Error::io("seek LOD order", e))?;
2272            let mut indices = Vec::with_capacity(node.count);
2273            for _ in 0..node.count {
2274                indices.push(
2275                    reader
2276                        .read_u32::<LittleEndian>()
2277                        .map_err(|e| Error::io("read LOD order", e))?,
2278                );
2279            }
2280            out.push((node.key, indices));
2281        }
2282        Ok(out)
2283    }
2284}