Skip to content

Commit ae10cfa

Browse files
authored
Merge pull request #39 from roteiro-gis/ian/dev
Add CRS WKT override hook
2 parents bc736bf + 9e4ec0d commit ae10cfa

9 files changed

Lines changed: 156 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
## 0.4.2 - 2026-07-05
6+
7+
- add a proj-free CRS WKT override hook for LAS/LAZ-to-COPC streaming
8+
conversion, allowing downstream engines to resolve GeoTIFF-only CRS metadata
9+
externally and have `copc-writer` emit the resulting WKT CRS VLR
10+
- keep GeoTIFF-only CRS inputs rejected with the specific unsupported
11+
conversion error when no WKT CRS record or WKT override is supplied
12+
513
## 0.4.1 - 2026-07-04
614

715
- preserve source EVLRs when converting from COPC/LAZ inputs whose EVLR list

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["copc-core", "copc-reader", "copc-writer"]
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.4.1"
6+
version = "0.4.2"
77
edition = "2021"
88
rust-version = "1.77"
99
license = "MIT OR Apache-2.0"

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ convert_las_to_copc_streaming(
7272
)?;
7373
```
7474

75+
For GeoTIFF-only CRS inputs, resolve CRS externally and call
76+
`convert_las_to_copc_streaming_with_crs_wkt_override` with `Some(wkt)`; the
77+
writer emits the supplied WKT CRS VLR without depending on a geodesy library.
78+
7579
## Column Ownership Model
7680

7781
`copc-core` owns the LAS/COPC-native column model: `LasDimension`,
@@ -106,14 +110,16 @@ behind a future optional feature.
106110
- Streaming LAS/LAZ-to-COPC conversion through a disk-backed mmap spill
107111
- Streaming conversion preserves WKT CRS records, LAS Extra Bytes payloads and
108112
descriptors, and source non-CRS VLRs/EVLRs
113+
- Streaming conversion can accept caller-resolved WKT for GeoTIFF-only CRS
114+
inputs without adding a geodesy dependency to `copc-writer`
109115
- LAS 1.4 point formats 6 and 7 with LAZ variable-size chunks
110116
- Interior-node representative points for native LOD reads
111117

112118
## Not Yet Supported
113119

114120
- Zero-copy column views directly over compressed COPC/LAZ point data
115121
- Built-in Arrow or DataFusion conversion
116-
- GeoTIFF-only CRS conversion to WKT during LAS/LAZ-to-COPC conversion
122+
- Built-in GeoTIFF-only CRS conversion to WKT during LAS/LAZ-to-COPC conversion
117123

118124
## Testing
119125

copc-reader/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ exclude = [
1818

1919
[dependencies]
2020
byteorder = { workspace = true }
21-
copc-core = { version = "0.4.1", path = "../copc-core" }
21+
copc-core = { version = "0.4.2", path = "../copc-core" }
2222
las = { workspace = true }
2323
laz = { workspace = true }

copc-writer/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ categories = ["science", "encoding"]
1414

1515
[dependencies]
1616
byteorder = { workspace = true }
17-
copc-core = { version = "0.4.1", path = "../copc-core" }
17+
copc-core = { version = "0.4.2", path = "../copc-core" }
1818
las = { workspace = true }
1919
laz = { workspace = true }
2020
memmap2 = { workspace = true }
2121
tempfile = { version = "3", default-features = false }
2222

2323
[dev-dependencies]
24-
copc-reader = { version = "0.4.1", path = "../copc-reader" }
24+
copc-reader = { version = "0.4.2", path = "../copc-reader" }

copc-writer/src/lib.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ mod writer;
55

66
pub use spill::{SpillReader, SpillWriter};
77
pub use writer::{
8-
convert_las_to_copc_streaming, write_source, write_source_with_cancel,
9-
write_streaming_with_cancel, ColumnBatchSource, CopcPointFields, CopcPointSource,
10-
CopcWriterParams,
8+
convert_las_to_copc_streaming, convert_las_to_copc_streaming_with_crs_wkt_override,
9+
write_source, write_source_with_cancel, write_streaming_with_cancel, ColumnBatchSource,
10+
CopcPointFields, CopcPointSource, CopcWriterParams,
1111
};

copc-writer/src/writer.rs

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,12 +475,21 @@ impl Default for OutputLasMetadata {
475475
}
476476

477477
impl OutputLasMetadata {
478-
fn from_las_header(header: &las::Header, source_evlrs: &[las::Vlr]) -> Self {
478+
fn from_las_header(
479+
header: &las::Header,
480+
source_evlrs: &[las::Vlr],
481+
crs_wkt_override: Option<&str>,
482+
) -> Self {
479483
let mut global_encoding = u16::from(header.gps_time_type());
480484
if header.has_synthetic_return_numbers() {
481485
global_encoding |= 8;
482486
}
483-
let crs_records = extract_source_wkt_crs_records(header, source_evlrs);
487+
let mut crs_records = extract_source_wkt_crs_records(header, source_evlrs);
488+
if crs_records.is_empty() && has_geotiff_crs_record(header, source_evlrs) {
489+
if let Some(crs_wkt) = normalized_crs_wkt_override(crs_wkt_override) {
490+
crs_records.push(wkt_override_crs_record(crs_wkt));
491+
}
492+
}
484493
if !crs_records.is_empty() {
485494
global_encoding |= WKT_GLOBAL_ENCODING_BIT;
486495
}
@@ -678,14 +687,46 @@ pub fn convert_las_to_copc_streaming(
678687
params: &CopcWriterParams,
679688
spill_dir: &Path,
680689
cancel: &dyn CancelCheck,
690+
) -> Result<()> {
691+
convert_las_to_copc_streaming_inner(las_path, copc_path, params, spill_dir, cancel, None)
692+
}
693+
694+
/// Converts LAS/LAZ to COPC and emits `crs_wkt_override` as a WKT CRS VLR
695+
/// when the source has GeoTIFF CRS records but no WKT CRS record.
696+
pub fn convert_las_to_copc_streaming_with_crs_wkt_override(
697+
las_path: &Path,
698+
copc_path: &Path,
699+
params: &CopcWriterParams,
700+
spill_dir: &Path,
701+
cancel: &dyn CancelCheck,
702+
crs_wkt_override: Option<&str>,
703+
) -> Result<()> {
704+
convert_las_to_copc_streaming_inner(
705+
las_path,
706+
copc_path,
707+
params,
708+
spill_dir,
709+
cancel,
710+
crs_wkt_override,
711+
)
712+
}
713+
714+
fn convert_las_to_copc_streaming_inner(
715+
las_path: &Path,
716+
copc_path: &Path,
717+
params: &CopcWriterParams,
718+
spill_dir: &Path,
719+
cancel: &dyn CancelCheck,
720+
crs_wkt_override: Option<&str>,
681721
) -> Result<()> {
682722
cancel.check()?;
683723
let las_file = File::open(las_path).map_err(|e| Error::io("open source LAS/LAZ", e))?;
684724
let mut reader = las::Reader::new(BufReader::with_capacity(LAS_INPUT_BUFFER_BYTES, las_file))
685725
.map_err(|e| Error::Las(e.to_string()))?;
686726
let source_evlrs = read_all_source_evlrs(las_path)?;
687-
validate_las_conversion_supported(reader.header(), &source_evlrs)?;
688-
let output_metadata = OutputLasMetadata::from_las_header(reader.header(), &source_evlrs);
727+
validate_las_conversion_supported(reader.header(), &source_evlrs, crs_wkt_override)?;
728+
let output_metadata =
729+
OutputLasMetadata::from_las_header(reader.header(), &source_evlrs, crs_wkt_override);
689730
let layout = StreamingLayout::from_las_header(reader.header());
690731
let mut spill = SpillWriter::create(spill_dir, layout)?;
691732
for (index, result) in reader.points().enumerate() {
@@ -745,6 +786,7 @@ fn validate_streaming_layout_supported(layout: &StreamingLayout) -> Result<()> {
745786
fn validate_las_conversion_supported(
746787
header: &las::Header,
747788
source_evlrs: &[las::Vlr],
789+
crs_wkt_override: Option<&str>,
748790
) -> Result<()> {
749791
let mut unsupported = Vec::new();
750792
let format = header.point_format();
@@ -755,14 +797,15 @@ fn validate_las_conversion_supported(
755797
unsupported.push("waveform point data".to_string());
756798
}
757799
let source_has_wkt_crs_record = has_wkt_crs_record(header, source_evlrs);
800+
let has_crs_wkt_override = normalized_crs_wkt_override(crs_wkt_override).is_some();
758801
let mut geotiff_crs_record_count = 0usize;
759802
for vlr in header.vlrs() {
760-
if is_geotiff_crs_vlr(vlr) && !source_has_wkt_crs_record {
803+
if is_geotiff_crs_vlr(vlr) && !source_has_wkt_crs_record && !has_crs_wkt_override {
761804
geotiff_crs_record_count += 1;
762805
}
763806
}
764807
for evlr in source_evlrs {
765-
if is_geotiff_crs_vlr(evlr) && !source_has_wkt_crs_record {
808+
if is_geotiff_crs_vlr(evlr) && !source_has_wkt_crs_record && !has_crs_wkt_override {
766809
geotiff_crs_record_count += 1;
767810
}
768811
}
@@ -808,6 +851,10 @@ fn is_geotiff_crs_vlr(vlr: &las::Vlr) -> bool {
808851
)
809852
}
810853

854+
fn normalized_crs_wkt_override(crs_wkt_override: Option<&str>) -> Option<&str> {
855+
crs_wkt_override.filter(|crs_wkt| !crs_wkt.trim().is_empty())
856+
}
857+
811858
fn is_extra_bytes_descriptor_vlr(vlr: &las::Vlr) -> bool {
812859
vlr.user_id == LASF_SPEC_USER_ID && vlr.record_id == EXTRA_BYTES_RECORD_ID
813860
}
@@ -816,6 +863,10 @@ fn has_wkt_crs_record(header: &las::Header, source_evlrs: &[las::Vlr]) -> bool {
816863
header.vlrs().iter().any(is_wkt_crs_vlr) || source_evlrs.iter().any(is_wkt_crs_vlr)
817864
}
818865

866+
fn has_geotiff_crs_record(header: &las::Header, source_evlrs: &[las::Vlr]) -> bool {
867+
header.vlrs().iter().any(is_geotiff_crs_vlr) || source_evlrs.iter().any(is_geotiff_crs_vlr)
868+
}
869+
819870
fn extract_source_wkt_crs_records(
820871
header: &las::Header,
821872
source_evlrs: &[las::Vlr],
@@ -840,6 +891,18 @@ fn extract_source_wkt_crs_records(
840891
records
841892
}
842893

894+
fn wkt_override_crs_record(crs_wkt: &str) -> OutputCrsRecord {
895+
OutputCrsRecord {
896+
vlr: las::Vlr {
897+
user_id: LASF_PROJECTION_USER_ID.to_string(),
898+
record_id: WKT_CRS_RECORD_ID,
899+
description: "OGC WKT CRS".to_string(),
900+
data: crs_wkt.as_bytes().to_vec(),
901+
},
902+
is_extended: false,
903+
}
904+
}
905+
843906
fn extract_pass_through_vlrs(header: &las::Header) -> Vec<las::Vlr> {
844907
header
845908
.vlrs()

copc-writer/tests/write_parse.rs

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use byteorder::{LittleEndian, ReadBytesExt};
22
use copc_core::{Bounds, NeverCancel, StreamingLayout};
33
use copc_reader::{BoundsSelection, CopcFile, CopcReader, LodSelection};
44
use copc_writer::{
5-
convert_las_to_copc_streaming, write_source, write_streaming_with_cancel, CopcPointFields,
6-
CopcPointSource, CopcWriterParams,
5+
convert_las_to_copc_streaming, convert_las_to_copc_streaming_with_crs_wkt_override,
6+
write_source, write_streaming_with_cancel, CopcPointFields, CopcPointSource, CopcWriterParams,
77
};
88
use las::point::ScanDirection;
99
use las::{Color, Point};
@@ -1105,6 +1105,66 @@ fn streaming_conversion_rejects_geotiff_only_crs_with_specific_error() {
11051105
assert!(!message.contains("1 VLR(s)"));
11061106
}
11071107

1108+
#[test]
1109+
fn streaming_conversion_uses_crs_wkt_override_for_geotiff_only_crs() {
1110+
let dir = tempfile::tempdir().unwrap();
1111+
let las_path = dir.path().join("geotiff-crs-override.las");
1112+
let copc_path = dir.path().join("geotiff-crs-override.copc.laz");
1113+
let spill_dir = dir.path().join("spill");
1114+
std::fs::create_dir(&spill_dir).unwrap();
1115+
1116+
let crs_wkt = "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563]],PRIMEM[\"Greenwich\",0],UNIT[\"degree\",0.0174532925199433],AUTHORITY[\"EPSG\",\"4326\"]]";
1117+
let mut builder = las::Builder::from((1, 4));
1118+
builder.point_format = las::point::Format::new(6).unwrap();
1119+
builder.vlrs.push(las::Vlr {
1120+
user_id: "LASF_Projection".to_string(),
1121+
record_id: 34735,
1122+
description: "GeoTIFF GeoKeyDirectoryTag".to_string(),
1123+
data: vec![1, 1, 0, 0],
1124+
});
1125+
let mut writer = las::Writer::from_path(&las_path, builder.into_header().unwrap()).unwrap();
1126+
writer
1127+
.write(las::Point {
1128+
x: 1.0,
1129+
y: 2.0,
1130+
z: 3.0,
1131+
return_number: 1,
1132+
number_of_returns: 1,
1133+
gps_time: Some(1.0),
1134+
..Default::default()
1135+
})
1136+
.unwrap();
1137+
writer.close().unwrap();
1138+
1139+
convert_las_to_copc_streaming_with_crs_wkt_override(
1140+
&las_path,
1141+
&copc_path,
1142+
&CopcWriterParams::default(),
1143+
&spill_dir,
1144+
&NeverCancel,
1145+
Some(crs_wkt),
1146+
)
1147+
.unwrap();
1148+
1149+
let header = read_las_header_prefix(&copc_path);
1150+
assert_eq!(3, header.number_of_vlrs);
1151+
assert_eq!(16, header.global_encoding & 16);
1152+
1153+
let reader = las::Reader::from_path(&copc_path).unwrap();
1154+
assert!(reader.header().has_wkt_crs());
1155+
let crs_records: Vec<_> = reader
1156+
.header()
1157+
.all_vlrs()
1158+
.filter(|vlr| vlr.user_id == "LASF_Projection" && vlr.record_id == 2112)
1159+
.collect();
1160+
assert_eq!(1, crs_records.len());
1161+
assert_eq!(crs_wkt.as_bytes(), crs_records[0].data.as_slice());
1162+
assert!(!reader
1163+
.header()
1164+
.all_vlrs()
1165+
.any(|vlr| vlr.user_id == "LASF_Projection" && vlr.record_id == 34735));
1166+
}
1167+
11081168
#[test]
11091169
fn streaming_conversion_allows_source_laszip_vlr() {
11101170
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)