Skip to main content

copc_writer/
writer.rs

1//! COPC write orchestration: entry points, chunk emission, and file assembly.
2
3use std::fs::File;
4use std::io::{BufReader, BufWriter, Seek, SeekFrom, Write};
5use std::path::Path;
6
7use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
8use copc_core::{
9    Bounds, CancelCheck, CopcInfo, Entry, Error, LasPointRecord, NeverCancel, Result,
10    StreamingLayout, VoxelKey, MAX_EVLR_COUNT, MAX_VLR_COUNT,
11};
12use las::point::Format as LasFormat;
13use laz::{LasZipCompressor, LazVlrBuilder};
14use tempfile::NamedTempFile;
15
16use crate::hierarchy_pages::{
17    assign_hierarchy_page_offsets, plan_hierarchy_pages, write_hierarchy_page_tree,
18};
19use crate::las_out::{
20    regular_las_vlrs_bytes, write_evlr_header, write_las_evlr, write_las_vlr, write_vlr_header,
21    LasHeader, LAS_EVLR_HEADER_BYTES, LAS_VLR_HEADER_BYTES,
22};
23use crate::lod::{build_lod_index, cube_from_bounds, INDEX_IO_BUFFER_BYTES};
24use crate::metadata::{
25    read_all_source_evlrs, CopcWriteMetadata, OutputLasMetadata, LASZIP_VLR_RECORD_ID,
26    LASZIP_VLR_USER_ID,
27};
28use crate::source::{CopcPointFields, CopcPointSource, SpillSource};
29use crate::spill::{SpillReader, SpillWriter};
30use crate::validate::{
31    quantize_xyz, scan_angle_to_las_scaled, validate_las_conversion_supported,
32    validate_source_points, validate_streaming_layout_supported, validate_write_setup, PointStats,
33};
34use crate::CANCEL_POLL_STRIDE;
35
36const LAS_INPUT_BUFFER_BYTES: usize = 1024 * 1024;
37const COPC_OUTPUT_BUFFER_BYTES: usize = 1024 * 1024;
38const LAS_POINT_BATCH_SIZE: u64 = 64 * 1024;
39
40/// Tuning parameters for COPC writes.
41#[derive(Debug, Clone, Copy)]
42#[non_exhaustive]
43pub struct CopcWriterParams {
44    /// Target maximum number of points per octree node (one LAZ chunk). The
45    /// octree subdivides until nodes fit this budget, up to an internal depth
46    /// cap that keeps voxel keys in range.
47    pub max_points_per_node: u32,
48}
49
50impl CopcWriterParams {
51    pub fn new(max_points_per_node: u32) -> Self {
52        Self {
53            max_points_per_node,
54        }
55    }
56}
57
58impl Default for CopcWriterParams {
59    fn default() -> Self {
60        Self::new(100_000)
61    }
62}
63
64pub fn write_source<S: CopcPointSource>(
65    path: &Path,
66    source: &S,
67    has_color: bool,
68    bounds: Bounds,
69    params: &CopcWriterParams,
70    metadata: &CopcWriteMetadata,
71) -> Result<()> {
72    write_source_with_cancel(
73        path,
74        source,
75        has_color,
76        bounds,
77        params,
78        metadata,
79        &NeverCancel,
80    )
81}
82
83pub fn write_source_with_cancel<S: CopcPointSource>(
84    path: &Path,
85    source: &S,
86    has_color: bool,
87    bounds: Bounds,
88    params: &CopcWriterParams,
89    metadata: &CopcWriteMetadata,
90    cancel: &dyn CancelCheck,
91) -> Result<()> {
92    cancel.check()?;
93    if source.is_empty() {
94        return Err(Error::InvalidInput(
95            "cannot write empty cloud to COPC".into(),
96        ));
97    }
98    write_copc_inner(
99        path,
100        source,
101        has_color,
102        bounds,
103        params,
104        cancel,
105        &metadata.to_output(),
106        None,
107    )
108}
109
110pub fn write_streaming_with_cancel<I>(
111    path: &Path,
112    layout: StreamingLayout,
113    points: I,
114    params: &CopcWriterParams,
115    metadata: &CopcWriteMetadata,
116    spill_dir: &Path,
117    cancel: &dyn CancelCheck,
118) -> Result<()>
119where
120    I: IntoIterator<Item = Result<LasPointRecord>>,
121{
122    cancel.check()?;
123    validate_streaming_layout_supported(&layout)?;
124    let mut spill = SpillWriter::create(spill_dir, layout)?;
125    for (index, item) in points.into_iter().enumerate() {
126        if index.is_multiple_of(CANCEL_POLL_STRIDE) {
127            cancel.check()?;
128        }
129        spill.push(&item?)?;
130    }
131    cancel.check()?;
132    let reader = spill.finalize()?;
133    write_copc_from_spill(path, reader, params, cancel, &metadata.to_output())
134}
135
136pub fn convert_las_to_copc_streaming(
137    las_path: &Path,
138    copc_path: &Path,
139    params: &CopcWriterParams,
140    spill_dir: &Path,
141    cancel: &dyn CancelCheck,
142) -> Result<()> {
143    convert_las_to_copc_streaming_inner(las_path, copc_path, params, spill_dir, cancel, None)
144}
145
146/// Converts LAS/LAZ to COPC and emits `crs_wkt_override` as a WKT CRS VLR
147/// when the source has GeoTIFF CRS records but no WKT CRS record.
148pub fn convert_las_to_copc_streaming_with_crs_wkt_override(
149    las_path: &Path,
150    copc_path: &Path,
151    params: &CopcWriterParams,
152    spill_dir: &Path,
153    cancel: &dyn CancelCheck,
154    crs_wkt_override: Option<&str>,
155) -> Result<()> {
156    convert_las_to_copc_streaming_inner(
157        las_path,
158        copc_path,
159        params,
160        spill_dir,
161        cancel,
162        crs_wkt_override,
163    )
164}
165
166fn convert_las_to_copc_streaming_inner(
167    las_path: &Path,
168    copc_path: &Path,
169    params: &CopcWriterParams,
170    spill_dir: &Path,
171    cancel: &dyn CancelCheck,
172    crs_wkt_override: Option<&str>,
173) -> Result<()> {
174    cancel.check()?;
175    let las_file = File::open(las_path).map_err(|e| Error::io("open source LAS/LAZ", e))?;
176    let mut reader = las::Reader::new(BufReader::with_capacity(LAS_INPUT_BUFFER_BYTES, las_file))
177        .map_err(|e| Error::Las(e.to_string()))?;
178    let source_evlrs = read_all_source_evlrs(las_path)?;
179    validate_las_conversion_supported(reader.header(), &source_evlrs, crs_wkt_override)?;
180    let output_metadata =
181        OutputLasMetadata::from_las_header(reader.header(), &source_evlrs, crs_wkt_override);
182    let layout = StreamingLayout::from_las_header(reader.header());
183    let mut spill = SpillWriter::create(spill_dir, layout)?;
184    let mut point_data = las::PointDataBuilder::new()
185        .for_header(reader.header())
186        .build();
187    let mut index = 0usize;
188    loop {
189        let count = reader
190            .fill_points(LAS_POINT_BATCH_SIZE, &mut point_data)
191            .map_err(|e| Error::Las(e.to_string()))?;
192        if count == 0 {
193            break;
194        }
195        for result in point_data.points() {
196            if index.is_multiple_of(CANCEL_POLL_STRIDE) {
197                cancel.check()?;
198            }
199            let point = result.map_err(|e| Error::Las(e.to_string()))?;
200            spill.push(&LasPointRecord::from_las_point(&point))?;
201            index = index
202                .checked_add(1)
203                .ok_or_else(|| Error::InvalidInput("source point count exceeds usize".into()))?;
204        }
205    }
206    cancel.check()?;
207    let reader = spill.finalize()?;
208    write_copc_from_spill(copc_path, reader, params, cancel, &output_metadata)
209}
210
211fn write_copc_from_spill(
212    path: &Path,
213    reader: SpillReader,
214    params: &CopcWriterParams,
215    cancel: &dyn CancelCheck,
216    metadata: &OutputLasMetadata,
217) -> Result<()> {
218    cancel.check()?;
219    if params.max_points_per_node == 0 {
220        return Err(Error::InvalidInput(
221            "max_points_per_node must be greater than zero".into(),
222        ));
223    }
224    validate_streaming_layout_supported(reader.layout())?;
225    if reader.is_empty() {
226        return Err(Error::InvalidInput(
227            "cannot write empty cloud to COPC".into(),
228        ));
229    }
230    let has_color = reader.layout().has_color;
231    let bounds = reader.bounds();
232    let stats = reader.stats();
233    let source = SpillSource::new(&reader);
234    write_copc_inner(
235        path,
236        &source,
237        has_color,
238        bounds,
239        params,
240        cancel,
241        metadata,
242        Some(stats),
243    )
244}
245
246#[allow(clippy::too_many_arguments)]
247fn write_copc_inner<S: CopcPointSource>(
248    path: &Path,
249    source: &S,
250    has_color: bool,
251    bounds: Bounds,
252    params: &CopcWriterParams,
253    cancel: &dyn CancelCheck,
254    metadata: &OutputLasMetadata,
255    intake_stats: Option<PointStats>,
256) -> Result<()> {
257    cancel.check()?;
258    if params.max_points_per_node == 0 {
259        return Err(Error::InvalidInput(
260            "max_points_per_node must be greater than zero".into(),
261        ));
262    }
263    let point_format_id = if has_color { 7u8 } else { 6u8 };
264    let mut point_format =
265        LasFormat::new(point_format_id).map_err(|e| Error::Las(format!("point format: {e}")))?;
266    let extra_byte_count = source.extra_byte_count();
267    let point_record_length = point_format
268        .len()
269        .checked_add(extra_byte_count)
270        .ok_or_else(|| {
271            Error::InvalidInput(format!(
272                "point record length with {extra_byte_count} extra bytes exceeds LAS u16 range"
273            ))
274        })?;
275    point_format.extra_bytes = extra_byte_count;
276
277    let (scale_x, scale_y, scale_z) = metadata.scale;
278    let (offset_x, offset_y, offset_z) =
279        metadata
280            .offset
281            .unwrap_or((bounds.min.0, bounds.min.1, bounds.min.2));
282    validate_write_setup(
283        bounds,
284        (scale_x, scale_y, scale_z),
285        (offset_x, offset_y, offset_z),
286    )?;
287    // Spill-backed sources validate records and accumulate stats at intake;
288    // other sources need the full validation pass here. Quantization-range
289    // failures for intake-validated sources surface during encoding instead,
290    // where the atomic output rename still prevents partial files.
291    let point_stats = match intake_stats {
292        Some(stats) => stats,
293        None => validate_source_points(
294            source,
295            bounds,
296            (scale_x, scale_y, scale_z),
297            (offset_x, offset_y, offset_z),
298            cancel,
299        )?,
300    };
301    let (center, halfsize) = cube_from_bounds(&bounds);
302
303    let lod_index = build_lod_index(source, center, halfsize, params, cancel)?;
304    cancel.check()?;
305
306    let var_vlr = LazVlrBuilder::default()
307        .with_point_format(point_format_id, extra_byte_count)
308        .map_err(|e| Error::Las(format!("laz items: {e}")))?
309        .with_variable_chunk_size()
310        .build();
311    let mut var_vlr_bytes = Vec::new();
312    var_vlr
313        .write_to(&mut var_vlr_bytes)
314        .map_err(|e| Error::Las(format!("variable chunk LAZ VLR: {e}")))?;
315
316    let copc_info_vlr_size = 160u16;
317    let las_header_size = 375u32;
318    let regular_crs_vlr_count = metadata.regular_crs_vlr_count();
319    let regular_crs_vlr_bytes = metadata.regular_crs_vlr_bytes()?;
320    let extra_bytes_vlrs = source.extra_bytes_vlrs();
321    let extra_bytes_vlr_bytes = regular_las_vlrs_bytes(extra_bytes_vlrs)?;
322    let pass_through_vlr_bytes = regular_las_vlrs_bytes(&metadata.pass_through_vlrs)?;
323    let number_of_vlrs = u32::try_from(
324        2usize
325            .checked_add(regular_crs_vlr_count)
326            .and_then(|count| count.checked_add(extra_bytes_vlrs.len()))
327            .and_then(|count| count.checked_add(metadata.pass_through_vlrs.len()))
328            .ok_or_else(|| Error::InvalidInput("VLR count overflow".into()))?,
329    )
330    .map_err(|_| Error::InvalidInput("VLR count overflow".into()))?;
331    if number_of_vlrs > MAX_VLR_COUNT {
332        return Err(Error::InvalidInput(format!(
333            "output VLR count {number_of_vlrs} exceeds max supported {MAX_VLR_COUNT}"
334        )));
335    }
336    let number_of_evlrs = u32::try_from(
337        1usize
338            .checked_add(metadata.source_evlr_count_after_hierarchy())
339            .ok_or_else(|| Error::InvalidInput("EVLR count overflow".into()))?,
340    )
341    .map_err(|_| Error::InvalidInput("EVLR count overflow".into()))?;
342    if number_of_evlrs > MAX_EVLR_COUNT {
343        return Err(Error::InvalidInput(format!(
344            "output EVLR count {number_of_evlrs} exceeds max supported {MAX_EVLR_COUNT}"
345        )));
346    }
347    let var_vlr_body_size = u16::try_from(var_vlr_bytes.len())
348        .map_err(|_| Error::InvalidInput("LAZ VLR byte size exceeds LAS VLR limit".into()))?;
349    let var_vlr_storage_bytes = LAS_VLR_HEADER_BYTES
350        .checked_add(u32::from(var_vlr_body_size))
351        .ok_or_else(|| Error::InvalidInput("LAZ VLR byte size overflow".into()))?;
352    let total_vlr_bytes = LAS_VLR_HEADER_BYTES
353        .checked_add(u32::from(copc_info_vlr_size))
354        .and_then(|total| total.checked_add(var_vlr_storage_bytes))
355        .and_then(|total| total.checked_add(regular_crs_vlr_bytes))
356        .and_then(|total| total.checked_add(extra_bytes_vlr_bytes))
357        .and_then(|total| total.checked_add(pass_through_vlr_bytes))
358        .ok_or_else(|| Error::InvalidInput("VLR byte size overflow".into()))?;
359    let offset_to_point_data = las_header_size
360        .checked_add(total_vlr_bytes)
361        .ok_or_else(|| Error::InvalidInput("point data offset overflow".into()))?;
362
363    let pending = PendingOutput::create(path)?;
364    let file = pending.reopen()?;
365    let mut writer = BufWriter::with_capacity(COPC_OUTPUT_BUFFER_BYTES, file);
366
367    let header = LasHeader {
368        point_data_format: point_format_id | 0x80,
369        point_record_length,
370        offset_to_point_data,
371        number_of_vlrs,
372        file_source_id: metadata.file_source_id,
373        global_encoding: metadata.global_encoding,
374        guid: metadata.guid,
375        system_identifier: metadata.system_identifier.clone(),
376        generating_software: metadata.generating_software.clone(),
377        creation_day_of_year: metadata.creation_day_of_year,
378        creation_year: metadata.creation_year,
379        scale: (scale_x, scale_y, scale_z),
380        offset: (offset_x, offset_y, offset_z),
381        bounds,
382        legacy_point_count: 0,
383        total_point_count: source.len() as u64,
384        offset_to_first_evlr: 0,
385        number_of_evlrs,
386        extended_return_counts: point_stats.extended_return_counts,
387    };
388    header.write(&mut writer)?;
389
390    write_vlr_header(&mut writer, "copc", 1, copc_info_vlr_size, "COPC info")?;
391    let copc_info_payload_start = writer
392        .stream_position()
393        .map_err(|e| Error::io("record COPC info payload offset", e))?;
394    writer
395        .write_all(&[0u8; 160])
396        .map_err(|e| Error::io("write COPC info placeholder", e))?;
397
398    write_vlr_header(
399        &mut writer,
400        LASZIP_VLR_USER_ID,
401        LASZIP_VLR_RECORD_ID,
402        var_vlr_body_size,
403        "http://laszip.org",
404    )?;
405    writer
406        .write_all(&var_vlr_bytes)
407        .map_err(|e| Error::io("write LAZ VLR", e))?;
408
409    for vlr in metadata.regular_crs_vlrs() {
410        write_las_vlr(&mut writer, vlr)?;
411    }
412    for vlr in extra_bytes_vlrs {
413        write_las_vlr(&mut writer, vlr)?;
414    }
415    for vlr in &metadata.pass_through_vlrs {
416        write_las_vlr(&mut writer, vlr)?;
417    }
418
419    let point_data_actual_start = writer
420        .stream_position()
421        .map_err(|e| Error::io("record point data offset", e))?;
422    if point_data_actual_start as u32 != offset_to_point_data {
423        return Err(Error::InvalidInput(format!(
424            "VLR size accounting mismatch: at {point_data_actual_start}, expected {offset_to_point_data}"
425        )));
426    }
427
428    let hierarchy = compress_nodes(
429        &mut writer,
430        &var_vlr,
431        &lod_index,
432        source,
433        (scale_x, scale_y, scale_z),
434        (offset_x, offset_y, offset_z),
435        usize::from(point_record_length),
436        &point_format,
437        cancel,
438    )?;
439
440    let hierarchy_evlr_start = writer
441        .stream_position()
442        .map_err(|e| Error::io("record hierarchy EVLR start", e))?;
443    let root_hier_offset = hierarchy_evlr_start
444        .checked_add(LAS_EVLR_HEADER_BYTES)
445        .ok_or_else(|| Error::InvalidInput("hierarchy EVLR offset overflow".into()))?;
446    let mut hierarchy_pages = plan_hierarchy_pages(&hierarchy, VoxelKey::root())?;
447    let hierarchy_end = assign_hierarchy_page_offsets(&mut hierarchy_pages, root_hier_offset)?;
448    let hierarchy_body_size = hierarchy_end
449        .checked_sub(root_hier_offset)
450        .ok_or_else(|| Error::InvalidInput("hierarchy size overflow".into()))?;
451    write_evlr_header(
452        &mut writer,
453        "copc",
454        1000,
455        hierarchy_body_size,
456        "COPC hierarchy",
457    )?;
458    let actual_root_hier_offset = writer
459        .stream_position()
460        .map_err(|e| Error::io("record root hierarchy offset", e))?;
461    if actual_root_hier_offset != root_hier_offset {
462        return Err(Error::InvalidInput(format!(
463            "hierarchy offset accounting mismatch: at {actual_root_hier_offset}, expected {root_hier_offset}"
464        )));
465    }
466    write_hierarchy_page_tree(&mut writer, &hierarchy_pages)?;
467    for evlr in metadata.source_evlrs_after_hierarchy() {
468        write_las_evlr(&mut writer, evlr)?;
469    }
470
471    writer
472        .seek(SeekFrom::Start(copc_info_payload_start))
473        .map_err(|e| Error::io("seek COPC info payload", e))?;
474    let info = CopcInfo {
475        center,
476        halfsize,
477        spacing: halfsize / 128.0,
478        root_hier_offset,
479        root_hier_size: hierarchy_pages.byte_size,
480        gpstime_min: point_stats.gpstime_min,
481        gpstime_max: point_stats.gpstime_max,
482    };
483    let info_bytes = info.write_le_bytes()?;
484    writer
485        .write_all(&info_bytes)
486        .map_err(|e| Error::io("patch COPC info", e))?;
487
488    writer
489        .seek(SeekFrom::Start(235))
490        .map_err(|e| Error::io("seek first EVLR offset", e))?;
491    writer
492        .write_u64::<LittleEndian>(hierarchy_evlr_start)
493        .map_err(|e| Error::io("patch first EVLR offset", e))?;
494
495    writer
496        .flush()
497        .map_err(|e| Error::io("flush COPC file", e))?;
498    let file = writer
499        .into_inner()
500        .map_err(|e| Error::io("flush COPC file", e.into_error()))?;
501    file.sync_all()
502        .map_err(|e| Error::io("sync COPC file", e))?;
503    drop(file);
504    pending.commit()?;
505    Ok(())
506}
507
508/// Guards an in-progress output file so failed writes never leave a partial
509/// COPC file at the destination: the file is written to a same-directory temp
510/// name and atomically renamed into place on success.
511struct PendingOutput {
512    file: Option<NamedTempFile>,
513    final_path: std::path::PathBuf,
514}
515
516impl PendingOutput {
517    fn create(path: &Path) -> Result<Self> {
518        let file_name = path.file_name().ok_or_else(|| {
519            Error::InvalidInput(format!("output path {} has no file name", path.display()))
520        })?;
521        let mut prefix = std::ffi::OsString::from(".");
522        prefix.push(file_name);
523        prefix.push(".");
524        let parent = path
525            .parent()
526            .filter(|parent| !parent.as_os_str().is_empty())
527            .unwrap_or_else(|| Path::new("."));
528        let file = tempfile::Builder::new()
529            .prefix(&prefix)
530            .suffix(".part")
531            .tempfile_in(parent)
532            .map_err(|e| Error::io("create temporary COPC file", e))?;
533        Ok(Self {
534            file: Some(file),
535            final_path: path.to_path_buf(),
536        })
537    }
538
539    fn reopen(&self) -> Result<File> {
540        self.file
541            .as_ref()
542            .ok_or_else(|| Error::InvalidInput("temporary COPC file already committed".into()))?
543            .reopen()
544            .map_err(|e| Error::io("open temporary COPC file", e))
545    }
546
547    fn commit(mut self) -> Result<()> {
548        let file = self
549            .file
550            .take()
551            .ok_or_else(|| Error::InvalidInput("temporary COPC file already committed".into()))?;
552        file.persist(&self.final_path)
553            .map_err(|e| Error::io("persist COPC file", e.error))?;
554        sync_parent_directory(&self.final_path)?;
555        Ok(())
556    }
557}
558
559#[cfg(unix)]
560fn sync_parent_directory(path: &Path) -> Result<()> {
561    let parent = path
562        .parent()
563        .filter(|parent| !parent.as_os_str().is_empty())
564        .unwrap_or_else(|| Path::new("."));
565    File::open(parent)
566        .and_then(|directory| directory.sync_all())
567        .map_err(|e| Error::io("sync COPC output directory", e))
568}
569
570#[cfg(not(unix))]
571fn sync_parent_directory(_path: &Path) -> Result<()> {
572    Ok(())
573}
574
575/// Reads one node's ordered source indexes from the LOD order file and
576/// encodes its points into `raw` (`node.count * record_len` bytes).
577#[allow(clippy::too_many_arguments)]
578fn encode_node_points<S: CopcPointSource>(
579    node: &crate::lod::LodNodeRange,
580    index_reader: &mut BufReader<File>,
581    source: &S,
582    fields: &mut CopcPointFields,
583    raw: &mut Vec<u8>,
584    record_len: usize,
585    scale: (f64, f64, f64),
586    offset: (f64, f64, f64),
587    point_format: &LasFormat,
588    cancel: &dyn CancelCheck,
589) -> Result<()> {
590    raw.clear();
591    let raw_len = node
592        .count
593        .checked_mul(record_len)
594        .ok_or_else(|| Error::InvalidInput("node point buffer size overflows usize".into()))?;
595    raw.resize(raw_len, 0);
596    index_reader
597        .seek(SeekFrom::Start(node.start))
598        .map_err(|e| Error::io("seek LOD order", e))?;
599    for point_index in 0..node.count {
600        if point_index.is_multiple_of(CANCEL_POLL_STRIDE) {
601            cancel.check()?;
602        }
603        let source_index = index_reader
604            .read_u32::<LittleEndian>()
605            .map_err(|e| Error::io("read LOD order", e))? as usize;
606        source.fields_into(source_index, fields)?;
607        encode_point_record(
608            &mut raw[point_index * record_len..(point_index + 1) * record_len],
609            fields,
610            scale,
611            offset,
612            source_index,
613            point_format,
614        )?;
615    }
616    Ok(())
617}
618
619fn hierarchy_entry(key: VoxelKey, offset: u64, byte_size: u64, count: usize) -> Result<Entry> {
620    Ok(Entry {
621        key,
622        offset,
623        byte_size: i32::try_from(byte_size)
624            .map_err(|_| Error::InvalidInput("LAZ chunk exceeds COPC i32 byte size".into()))?,
625        point_count: i32::try_from(count)
626            .map_err(|_| Error::InvalidInput("node point count exceeds COPC i32 range".into()))?,
627    })
628}
629
630/// Compress each LOD node into one COPC chunk, returning the hierarchy
631/// entries. Sequential implementation: one `LasZipCompressor` streams every
632/// chunk in order.
633#[cfg(not(feature = "parallel"))]
634#[allow(clippy::too_many_arguments)]
635fn compress_nodes<W: Write + Seek + Send + Sync, S: CopcPointSource>(
636    writer: &mut W,
637    var_vlr: &laz::LazVlr,
638    lod_index: &crate::lod::LodIndex,
639    source: &S,
640    scale: (f64, f64, f64),
641    offset: (f64, f64, f64),
642    record_len: usize,
643    point_format: &LasFormat,
644    cancel: &dyn CancelCheck,
645) -> Result<Vec<Entry>> {
646    let mut compressor = LasZipCompressor::new(&mut *writer, var_vlr.clone())
647        .map_err(|e| Error::Las(format!("compressor: {e}")))?;
648    let mut hierarchy = Vec::with_capacity(lod_index.nodes.len());
649    let order_path: &Path = lod_index.order_path.as_ref();
650    let mut index_reader = BufReader::with_capacity(
651        INDEX_IO_BUFFER_BYTES,
652        File::open(order_path).map_err(|e| Error::io("open LOD order", e))?,
653    );
654    let mut raw = Vec::new();
655    let mut fields = CopcPointFields::default();
656    let mut chunk_start_file_offset = compressor
657        .get_mut()
658        .stream_position()
659        .map_err(|e| Error::io("record chunk start", e))?;
660    chunk_start_file_offset = chunk_start_file_offset
661        .checked_add(8)
662        .ok_or_else(|| Error::InvalidInput("LAZ point-data offset overflows u64".into()))?;
663
664    for node in &lod_index.nodes {
665        cancel.check()?;
666        encode_node_points(
667            node,
668            &mut index_reader,
669            source,
670            &mut fields,
671            &mut raw,
672            record_len,
673            scale,
674            offset,
675            point_format,
676            cancel,
677        )?;
678        compressor
679            .compress_many(&raw)
680            .map_err(|e| Error::Las(format!("compress chunk: {e}")))?;
681        compressor
682            .finish_current_chunk()
683            .map_err(|e| Error::Las(format!("finish chunk: {e}")))?;
684        let after = compressor
685            .get_mut()
686            .stream_position()
687            .map_err(|e| Error::io("record chunk end", e))?;
688        hierarchy.push(hierarchy_entry(
689            node.key,
690            chunk_start_file_offset,
691            after.checked_sub(chunk_start_file_offset).ok_or_else(|| {
692                Error::InvalidData("LAZ compressor moved before the chunk start".into())
693            })?,
694            node.count,
695        )?);
696        chunk_start_file_offset = after;
697    }
698
699    cancel.check()?;
700    compressor
701        .done()
702        .map_err(|e| Error::Las(format!("finish compressor: {e}")))?;
703    Ok(hierarchy)
704}
705
706/// Compress each LOD node into one COPC chunk, returning the hierarchy
707/// entries. Parallel implementation: node point buffers are encoded
708/// sequentially in bounded batches, compressed on rayon workers (each node is
709/// one standalone LAZ chunk), then written in node order; the LAZ chunk table
710/// and its offset are emitted to match the sequential layout.
711///
712/// Peak memory is roughly `2 * batch * max_points_per_node * record_len`
713/// bytes for the raw and compressed batch buffers, with
714/// `batch = 2 * rayon::current_num_threads()`.
715#[cfg(feature = "parallel")]
716#[allow(clippy::too_many_arguments)]
717fn compress_nodes<W: Write + Seek + Send, S: CopcPointSource>(
718    writer: &mut W,
719    var_vlr: &laz::LazVlr,
720    lod_index: &crate::lod::LodIndex,
721    source: &S,
722    scale: (f64, f64, f64),
723    offset: (f64, f64, f64),
724    record_len: usize,
725    point_format: &LasFormat,
726    cancel: &dyn CancelCheck,
727) -> Result<Vec<Entry>> {
728    use laz::laszip::{ChunkTable, ChunkTableEntry};
729    use rayon::prelude::*;
730
731    let table_offset_position = writer
732        .stream_position()
733        .map_err(|e| Error::io("record chunk table offset position", e))?;
734    writer
735        .write_i64::<LittleEndian>(-1)
736        .map_err(|e| Error::io("write chunk table offset placeholder", e))?;
737
738    let mut hierarchy = Vec::with_capacity(lod_index.nodes.len());
739    let mut chunk_table = ChunkTable::with_capacity(lod_index.nodes.len());
740    let order_path: &Path = lod_index.order_path.as_ref();
741    let mut index_reader = BufReader::with_capacity(
742        INDEX_IO_BUFFER_BYTES,
743        File::open(order_path).map_err(|e| Error::io("open LOD order", e))?,
744    );
745    let mut fields = CopcPointFields::default();
746    let mut chunk_start_file_offset = table_offset_position + 8;
747    let batch_size = rayon::current_num_threads().max(1) * 2;
748
749    for batch in lod_index.nodes.chunks(batch_size) {
750        cancel.check()?;
751        let mut raw_chunks = Vec::with_capacity(batch.len());
752        for node in batch {
753            let mut raw = Vec::new();
754            encode_node_points(
755                node,
756                &mut index_reader,
757                source,
758                &mut fields,
759                &mut raw,
760                record_len,
761                scale,
762                offset,
763                point_format,
764                cancel,
765            )?;
766            raw_chunks.push(raw);
767        }
768
769        let compressed: Vec<Result<Vec<u8>>> = raw_chunks
770            .par_iter()
771            .map(|raw| compress_standalone_chunk(raw, var_vlr))
772            .collect();
773
774        for (node, chunk) in batch.iter().zip(compressed) {
775            let chunk = chunk?;
776            writer
777                .write_all(&chunk)
778                .map_err(|e| Error::io("write LAZ chunk", e))?;
779            hierarchy.push(hierarchy_entry(
780                node.key,
781                chunk_start_file_offset,
782                chunk.len() as u64,
783                node.count,
784            )?);
785            chunk_table.push(ChunkTableEntry {
786                point_count: node.count as u64,
787                byte_count: chunk.len() as u64,
788            });
789            chunk_start_file_offset = chunk_start_file_offset
790                .checked_add(chunk.len() as u64)
791                .ok_or_else(|| Error::InvalidInput("LAZ point-data offset overflows u64".into()))?;
792        }
793    }
794
795    cancel.check()?;
796    let chunk_table_position = writer
797        .stream_position()
798        .map_err(|e| Error::io("record chunk table position", e))?;
799    chunk_table
800        .write_to(&mut *writer, var_vlr)
801        .map_err(|e| Error::io("write chunk table", e))?;
802    let end_position = writer
803        .stream_position()
804        .map_err(|e| Error::io("record chunk table end", e))?;
805    writer
806        .seek(SeekFrom::Start(table_offset_position))
807        .map_err(|e| Error::io("seek chunk table offset", e))?;
808    let chunk_table_position = i64::try_from(chunk_table_position)
809        .map_err(|_| Error::InvalidInput("LAZ chunk table offset exceeds i64 range".into()))?;
810    writer
811        .write_i64::<LittleEndian>(chunk_table_position)
812        .map_err(|e| Error::io("patch chunk table offset", e))?;
813    writer
814        .seek(SeekFrom::Start(end_position))
815        .map_err(|e| Error::io("seek end of point data", e))?;
816    Ok(hierarchy)
817}
818
819/// Compress one node's raw points as a standalone variable-size LAZ chunk and
820/// return exactly the chunk bytes (no chunk-table offset, no chunk table).
821#[cfg(feature = "parallel")]
822fn compress_standalone_chunk(raw_points: &[u8], var_vlr: &laz::LazVlr) -> Result<Vec<u8>> {
823    let mut cursor = std::io::Cursor::new(Vec::new());
824    let mut compressor = LasZipCompressor::new(&mut cursor, var_vlr.clone())
825        .map_err(|e| Error::Las(format!("chunk compressor: {e}")))?;
826    compressor
827        .compress_many(raw_points)
828        .map_err(|e| Error::Las(format!("compress chunk: {e}")))?;
829    compressor
830        .done()
831        .map_err(|e| Error::Las(format!("finish chunk: {e}")))?;
832    drop(compressor);
833    let bytes = cursor.into_inner();
834    // A LAZ point-data stream starts with an i64 offset to the chunk table;
835    // the single chunk's bytes sit between that offset field and the table.
836    let table_position = i64::from_le_bytes(
837        bytes[0..8]
838            .try_into()
839            .map_err(|_| Error::InvalidData("truncated LAZ chunk stream".into()))?,
840    );
841    let table_position = usize::try_from(table_position)
842        .map_err(|_| Error::InvalidData("invalid LAZ chunk table offset".into()))?;
843    if table_position < 8 || table_position > bytes.len() {
844        return Err(Error::InvalidData(
845            "LAZ chunk table offset out of range".into(),
846        ));
847    }
848    Ok(bytes[8..table_position].to_vec())
849}
850
851/// Encode one point directly into the PDRF 6/7 record layout, avoiding the
852/// per-point `las::raw::Point` construction (and its `extra_bytes` clone) in
853/// the hot compression loop.
854fn encode_point_record(
855    buf: &mut [u8],
856    fields: &CopcPointFields,
857    scale: (f64, f64, f64),
858    offset: (f64, f64, f64),
859    point_index: usize,
860    format: &LasFormat,
861) -> Result<()> {
862    debug_assert!(format.is_extended && !format.has_nir && !format.has_waveform);
863    debug_assert_eq!(usize::from(format.len()), buf.len());
864    let (ix, iy, iz) = quantize_xyz(point_index, fields.x, fields.y, fields.z, scale, offset)?;
865    buf[0..4].copy_from_slice(&ix.to_le_bytes());
866    buf[4..8].copy_from_slice(&iy.to_le_bytes());
867    buf[8..12].copy_from_slice(&iz.to_le_bytes());
868    buf[12..14].copy_from_slice(&fields.intensity.to_le_bytes());
869    buf[14] = fields.return_number | (fields.number_of_returns << 4);
870    buf[15] = fields.synthetic
871        | (fields.key_point << 1)
872        | (fields.withheld << 2)
873        | (fields.overlap << 3)
874        | (fields.scan_channel << 4)
875        | (fields.scan_direction_flag << 6)
876        | (fields.edge_of_flight_line << 7);
877    buf[16] = fields.classification;
878    buf[17] = fields.user_data;
879    buf[18..20].copy_from_slice(&scan_angle_to_las_scaled(fields.scan_angle).to_le_bytes());
880    buf[20..22].copy_from_slice(&fields.point_source_id.to_le_bytes());
881    buf[22..30].copy_from_slice(&fields.gps_time.to_le_bytes());
882    let mut cursor = 30;
883    if format.has_color {
884        buf[30..32].copy_from_slice(&fields.red.to_le_bytes());
885        buf[32..34].copy_from_slice(&fields.green.to_le_bytes());
886        buf[34..36].copy_from_slice(&fields.blue.to_le_bytes());
887        cursor = 36;
888    }
889    if fields.extra_bytes.len() != buf.len() - cursor {
890        return Err(Error::InvalidInput(format!(
891            "point {point_index} has {} extra byte(s), expected {}",
892            fields.extra_bytes.len(),
893            buf.len() - cursor
894        )));
895    }
896    buf[cursor..].copy_from_slice(&fields.extra_bytes);
897    Ok(())
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    use las::{raw, Color};
905
906    /// The direct encoder must stay byte-identical to `las::raw::Point`
907    /// serialization for the PDRF 6/7 layouts the writer emits.
908    #[test]
909    fn direct_point_encoding_matches_las_raw_point() {
910        let fields = CopcPointFields {
911            x: 12.345,
912            y: -67.89,
913            z: 101.5,
914            intensity: 0xBEEF,
915            return_number: 3,
916            number_of_returns: 5,
917            synthetic: 1,
918            key_point: 0,
919            withheld: 1,
920            overlap: 0,
921            scan_channel: 2,
922            scan_direction_flag: 1,
923            edge_of_flight_line: 0,
924            classification: 6,
925            user_data: 0x42,
926            scan_angle: -30.25,
927            point_source_id: 0xCAFE,
928            gps_time: 1.234e9,
929            red: 1_000,
930            green: 2_000,
931            blue: 3_000,
932            extra_bytes: Vec::new(),
933        };
934        let scale = (0.001, 0.001, 0.001);
935        let offset = (0.0, 0.0, 0.0);
936
937        for (format_id, extra_bytes) in [(6u8, 0u16), (6, 3), (7, 0), (7, 5)] {
938            let mut format = LasFormat::new(format_id).unwrap();
939            format.extra_bytes = extra_bytes;
940            let mut fields = fields.clone();
941            fields.extra_bytes = (0..extra_bytes).map(|byte| byte as u8 ^ 0xA5).collect();
942
943            let mut direct = vec![0u8; usize::from(format.len())];
944            encode_point_record(&mut direct, &fields, scale, offset, 0, &format).unwrap();
945
946            let (ix, iy, iz) =
947                quantize_xyz(0, fields.x, fields.y, fields.z, scale, offset).unwrap();
948            let class_flags = fields.synthetic
949                | (fields.key_point << 1)
950                | (fields.withheld << 2)
951                | (fields.overlap << 3);
952            let reference_point = raw::Point {
953                x: ix,
954                y: iy,
955                z: iz,
956                intensity: fields.intensity,
957                flags: raw::point::Flags::ThreeByte(
958                    fields.return_number | (fields.number_of_returns << 4),
959                    class_flags
960                        | (fields.scan_channel << 4)
961                        | (fields.scan_direction_flag << 6)
962                        | (fields.edge_of_flight_line << 7),
963                    fields.classification,
964                ),
965                scan_angle: raw::point::ScanAngle::Scaled(scan_angle_to_las_scaled(
966                    fields.scan_angle,
967                )),
968                user_data: fields.user_data,
969                point_source_id: fields.point_source_id,
970                gps_time: Some(fields.gps_time),
971                color: format.has_color.then_some(Color::new(
972                    fields.red,
973                    fields.green,
974                    fields.blue,
975                )),
976                waveform: None,
977                nir: None,
978                extra_bytes: fields.extra_bytes.clone(),
979            };
980            let mut reference = Vec::with_capacity(usize::from(format.len()));
981            reference_point.write_to(&mut reference, &format).unwrap();
982
983            assert_eq!(
984                reference, direct,
985                "format {format_id} with {extra_bytes} extra byte(s)"
986            );
987        }
988    }
989}