Skip to content

Commit 5241ca5

Browse files
authored
Merge pull request #16 from roteiro-gis/ian/dev
Validate COPC coordinate encoding
2 parents 76af083 + 51ca2fc commit 5241ca5

2 files changed

Lines changed: 295 additions & 6 deletions

File tree

copc-writer/src/writer.rs

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,9 @@ where
238238
if index % CANCEL_POLL_STRIDE == 0 {
239239
cancel.check()?;
240240
}
241-
spill.push(&item?)?;
241+
let record = item?;
242+
validate_record_coordinates(&record, index)?;
243+
spill.push(&record)?;
242244
}
243245
cancel.check()?;
244246
let reader = spill.finalize()?;
@@ -264,6 +266,7 @@ pub fn convert_las_to_copc_streaming(
264266
}
265267
let point = result.map_err(|e| Error::Las(e.to_string()))?;
266268
let record = LasPointRecord::from_las_point(&point);
269+
validate_record_coordinates(&record, index)?;
267270
spill.push(&record)?;
268271
}
269272
cancel.check()?;
@@ -342,6 +345,126 @@ fn is_laszip_vlr(vlr: &las::Vlr) -> bool {
342345
vlr.user_id == LASZIP_VLR_USER_ID && vlr.record_id == LASZIP_VLR_RECORD_ID
343346
}
344347

