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#[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 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
50pub 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
61pub 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, ¶ms, &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}