spatialrust_interchange/tiles3d/
copc.rs1use std::path::Path;
10
11use spatialrust_core::HasPositions3;
12use spatialrust_io::CopcNode;
13
14use crate::tiles3d::builder::{BuiltTile, BuiltTileset, TilesetWriteReceipt};
15use crate::tiles3d::pnts::{encode_pnts, PntsFeatureTable};
16use crate::tiles3d::tileset::{
17 serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, Tileset,
18};
19use crate::{InterchangeError, InterchangeResult};
20
21#[derive(Clone, Debug)]
23pub struct CopcTilesetOptions {
24 pub max_level: Option<i32>,
26 pub refine: Refinement,
28 pub root_geometric_error: Option<f64>,
30 pub geometric_error_scale: f64,
32}
33
34impl Default for CopcTilesetOptions {
35 fn default() -> Self {
36 Self {
37 max_level: None,
38 refine: Refinement::Replace,
39 root_geometric_error: None,
40 geometric_error_scale: 0.5,
41 }
42 }
43}
44
45impl CopcTilesetOptions {
46 fn validate(&self) -> InterchangeResult<()> {
47 if let Some(level) = self.max_level {
48 if level < 0 {
49 return Err(InterchangeError::InvalidConfiguration(
50 "max_level must be non-negative".into(),
51 ));
52 }
53 }
54 if let Some(error) = self.root_geometric_error {
55 if !error.is_finite() || error < 0.0 {
56 return Err(InterchangeError::InvalidConfiguration(
57 "root_geometric_error must be finite and non-negative".into(),
58 ));
59 }
60 }
61 if !self.geometric_error_scale.is_finite()
62 || !(0.0..=1.0).contains(&self.geometric_error_scale)
63 {
64 return Err(InterchangeError::InvalidConfiguration(
65 "geometric_error_scale must be within [0, 1]".into(),
66 ));
67 }
68 Ok(())
69 }
70}
71
72pub fn export_copc_tileset(
75 copc_path: impl AsRef<Path>,
76 out_dir: impl AsRef<Path>,
77 options: &CopcTilesetOptions,
78) -> InterchangeResult<TilesetWriteReceipt> {
79 options.validate()?;
80 let mut reader = spatialrust_io::CopcNodeReader::open(copc_path.as_ref())
81 .map_err(|error| InterchangeError::InvalidConfiguration(format!("COPC open: {error}")))?;
82
83 let nodes: Vec<CopcNode> = reader
84 .nodes()
85 .iter()
86 .filter(|node| options.max_level.map_or(true, |level| node.level <= level))
87 .copied()
88 .collect();
89 if nodes.is_empty() {
90 return Err(InterchangeError::InvalidConfiguration(
91 "COPC file contains no nodes at or above max_level".into(),
92 ));
93 }
94
95 let root_error = match options.root_geometric_error {
96 Some(error) => error,
97 None => bounds_diagonal(nodes[0].bounds.min, nodes[0].bounds.max),
98 };
99
100 let index_of = |level: i32, x: i32, y: i32, z: i32| -> Option<usize> {
101 nodes
102 .iter()
103 .position(|node| node.level == level && node.x == x && node.y == y && node.z == z)
104 };
105
106 let mut tiles: Vec<BuiltTile> = Vec::with_capacity(nodes.len());
107 let mut children_by_parent: Vec<Vec<usize>> = vec![Vec::new(); nodes.len()];
108 for (index, node) in nodes.iter().enumerate() {
109 if node.level == 0 {
110 continue;
111 }
112 if let Some(parent) = index_of(node.level - 1, node.x >> 1, node.y >> 1, node.z >> 1) {
113 children_by_parent[parent].push(index);
114 }
115 }
116 for children in &mut children_by_parent {
117 children.sort_unstable();
118 }
119
120 let mut root_tile_index = None;
122 for (index, node) in nodes.iter().enumerate() {
123 if node.level == 0 {
124 root_tile_index = Some(index);
125 break;
126 }
127 }
128 let root_index = root_tile_index.ok_or_else(|| {
129 InterchangeError::InvalidConfiguration("COPC file has no level-0 root node".into())
130 })?;
131
132 fn materialize(
133 reader: &mut spatialrust_io::CopcNodeReader,
134 nodes: &[CopcNode],
135 children: &[Vec<usize>],
136 tiles: &mut Vec<BuiltTile>,
137 index: usize,
138 parent_error: f64,
139 scale: f64,
140 ) -> InterchangeResult<Tile> {
141 let node = &nodes[index];
142 let bounds = node.bounds;
143 let center = [
144 (bounds.min[0] + bounds.max[0]) * 0.5,
145 (bounds.min[1] + bounds.max[1]) * 0.5,
146 (bounds.min[2] + bounds.max[2]) * 0.5,
147 ];
148 let geometric_error = if node.level == 0 { parent_error } else { parent_error * scale };
149
150 let cloud = reader.read_node(index).map_err(|error| {
151 InterchangeError::InvalidConfiguration(format!("COPC node: {error}"))
152 })?;
153 let (x, y, z) = cloud.positions3().map_err(|error| {
154 InterchangeError::InvalidConfiguration(format!("COPC schema: {error}"))
155 })?;
156 let rgb = extract_rgb(&cloud);
157 let mut local_positions = Vec::with_capacity(cloud.len() * 3);
158 for point_index in 0..cloud.len() {
159 local_positions.push(x[point_index] - center[0] as f32);
160 local_positions.push(y[point_index] - center[1] as f32);
161 local_positions.push(z[point_index] - center[2] as f32);
162 }
163 let table = PntsFeatureTable { positions: local_positions, rgb, rtc_center: Some(center) };
164 let pnts = encode_pnts(&table)?;
165 let uri = format!("{}.pnts", tiles.len());
166 let point_count = table.point_count();
167 tiles.push(BuiltTile { uri: uri.clone(), pnts, point_count });
168
169 let mut child_tiles = Vec::new();
170 for &child in &children[index] {
171 child_tiles.push(materialize(
172 reader,
173 nodes,
174 children,
175 tiles,
176 child,
177 geometric_error,
178 scale,
179 )?);
180 }
181
182 Ok(Tile {
183 bounding_volume: BoundingVolume::box_from_bounds(bounds.min, bounds.max)?,
184 geometric_error,
185 refine: None,
186 content: Some(TileContent { uri }),
187 children: child_tiles,
188 })
189 }
190
191 let root_tile = materialize(
192 &mut reader,
193 &nodes,
194 &children_by_parent,
195 &mut tiles,
196 root_index,
197 root_error,
198 options.geometric_error_scale,
199 )?;
200 let mut root_tile = root_tile;
201 root_tile.refine = Some(options.refine);
202
203 let built =
204 BuiltTileset { tileset: Tileset { geometric_error: root_error, root: root_tile }, tiles };
205 write_built(out_dir.as_ref(), &built)
206}
207
208fn write_built(dir: &Path, built: &BuiltTileset) -> InterchangeResult<TilesetWriteReceipt> {
210 std::fs::create_dir_all(dir).map_err(io_error)?;
211 let document = serialize_tileset_json(&built.tileset)?;
212 std::fs::write(dir.join("tileset.json"), document.as_bytes()).map_err(io_error)?;
213
214 let mut tile_count = 0u64;
215 let mut point_count = 0u64;
216 let mut pnts_bytes = 0u64;
217 for tile in &built.tiles {
218 std::fs::write(dir.join(&tile.uri), &tile.pnts).map_err(io_error)?;
219 tile_count += 1;
220 point_count = point_count
221 .checked_add(tile.point_count as u64)
222 .ok_or_else(|| InterchangeError::InvalidConfiguration("point count overflow".into()))?;
223 pnts_bytes = pnts_bytes.checked_add(tile.pnts.len() as u64).ok_or_else(|| {
224 InterchangeError::InvalidConfiguration("pnts byte count overflow".into())
225 })?;
226 }
227 Ok(TilesetWriteReceipt {
228 tileset_json_bytes: document.len() as u64,
229 tile_count,
230 point_count,
231 pnts_bytes,
232 })
233}
234
235fn bounds_diagonal(min: [f64; 3], max: [f64; 3]) -> f64 {
236 let dx = max[0] - min[0];
237 let dy = max[1] - min[1];
238 let dz = max[2] - min[2];
239 (dx * dx + dy * dy + dz * dz).sqrt()
240}
241
242fn extract_rgb(cloud: &spatialrust_core::PointCloud) -> Option<Vec<u8>> {
248 use spatialrust_core::{FieldSemantic, PointBuffer};
249
250 let field = |semantic: FieldSemantic| {
251 let name = match semantic {
252 FieldSemantic::ColorR => "red",
253 FieldSemantic::ColorG => "green",
254 FieldSemantic::ColorB => "blue",
255 _ => return None,
256 };
257 let buffer = cloud.field(name).ok()?;
258 match buffer {
259 PointBuffer::U16(values) => Some(values.as_slice()),
260 _ => None,
261 }
262 };
263
264 let (r, g, b) = match (
265 field(FieldSemantic::ColorR),
266 field(FieldSemantic::ColorG),
267 field(FieldSemantic::ColorB),
268 ) {
269 (Some(r), Some(g), Some(b)) => (r, g, b),
270 _ => return None,
271 };
272 if r.len() != cloud.len() || g.len() != cloud.len() || b.len() != cloud.len() {
273 return None;
274 }
275 let mut out = Vec::with_capacity(cloud.len() * 3);
276 for index in 0..cloud.len() {
277 out.push((r[index] >> 8) as u8);
278 out.push((g[index] >> 8) as u8);
279 out.push((b[index] >> 8) as u8);
280 }
281 Some(out)
282}
283
284fn io_error(error: std::io::Error) -> InterchangeError {
285 InterchangeError::InvalidConfiguration(format!("tileset IO failure: {error}"))
286}
287
288#[cfg(test)]
289mod tests {
290 use super::{export_copc_tileset, CopcTilesetOptions};
291 use crate::tiles3d::pnts::decode_pnts;
292 use crate::tiles3d::tileset::parse_tileset_json;
293 use spatialrust_core::PointCloudBuilder;
294 use spatialrust_io::{write_copc_file, write_copc_file_with_params, CopcWriterParams};
295
296 fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud {
297 let mut builder = PointCloudBuilder::xyz();
298 for index in 0..count {
299 let x = (index % 31) as f32 - 15.0;
300 let y = ((index / 31) % 29) as f32 - 14.0;
301 let z = ((index / (31 * 29)) % 23) as f32 - 11.0;
302 builder.push_point([x, y, z]).unwrap();
303 }
304 builder.build().unwrap()
305 }
306
307 #[test]
308 fn exports_copc_without_full_materialization() {
309 let cloud = dense_grid_cloud(7_000);
310 let copc_path = std::env::temp_dir()
311 .join(format!("spatialrust_tiles3d_copc_{}.copc.laz", std::process::id()));
312 write_copc_file_with_params(
313 &copc_path,
314 &cloud,
315 &CopcWriterParams { max_points_per_node: 96, max_depth: 8 },
316 )
317 .unwrap();
318
319 let out_dir = std::env::temp_dir()
320 .join(format!("spatialrust_tiles3d_copc_out_{}", std::process::id()));
321 let receipt =
322 export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap();
323 assert_eq!(receipt.point_count, cloud.len() as u64);
324 assert!(receipt.tile_count > 1);
325
326 let document = std::fs::read_to_string(out_dir.join("tileset.json")).unwrap();
327 let tileset = parse_tileset_json(&document).unwrap();
328 assert_eq!(tileset.root.content.as_ref().unwrap().uri, "0.pnts");
329
330 for tile in 0..receipt.tile_count {
331 let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap();
332 assert!(decode_pnts(&pnts).is_ok());
333 }
334
335 let _ = std::fs::remove_dir_all(&out_dir);
336 let _ = std::fs::remove_file(&copc_path);
337 }
338
339 #[test]
340 fn max_level_bounds_export() {
341 let cloud = dense_grid_cloud(7_000);
342 let copc_path = std::env::temp_dir()
343 .join(format!("spatialrust_tiles3d_copc_lvl_{}.copc.laz", std::process::id()));
344 write_copc_file_with_params(
345 &copc_path,
346 &cloud,
347 &CopcWriterParams { max_points_per_node: 96, max_depth: 8 },
348 )
349 .unwrap();
350
351 let out_dir = std::env::temp_dir()
352 .join(format!("spatialrust_tiles3d_copc_lvl_out_{}", std::process::id()));
353 let receipt = export_copc_tileset(
354 &copc_path,
355 &out_dir,
356 &CopcTilesetOptions { max_level: Some(0), ..Default::default() },
357 )
358 .unwrap();
359 assert_eq!(receipt.tile_count, 1);
360 assert!(
361 receipt.point_count < cloud.len() as u64,
362 "level-0 export must expose only the root node chunk"
363 );
364
365 let _ = std::fs::remove_dir_all(&out_dir);
366 let _ = std::fs::remove_file(&copc_path);
367 }
368
369 #[test]
370 fn preserves_rgb_from_las_color() {
371 use spatialrust_core::{PointCloudBuilder, StandardSchemas};
372
373 let mut builder = PointCloudBuilder::new(StandardSchemas::point_xyzrgb());
374 for index in 0..2_000usize {
375 let x = (index % 31) as f32 - 15.0;
376 let y = ((index / 31) % 29) as f32 - 14.0;
377 let z = ((index / (31 * 29)) % 23) as f32 - 11.0;
378 let r = ((index % 256) << 8) as f32;
379 let g = (((index * 7) % 256) << 8) as f32;
380 let b = (((index * 13) % 256) << 8) as f32;
381 builder.push_point([x, y, z, r, g, b]).unwrap();
382 }
383 let cloud = builder.build().unwrap();
384
385 let copc_path = std::env::temp_dir()
386 .join(format!("spatialrust_tiles3d_copc_rgb_{}.copc.laz", std::process::id()));
387 write_copc_file(&copc_path, &cloud).unwrap();
388
389 let out_dir = std::env::temp_dir()
390 .join(format!("spatialrust_tiles3d_copc_rgb_out_{}", std::process::id()));
391 let receipt =
392 export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap();
393 assert_eq!(receipt.point_count, cloud.len() as u64);
394
395 let mut rgb_points = 0usize;
396 for tile in 0..receipt.tile_count {
397 let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap();
398 let decoded = decode_pnts(&pnts).unwrap();
399 let rgb = decoded.rgb.as_ref().expect("color-bearing COPC must write RGB");
400 assert_eq!(rgb.len(), decoded.point_count() * 3);
401 rgb_points += decoded.point_count();
402 }
403 assert_eq!(rgb_points, cloud.len());
404
405 let _ = std::fs::remove_dir_all(&out_dir);
406 let _ = std::fs::remove_file(&copc_path);
407 }
408}