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