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