348+
fn validate_record_coordinates(record: &LasPointRecord, index: usize) -> Result<()> {
349+
validate_xyz_finite(index, record.x, record.y, record.z)
350+
}
351+
352+
fn validate_coordinate_inputs<S: CopcPointSource>(
353+
source: &S,
354+
bounds: Bounds,
355+
scale: (f64, f64, f64),
356+
offset: (f64, f64, f64),
357+
cancel: &dyn CancelCheck,
358+
) -> Result<()> {
359+
validate_bounds(bounds)?;
360+
validate_transform(scale, offset)?;
361+
for index in 0..source.len() {
362+
if index % CANCEL_POLL_STRIDE == 0 {
363+
cancel.check()?;
364+
}
365+
let (x, y, z) = source.xyz(index);
366+
validate_xyz_finite(index, x, y, z)?;
367+
quantize_xyz(index, x, y, z, scale, offset)?;
368+
369+
let fields = source.fields(index)?;
370+
validate_xyz_finite(index, fields.x, fields.y, fields.z)?;
371+
quantize_xyz(index, fields.x, fields.y, fields.z, scale, offset)?;
372+
}
373+
Ok(())
374+
}
375+
376+
fn validate_bounds(bounds: Bounds) -> Result<()> {
377+
validate_finite_value("bounds min x", bounds.min.0)?;
378+
validate_finite_value("bounds min y", bounds.min.1)?;
379+
validate_finite_value("bounds min z", bounds.min.2)?;
380+
validate_finite_value("bounds max x", bounds.max.0)?;
381+
validate_finite_value("bounds max y", bounds.max.1)?;
382+
validate_finite_value("bounds max z", bounds.max.2)?;
383+
for (axis, min, max) in [
384+
("x", bounds.min.0, bounds.max.0),
385+
("y", bounds.min.1, bounds.max.1),
386+
("z", bounds.min.2, bounds.max.2),
387+
] {
388+
if min > max {
389+
return Err(Error::InvalidInput(format!(
390+
"bounds {axis} min {min} exceeds max {max}"
391+
)));
392+
}
393+
validate_finite_value(&format!("bounds {axis} span"), max - min)?;
394+
}
395+
Ok(())
396+
}
397+
398+
fn validate_transform(scale: (f64, f64, f64), offset: (f64, f64, f64)) -> Result<()> {
399+
for (axis, value) in [("x", scale.0), ("y", scale.1), ("z", scale.2)] {
400+
if !value.is_finite() || value <= 0.0 {
401+
return Err(Error::InvalidInput(format!(
402+
"LAS {axis} scale must be finite and positive, got {value}"
403+
)));
404+
}
405+
}
406+
validate_finite_value("LAS x offset", offset.0)?;
407+
validate_finite_value("LAS y offset", offset.1)?;
408+
validate_finite_value("LAS z offset", offset.2)?;
409+
Ok(())
410+
}
411+
412+
fn validate_xyz_finite(index: usize, x: f64, y: f64, z: f64) -> Result<()> {
413+
validate_point_axis_finite(index, "x", x)?;
414+
validate_point_axis_finite(index, "y", y)?;
415+
validate_point_axis_finite(index, "z", z)
416+
}
417+
418+
fn validate_point_axis_finite(index: usize, axis: &str, value: f64) -> Result<()> {
419+
if value.is_finite() {
420+
Ok(())
421+
} else {
422+
Err(Error::InvalidInput(format!(
423+
"point {index} {axis} coordinate must be finite, got {value}"
424+
)))
425+
}
426+
}
427+
428+
fn validate_finite_value(name: &str, value: f64) -> Result<()> {
429+
if value.is_finite() {
430+
Ok(())
431+
} else {
432+
Err(Error::InvalidInput(format!(
433+
"{name} must be finite, got {value}"
434+
)))
435+
}
436+
}
437+
438+
fn quantize_xyz(
439+
index: usize,
440+
x: f64,
441+
y: f64,
442+
z: f64,
443+
scale: (f64, f64, f64),
444+
offset: (f64, f64, f64),
445+
) -> Result<(i32, i32, i32)> {
446+
Ok((
447+
quantize_axis(index, "x", x, scale.0, offset.0)?,
448+
quantize_axis(index, "y", y, scale.1, offset.1)?,
449+
quantize_axis(index, "z", z, scale.2, offset.2)?,
450+
))
451+
}
452+
453+
fn quantize_axis(index: usize, axis: &str, value: f64, scale: f64, offset: f64) -> Result<i32> {
454+
let scaled = ((value - offset) / scale).round();
455+
if !scaled.is_finite() {
456+
return Err(Error::InvalidInput(format!(
457+
"point {index} {axis} coordinate cannot be encoded with scale {scale} and offset {offset}"
458+
)));
459+
}
460+
if scaled < f64::from(i32::MIN) || scaled > f64::from(i32::MAX) {
461+
return Err(Error::InvalidInput(format!(
462+
"point {index} {axis} coordinate {value} encodes to {scaled}, outside LAS i32 range"
463+
)));
464+
}
465+
Ok(scaled as i32)
466+
}
467+
345468
fn write_copc_from_spill(
346469
path: &Path,
347470
reader: SpillReader,
@@ -377,12 +500,19 @@ fn write_copc_inner<S: CopcPointSource>(
377500
LasFormat::new(point_format_id).map_err(|e| Error::Las(format!("point format: {e}")))?;
378501
let point_record_length = point_format.len();
379502

380-
let (center, halfsize) = cube_from_bounds(&bounds);
381503
let (scale_x, scale_y, scale_z) = metadata.scale;
382504
let (offset_x, offset_y, offset_z) =
383505
metadata
384506
.offset
385507
.unwrap_or((bounds.min.0, bounds.min.1, bounds.min.2));
508+
validate_coordinate_inputs(
509+
source,
510+
bounds,
511+
(scale_x, scale_y, scale_z),
512+
(offset_x, offset_y, offset_z),
513+
cancel,
514+
)?;
515+
let (center, halfsize) = cube_from_bounds(&bounds);
386516

387517
let nodes = build_lod_nodes(source, center, halfsize, params, cancel)?;
388518
cancel.check()?;
@@ -479,6 +609,7 @@ fn write_copc_inner<S: CopcPointSource>(
479609
&fields,
480610
(scale_x, scale_y, scale_z),
481611
(offset_x, offset_y, offset_z),
612+
source_index as usize,
482613
&point_format,
483614
)?;
484615
compressor
@@ -659,10 +790,14 @@ fn child_octant(bounds: Bounds, x: f64, y: f64, z: f64) -> usize {
659790
}
660791

661792
fn cube_from_bounds(bounds: &Bounds) -> ((f64, f64, f64), f64) {
662-
let center = bounds.center();
663793
let dx = bounds.max.0 - bounds.min.0;
664794
let dy = bounds.max.1 - bounds.min.1;
665795
let dz = bounds.max.2 - bounds.min.2;
796+
let center = (
797+
bounds.min.0 + dx * 0.5,
798+
bounds.min.1 + dy * 0.5,
799+
bounds.min.2 + dz * 0.5,
800+
);
666801
let halfsize = (dx.max(dy).max(dz) * 0.5).max(1e-6);
667802
(center, halfsize)
668803
}
@@ -864,12 +999,11 @@ fn encode_point_record(
864999
fields: &CopcPointFields,
8651000
scale: (f64, f64, f64),
8661001
offset: (f64, f64, f64),
1002+
point_index: usize,
8671003
format: &LasFormat,
8681004
) -> Result<()> {
8691005
let mut cursor = Cursor::new(buf);
870-
let ix = ((fields.x - offset.0) / scale.0).round() as i32;
871-
let iy = ((fields.y - offset.1) / scale.1).round() as i32;
872-
let iz = ((fields.z - offset.2) / scale.2).round() as i32;
1006+
let (ix, iy, iz) = quantize_xyz(point_index, fields.x, fields.y, fields.z, scale, offset)?;
8731007
let rn = fields.return_number & 0x0F;
8741008
let nr = fields.number_of_returns & 0x0F;
8751009
let flags = (fields.synthetic & 1)

copc-writer/tests/write_parse.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,74 @@ fn writer_output_parses_with_reader_hierarchy() {
127127
.all(|point| query_bounds.contains_xyz(point.x, point.y, point.z)));
128128
}
129129

130+
#[test]
131+
fn writer_rejects_non_finite_bounds() {
132+
let source = VecSource {
133+
points: vec![point_fields(0.0, 0.0, 0.0)],
134+
};
135+
let dir = tempfile::tempdir().unwrap();
136+
let path = dir.path().join("non-finite-bounds.copc.laz");
137+
138+
let err = write_source(
139+
&path,
140+
&source,
141+
false,
142+
Bounds::new((0.0, 0.0, 0.0), (f64::INFINITY, 0.0, 0.0)),
143+
&CopcWriterParams::default(),
144+
)
145+
.unwrap_err();
146+
147+
assert!(err.to_string().contains("bounds max x must be finite"));
148+
assert!(!path.exists());
149+
}
150+
151+
#[test]
152+
fn writer_rejects_non_finite_source_coordinate() {
153+
let source = VecSource {
154+
points: vec![point_fields(f64::NAN, 0.0, 0.0)],
155+
};
156+
let dir = tempfile::tempdir().unwrap();
157+
let path = dir.path().join("non-finite-point.copc.laz");
158+
159+
let err = write_source(
160+
&path,
161+
&source,
162+
false,
163+
Bounds::point(0.0, 0.0, 0.0),
164+
&CopcWriterParams::default(),
165+
)
166+
.unwrap_err();
167+
168+
assert!(err
169+
.to_string()
170+
.contains("point 0 x coordinate must be finite"));
171+
assert!(!path.exists());
172+
}
173+
174+
#[test]
175+
fn writer_rejects_coordinate_outside_las_i32_range() {
176+
let source = VecSource {
177+
points: vec![
178+
point_fields(0.0, 0.0, 0.0),
179+
point_fields(5_000_000.0, 0.0, 0.0),
180+
],
181+
};
182+
let dir = tempfile::tempdir().unwrap();
183+
let path = dir.path().join("out-of-range-point.copc.laz");
184+
185+
let err = write_source(
186+
&path,
187+
&source,
188+
false,
189+
Bounds::new((0.0, 0.0, 0.0), (5_000_000.0, 0.0, 0.0)),
190+
&CopcWriterParams::default(),
191+
)
192+
.unwrap_err();
193+
194+
assert!(err.to_string().contains("outside LAS i32 range"));
195+
assert!(!path.exists());
196+
}
197+
130198
#[test]
131199
fn streaming_conversion_preserves_scan_angle_degrees() {
132200
let dir = tempfile::tempdir().unwrap();
@@ -366,6 +434,36 @@ fn streaming_writer_rejects_unsupported_layout_dimensions() {
366434
assert!(message.contains("waveform point data"));
367435
}
368436

437+
#[test]
438+
fn streaming_writer_rejects_non_finite_record_coordinate() {
439+
let dir = tempfile::tempdir().unwrap();
440+
let path = dir.path().join("streaming-non-finite.copc.laz");
441+
let spill_dir = dir.path().join("spill");
442+
std::fs::create_dir(&spill_dir).unwrap();
443+
let layout = StreamingLayout {
444+
point_format: 6,
445+
has_gps: true,
446+
has_color: false,
447+
has_nir: false,
448+
has_waveform: false,
449+
};
450+
451+
let err = write_streaming_with_cancel(
452+
&path,
453+
layout,
454+
vec![Ok(las_record(f64::INFINITY, 0.0, 0.0))],
455+
&CopcWriterParams::default(),
456+
&spill_dir,
457+
&NeverCancel,
458+
)
459+
.unwrap_err();
460+
461+
assert!(err
462+
.to_string()
463+
.contains("point 0 x coordinate must be finite"));
464+
assert!(!path.exists());
465+
}
466+
369467
struct LasHeaderPrefix {
370468
file_source_id: u16,
371469
global_encoding: u16,
@@ -404,3 +502,60 @@ fn trim_nuls(bytes: &[u8]) -> String {
404502
.unwrap_or(bytes.len());
405503
String::from_utf8_lossy(&bytes[..end]).into_owned()
406504
}
505+
506+
fn point_fields(x: f64, y: f64, z: f64) -> CopcPointFields {
507+
CopcPointFields {
508+
x,
509+
y,
510+
z,
511+
intensity: 0,
512+
return_number: 1,
513+
number_of_returns: 1,
514+
synthetic: 0,
515+
key_point: 0,
516+
withheld: 0,
517+
overlap: 0,
518+
scan_channel: 0,
519+
scan_direction_flag: 0,
520+
edge_of_flight_line: 0,
521+
classification: 0,
522+
user_data: 0,
523+
scan_angle_rank: 0,
524+
point_source_id: 0,
525+
gps_time: 0.0,
526+
red: 0,
527+
green: 0,
528+
blue: 0,
529+
}
530+
}
531+
532+
fn las_record(x: f64, y: f64, z: f64) -> copc_core::LasPointRecord {
533+
copc_core::LasPointRecord {
534+
x,
535+
y,
536+
z,
537+
intensity: 0,
538+
return_number: 1,
539+
number_of_returns: 1,
540+
classification: 0,
541+
scan_direction_flag: false,
542+
edge_of_flight_line: false,
543+
scan_angle: 0,
544+
user_data: 0,
545+
point_source_id: 0,
546+
synthetic: false,
547+
key_point: false,
548+
withheld: false,
549+
overlap: false,
550+
scan_channel: 0,
551+
gps_time: 0.0,
552+
red: 0,
553+
green: 0,
554+
blue: 0,
555+
nir: 0,
556+
wave_packet_descriptor_index: 0,
557+
byte_offset_to_waveform_data: 0,
558+
waveform_packet_size: 0,
559+
return_point_waveform_location: 0.0,
560+
}
561+
}

0 commit comments

Comments
 (0)