diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0275c..d1031ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **Epic 147E bounded COPC → 3D Tiles exporter** (`interchange-tiles3d-copc`): + `spatialrust-io` gains `CopcNodeReader`, which opens a COPC file once, loads + only hierarchy metadata, and yields one `PointCloud` per octree node in + deterministic `(level, x, y, z)` order. `spatialrust-interchange` adds + `export_copc_tileset`, which mirrors the COPC octree into a `tileset.json` + with one `pnts` tile per node and a per-tile `RTC_CENTER`, without ever + materializing the whole cloud. `max_level` bounds the exported hierarchy, + and the `tiles3d_copc_export` example converts a `.copc.laz` file directly. - **Epic 147 OGC 3D Tiles 1.1 point-cloud tileset export** (`interchange-tiles3d`): a dependency-light `pnts` binary codec, a validated `tileset.json` model with box bounding volumes and geometric error, and a deterministic octree tileset diff --git a/crates/spatialrust-interchange/Cargo.toml b/crates/spatialrust-interchange/Cargo.toml index 1ce2446..a2758d8 100644 --- a/crates/spatialrust-interchange/Cargo.toml +++ b/crates/spatialrust-interchange/Cargo.toml @@ -13,9 +13,12 @@ default = [] gltf = [] openusd = [] tiles3d = [] -full = ["gltf", "openusd", "tiles3d"] +tiles3d-copc = ["tiles3d", "dep:spatialrust-io", "dep:spatialrust-core"] +full = ["gltf", "openusd", "tiles3d", "tiles3d-copc"] [dependencies] spatialrust-scene.workspace = true spatialrust-math.workspace = true +spatialrust-core = { workspace = true, optional = true } +spatialrust-io = { workspace = true, optional = true, features = ["io-copc"] } thiserror.workspace = true diff --git a/crates/spatialrust-interchange/src/lib.rs b/crates/spatialrust-interchange/src/lib.rs index 30abb2f..cba01d4 100644 --- a/crates/spatialrust-interchange/src/lib.rs +++ b/crates/spatialrust-interchange/src/lib.rs @@ -31,6 +31,8 @@ pub use tiles3d::{ write_point_tileset, BoundingVolume, BuiltTile, BuiltTileset, PntsFeatureTable, Refinement, Tile, TileContent, Tileset, TilesetBuilderOptions, TilesetWriteReceipt, }; +#[cfg(feature = "tiles3d-copc")] +pub use tiles3d::{export_copc_tileset, CopcTilesetOptions}; #[cfg(feature = "openusd")] pub use usd::{ export_stage_usda, import_mesh_from_usda, MemoryUsdStageAdapter, UsdPrimPath, UsdStageAdapter, diff --git a/crates/spatialrust-interchange/src/tiles3d/copc.rs b/crates/spatialrust-interchange/src/tiles3d/copc.rs new file mode 100644 index 0000000..05667d3 --- /dev/null +++ b/crates/spatialrust-interchange/src/tiles3d/copc.rs @@ -0,0 +1,326 @@ +//! Bounded COPC → 3D Tiles 1.1 tileset export. +//! +//! Opens a COPC file once, walks its octree hierarchy in deterministic order, +//! and writes one `pnts` tile per COPC node plus a `tileset.json` that mirrors +//! the octree parent/child structure. The cloud is never materialized as a +//! whole: each node is decoded, re-centered to its own `RTC_CENTER`, encoded, +//! and dropped before the next node is processed. + +use std::path::Path; + +use spatialrust_core::HasPositions3; +use spatialrust_io::CopcNode; + +use crate::tiles3d::builder::{BuiltTile, BuiltTileset, TilesetWriteReceipt}; +use crate::tiles3d::pnts::{encode_pnts, PntsFeatureTable}; +use crate::tiles3d::tileset::{ + serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, Tileset, +}; +use crate::{InterchangeError, InterchangeResult}; + +/// Options controlling COPC → 3D Tiles export. +#[derive(Clone, Debug)] +pub struct CopcTilesetOptions { + /// Maximum octree depth to export; `None` exports the whole hierarchy. + pub max_level: Option, + /// Refinement policy recorded on the root tile. + pub refine: Refinement, + /// Root geometric error; defaults to the root bounds diagonal when `None`. + pub root_geometric_error: Option, + /// Multiplier applied to the parent geometric error for each child level. + pub geometric_error_scale: f64, +} + +impl Default for CopcTilesetOptions { + fn default() -> Self { + Self { + max_level: None, + refine: Refinement::Replace, + root_geometric_error: None, + geometric_error_scale: 0.5, + } + } +} + +impl CopcTilesetOptions { + fn validate(&self) -> InterchangeResult<()> { + if let Some(level) = self.max_level { + if level < 0 { + return Err(InterchangeError::InvalidConfiguration( + "max_level must be non-negative".into(), + )); + } + } + if let Some(error) = self.root_geometric_error { + if !error.is_finite() || error < 0.0 { + return Err(InterchangeError::InvalidConfiguration( + "root_geometric_error must be finite and non-negative".into(), + )); + } + } + if !self.geometric_error_scale.is_finite() + || !(0.0..=1.0).contains(&self.geometric_error_scale) + { + return Err(InterchangeError::InvalidConfiguration( + "geometric_error_scale must be within [0, 1]".into(), + )); + } + Ok(()) + } +} + +/// Exports a COPC file into a 3D Tiles 1.1 tileset without materializing the +/// whole cloud. Returns the byte/point/tile receipt. +pub fn export_copc_tileset( + copc_path: impl AsRef, + out_dir: impl AsRef, + options: &CopcTilesetOptions, +) -> InterchangeResult { + options.validate()?; + let mut reader = spatialrust_io::CopcNodeReader::open(copc_path.as_ref()) + .map_err(|error| InterchangeError::InvalidConfiguration(format!("COPC open: {error}")))?; + + let nodes: Vec = reader + .nodes() + .iter() + .filter(|node| options.max_level.map_or(true, |level| node.level <= level)) + .copied() + .collect(); + if nodes.is_empty() { + return Err(InterchangeError::InvalidConfiguration( + "COPC file contains no nodes at or above max_level".into(), + )); + } + + let root_error = match options.root_geometric_error { + Some(error) => error, + None => bounds_diagonal(nodes[0].bounds.min, nodes[0].bounds.max), + }; + + let index_of = |level: i32, x: i32, y: i32, z: i32| -> Option { + nodes + .iter() + .position(|node| node.level == level && node.x == x && node.y == y && node.z == z) + }; + + let mut tiles: Vec = Vec::with_capacity(nodes.len()); + let mut children_by_parent: Vec> = vec![Vec::new(); nodes.len()]; + for (index, node) in nodes.iter().enumerate() { + if node.level == 0 { + continue; + } + if let Some(parent) = index_of(node.level - 1, node.x >> 1, node.y >> 1, node.z >> 1) { + children_by_parent[parent].push(index); + } + } + for children in &mut children_by_parent { + children.sort_unstable(); + } + + // Recursive materialization in deterministic parent-before-child order. + let mut root_tile_index = None; + for (index, node) in nodes.iter().enumerate() { + if node.level == 0 { + root_tile_index = Some(index); + break; + } + } + let root_index = root_tile_index.ok_or_else(|| { + InterchangeError::InvalidConfiguration("COPC file has no level-0 root node".into()) + })?; + + fn materialize( + reader: &mut spatialrust_io::CopcNodeReader, + nodes: &[CopcNode], + children: &[Vec], + tiles: &mut Vec, + index: usize, + parent_error: f64, + scale: f64, + ) -> InterchangeResult { + let node = &nodes[index]; + let bounds = node.bounds; + let center = [ + (bounds.min[0] + bounds.max[0]) * 0.5, + (bounds.min[1] + bounds.max[1]) * 0.5, + (bounds.min[2] + bounds.max[2]) * 0.5, + ]; + let geometric_error = if node.level == 0 { parent_error } else { parent_error * scale }; + + let cloud = reader.read_node(index).map_err(|error| { + InterchangeError::InvalidConfiguration(format!("COPC node: {error}")) + })?; + let (x, y, z) = cloud.positions3().map_err(|error| { + InterchangeError::InvalidConfiguration(format!("COPC schema: {error}")) + })?; + let mut local_positions = Vec::with_capacity(cloud.len() * 3); + for point_index in 0..cloud.len() { + local_positions.push(x[point_index] - center[0] as f32); + local_positions.push(y[point_index] - center[1] as f32); + local_positions.push(z[point_index] - center[2] as f32); + } + let table = + PntsFeatureTable { positions: local_positions, rgb: None, rtc_center: Some(center) }; + let pnts = encode_pnts(&table)?; + let uri = format!("{}.pnts", tiles.len()); + let point_count = table.point_count(); + tiles.push(BuiltTile { uri: uri.clone(), pnts, point_count }); + + let mut child_tiles = Vec::new(); + for &child in &children[index] { + child_tiles.push(materialize( + reader, + nodes, + children, + tiles, + child, + geometric_error, + scale, + )?); + } + + Ok(Tile { + bounding_volume: BoundingVolume::box_from_bounds(bounds.min, bounds.max)?, + geometric_error, + refine: None, + content: Some(TileContent { uri }), + children: child_tiles, + }) + } + + let root_tile = materialize( + &mut reader, + &nodes, + &children_by_parent, + &mut tiles, + root_index, + root_error, + options.geometric_error_scale, + )?; + let mut root_tile = root_tile; + root_tile.refine = Some(options.refine); + + let built = + BuiltTileset { tileset: Tileset { geometric_error: root_error, root: root_tile }, tiles }; + write_built(out_dir.as_ref(), &built) +} + +/// Writes a built tileset to `dir` and returns the receipt. +fn write_built(dir: &Path, built: &BuiltTileset) -> InterchangeResult { + std::fs::create_dir_all(dir).map_err(io_error)?; + let document = serialize_tileset_json(&built.tileset)?; + std::fs::write(dir.join("tileset.json"), document.as_bytes()).map_err(io_error)?; + + let mut tile_count = 0u64; + let mut point_count = 0u64; + let mut pnts_bytes = 0u64; + for tile in &built.tiles { + std::fs::write(dir.join(&tile.uri), &tile.pnts).map_err(io_error)?; + tile_count += 1; + point_count = point_count + .checked_add(tile.point_count as u64) + .ok_or_else(|| InterchangeError::InvalidConfiguration("point count overflow".into()))?; + pnts_bytes = pnts_bytes.checked_add(tile.pnts.len() as u64).ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts byte count overflow".into()) + })?; + } + Ok(TilesetWriteReceipt { + tileset_json_bytes: document.len() as u64, + tile_count, + point_count, + pnts_bytes, + }) +} + +fn bounds_diagonal(min: [f64; 3], max: [f64; 3]) -> f64 { + let dx = max[0] - min[0]; + let dy = max[1] - min[1]; + let dz = max[2] - min[2]; + (dx * dx + dy * dy + dz * dz).sqrt() +} + +fn io_error(error: std::io::Error) -> InterchangeError { + InterchangeError::InvalidConfiguration(format!("tileset IO failure: {error}")) +} + +#[cfg(test)] +mod tests { + use super::{export_copc_tileset, CopcTilesetOptions}; + use crate::tiles3d::pnts::decode_pnts; + use crate::tiles3d::tileset::parse_tileset_json; + use spatialrust_core::PointCloudBuilder; + use spatialrust_io::{write_copc_file_with_params, CopcWriterParams}; + + fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud { + let mut builder = PointCloudBuilder::xyz(); + for index in 0..count { + let x = (index % 31) as f32 - 15.0; + let y = ((index / 31) % 29) as f32 - 14.0; + let z = ((index / (31 * 29)) % 23) as f32 - 11.0; + builder.push_point([x, y, z]).unwrap(); + } + builder.build().unwrap() + } + + #[test] + fn exports_copc_without_full_materialization() { + let cloud = dense_grid_cloud(7_000); + let copc_path = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_{}.copc.laz", std::process::id())); + write_copc_file_with_params( + &copc_path, + &cloud, + &CopcWriterParams { max_points_per_node: 96, max_depth: 8 }, + ) + .unwrap(); + + let out_dir = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_out_{}", std::process::id())); + let receipt = + export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap(); + assert_eq!(receipt.point_count, cloud.len() as u64); + assert!(receipt.tile_count > 1); + + let document = std::fs::read_to_string(out_dir.join("tileset.json")).unwrap(); + let tileset = parse_tileset_json(&document).unwrap(); + assert_eq!(tileset.root.content.as_ref().unwrap().uri, "0.pnts"); + + for tile in 0..receipt.tile_count { + let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap(); + assert!(decode_pnts(&pnts).is_ok()); + } + + let _ = std::fs::remove_dir_all(&out_dir); + let _ = std::fs::remove_file(&copc_path); + } + + #[test] + fn max_level_bounds_export() { + let cloud = dense_grid_cloud(7_000); + let copc_path = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_lvl_{}.copc.laz", std::process::id())); + write_copc_file_with_params( + &copc_path, + &cloud, + &CopcWriterParams { max_points_per_node: 96, max_depth: 8 }, + ) + .unwrap(); + + let out_dir = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_lvl_out_{}", std::process::id())); + let receipt = export_copc_tileset( + &copc_path, + &out_dir, + &CopcTilesetOptions { max_level: Some(0), ..Default::default() }, + ) + .unwrap(); + assert_eq!(receipt.tile_count, 1); + assert!( + receipt.point_count < cloud.len() as u64, + "level-0 export must expose only the root node chunk" + ); + + let _ = std::fs::remove_dir_all(&out_dir); + let _ = std::fs::remove_file(&copc_path); + } +} diff --git a/crates/spatialrust-interchange/src/tiles3d/mod.rs b/crates/spatialrust-interchange/src/tiles3d/mod.rs index 8a87fa9..1236988 100644 --- a/crates/spatialrust-interchange/src/tiles3d/mod.rs +++ b/crates/spatialrust-interchange/src/tiles3d/mod.rs @@ -6,6 +6,8 @@ //! caller-owned host memory. mod builder; +#[cfg(feature = "tiles3d-copc")] +mod copc; mod pnts; mod tileset; @@ -13,6 +15,8 @@ pub use builder::{ build_point_tileset, write_point_tileset, BuiltTile, BuiltTileset, TilesetBuilderOptions, TilesetWriteReceipt, }; +#[cfg(feature = "tiles3d-copc")] +pub use copc::{export_copc_tileset, CopcTilesetOptions}; pub use pnts::{decode_pnts, encode_pnts, PntsFeatureTable}; pub use tileset::{ parse_tileset_json, serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, diff --git a/crates/spatialrust-io/src/copc/mod.rs b/crates/spatialrust-io/src/copc/mod.rs index 9ee162e..e5516d0 100644 --- a/crates/spatialrust-io/src/copc/mod.rs +++ b/crates/spatialrust-io/src/copc/mod.rs @@ -14,7 +14,7 @@ pub use query::{copc_level_for_resolution, CopcBounds, CopcFileInfo, CopcQuery}; pub use reader::CopcChunkSource; pub use reader::{ read_copc, read_copc_file, read_copc_file_in_bounds, read_copc_file_info, - read_copc_file_with_query, CopcReader, + read_copc_file_with_query, CopcNode, CopcNodeReader, CopcReader, }; pub use writer::{write_copc, write_copc_file, write_copc_file_with_params, CopcWriter}; #[cfg(feature = "streaming")] diff --git a/crates/spatialrust-io/src/copc/reader.rs b/crates/spatialrust-io/src/copc/reader.rs index 160cfe7..84bf360 100644 --- a/crates/spatialrust-io/src/copc/reader.rs +++ b/crates/spatialrust-io/src/copc/reader.rs @@ -410,6 +410,101 @@ impl BoundedSpatialRecordSource for CopcChunkSource { } } +/// One COPC octree node exposed by [`CopcNodeReader`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CopcNode { + /// Octree level; 0 is the root. + pub level: i32, + /// Node X index at `level`. + pub x: i32, + /// Node Y index at `level`. + pub y: i32, + /// Node Z index at `level`. + pub z: i32, + /// World-space bounds of this node. + pub bounds: crate::copc::CopcBounds, + /// Points materialized by this node's chunk. + pub point_count: u64, +} + +/// Bounded per-node COPC reader. +/// +/// Opens a COPC file once, loads the full hierarchy (metadata only), and +/// exposes one [`PointCloud`] per node on demand. Nodes are returned in +/// deterministic `(level, x, y, z)` order so the hierarchy can be turned +/// into a tile set without materializing the whole cloud. +pub struct CopcNodeReader { + reader: CopcStreamingReader, + keys: Vec, + root: copc_streaming::Aabb, + nodes: Vec, + schema: PointSchema, + metadata: SpatialMetadata, +} + +impl CopcNodeReader { + /// Opens a local COPC file and loads its hierarchy eagerly. + pub fn open(path: impl AsRef) -> Result { + let source = + FileSource::open(path.as_ref()).map_err(|error| copc_parse(error.to_string()))?; + let mut reader = pollster::block_on(CopcStreamingReader::open(source)) + .map_err(|error| copc_parse(error.to_string()))?; + pollster::block_on(reader.load_all_hierarchy()) + .map_err(|error| copc_parse(error.to_string()))?; + let root = reader.copc_info().root_bounds(); + let schema = schema_for_las_header(reader.header().las_header()); + let metadata = metadata_from_las_header(); + + let mut keys: Vec<_> = reader + .entries() + .filter(|(_, entry)| entry.point_count > 0) + .map(|(key, _)| *key) + .collect(); + keys.sort_by_key(|key| (key.level, key.x, key.y, key.z)); + + let mut nodes = Vec::with_capacity(keys.len()); + for key in &keys { + let bounds = key.bounds(&root); + let point_count = + reader.get(key).map(|entry| u64::from(entry.point_count)).unwrap_or(0); + nodes.push(CopcNode { + level: key.level, + x: key.x, + y: key.y, + z: key.z, + bounds: crate::copc::CopcBounds::new(bounds.min, bounds.max), + point_count, + }); + } + Ok(Self { reader, keys, root, nodes, schema, metadata }) + } + + /// Node descriptors in deterministic `(level, x, y, z)` order. + #[must_use] + pub fn nodes(&self) -> &[CopcNode] { + &self.nodes + } + + /// Root octree bounds. + #[must_use] + pub fn root_bounds(&self) -> crate::copc::CopcBounds { + crate::copc::CopcBounds::new(self.root.min, self.root.max) + } + + /// Reads the points of one node by index into [`Self::nodes`]. + pub fn read_node(&mut self, index: usize) -> Result { + let key = *self + .keys + .get(index) + .ok_or_else(|| copc_parse(format!("COPC node index {index} is out of range")))?; + let chunk = pollster::block_on(self.reader.fetch_chunk(&key)) + .map_err(|error| copc_parse(error.to_string()))?; + let points = + self.reader.read_points(&chunk).map_err(|error| copc_parse(error.to_string()))?; + point_cloud_from_las_points(self.schema.clone(), self.metadata.clone(), points) + } +} + #[cfg(test)] mod tests { use super::{read_copc_file, read_copc_file_info, read_copc_file_with_query, CopcQuery}; @@ -521,6 +616,42 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn copc_node_reader_enumerates_hierarchy_bounded() { + use copc_writer::CopcWriterParams; + + use crate::copc::writer::write_copc_file_with_params; + + let cloud = dense_grid_cloud(7_000); + let path = std::env::temp_dir() + .join(format!("spatialrust_copc_nodes_{}.copc.laz", std::process::id())); + write_copc_file_with_params( + &path, + &cloud, + &CopcWriterParams { max_points_per_node: 96, max_depth: 8 }, + ) + .unwrap(); + + let mut reader = super::CopcNodeReader::open(&path).unwrap(); + let nodes = reader.nodes().to_vec(); + assert!(nodes.len() > 1, "expected a multi-node hierarchy"); + assert!(nodes.iter().all(|node| node.point_count > 0)); + assert!( + nodes.windows(2).all(|pair| (pair[0].level, pair[0].x, pair[0].y, pair[0].z) + <= (pair[1].level, pair[1].x, pair[1].y, pair[1].z)), + "nodes must be deterministically ordered" + ); + + let mut total = 0u64; + for index in 0..nodes.len() { + let cloud = reader.read_node(index).unwrap(); + assert_eq!(cloud.len() as u64, nodes[index].point_count); + total += nodes[index].point_count; + } + assert_eq!(total, cloud.len() as u64, "per-node reads must reconstruct the cloud"); + let _ = std::fs::remove_file(path); + } + fn dense_grid_cloud(count: usize) -> spatialrust_core::PointCloud { use spatialrust_core::PointCloudBuilder; diff --git a/crates/spatialrust-io/src/lib.rs b/crates/spatialrust-io/src/lib.rs index 936a75e..726e08a 100644 --- a/crates/spatialrust-io/src/lib.rs +++ b/crates/spatialrust-io/src/lib.rs @@ -94,8 +94,8 @@ pub use e57::{ pub use copc::{ copc_level_for_resolution, read_copc, read_copc_file, read_copc_file_in_bounds, read_copc_file_info, read_copc_file_with_query, write_copc, write_copc_file, - write_copc_file_with_params, CopcBounds, CopcFileInfo, CopcQuery, CopcReader, CopcWriter, - CopcWriterParams, + write_copc_file_with_params, CopcBounds, CopcFileInfo, CopcNode, CopcNodeReader, CopcQuery, + CopcReader, CopcWriter, CopcWriterParams, }; #[cfg(all(feature = "io-copc", feature = "streaming"))] pub use copc::{write_copc_stream, CopcChunkSource, CopcStreamingWriteReceipt}; diff --git a/crates/spatialrust/Cargo.toml b/crates/spatialrust/Cargo.toml index acb8f1f..7bceaa5 100644 --- a/crates/spatialrust/Cargo.toml +++ b/crates/spatialrust/Cargo.toml @@ -230,7 +230,17 @@ interchange-tiles3d = [ "dep:spatialrust-interchange", "spatialrust-interchange/tiles3d", ] -interchange-full = ["interchange-gltf", "interchange-openusd", "interchange-tiles3d"] +interchange-tiles3d-copc = [ + "interchange-tiles3d", + "io-copc", + "spatialrust-interchange/tiles3d-copc", +] +interchange-full = [ + "interchange-gltf", + "interchange-openusd", + "interchange-tiles3d", + "interchange-tiles3d-copc", +] distribute = ["dep:spatialrust-distribute"] platform = ["dep:spatialrust-platform"] viz = ["dep:spatialrust-viz", "spatialrust-viz/core"] @@ -338,7 +348,7 @@ required-features = ["streaming-cli"] [[test]] name = "tiles3d_smoke" path = "tests/tiles3d_smoke.rs" -required-features = ["interchange-tiles3d", "io-pcd"] +required-features = ["interchange-tiles3d", "io-pcd", "interchange-tiles3d-copc", "io-copc"] [[example]] name = "north_star_demo" @@ -434,3 +444,8 @@ required-features = ["viewer-native"] name = "tiles3d_export" path = "examples/tiles3d_export.rs" required-features = ["io-pcd", "filter-voxel", "interchange-tiles3d"] + +[[example]] +name = "tiles3d_copc_export" +path = "examples/tiles3d_copc_export.rs" +required-features = ["io-copc", "interchange-tiles3d-copc"] diff --git a/crates/spatialrust/examples/tiles3d_copc_export.rs b/crates/spatialrust/examples/tiles3d_copc_export.rs new file mode 100644 index 0000000..ef610a0 --- /dev/null +++ b/crates/spatialrust/examples/tiles3d_copc_export.rs @@ -0,0 +1,56 @@ +//! Bounded COPC → 3D Tiles 1.1 export example. +//! +//! Opens a COPC file, walks its octree hierarchy in deterministic order, and +//! writes one `.pnts` tile per node plus a `tileset.json` without materializing +//! the whole cloud. Any 3D Tiles 1.1 runtime can then stream the result. +//! +//! Run with: +//! `cargo run -p spatialrust --example tiles3d_copc_export --features "io-copc interchange-tiles3d-copc" -- [--max-level N]` + +use std::path::PathBuf; + +use spatialrust::interchange::{export_copc_tileset, CopcTilesetOptions}; + +fn main() { + let mut args = std::env::args().skip(1); + let input = args.next().unwrap_or_else(|| { + panic!("usage: tiles3d_copc_export [--max-level N]") + }); + let output = args.next().unwrap_or_else(|| { + panic!("usage: tiles3d_copc_export [--max-level N]") + }); + + let mut max_level = None; + let mut rest = args; + while let Some(flag) = rest.next() { + match flag.as_str() { + "--max-level" => { + max_level = Some( + rest.next() + .expect("--max-level requires a value") + .parse() + .expect("max-level must be an integer"), + ); + } + other => panic!("unknown argument {other}"), + } + } + + let input = PathBuf::from(input); + let output = PathBuf::from(output); + let receipt = export_copc_tileset( + &input, + &output, + &CopcTilesetOptions { max_level, ..Default::default() }, + ) + .expect("COPC tileset export failed"); + + println!( + "wrote {} tiles / {} points to {} (tileset.json {} B, pnts {} B)", + receipt.tile_count, + receipt.point_count, + output.display(), + receipt.tileset_json_bytes, + receipt.pnts_bytes, + ); +} diff --git a/crates/spatialrust/tests/tiles3d_smoke.rs b/crates/spatialrust/tests/tiles3d_smoke.rs index 4a93286..6a81e49 100644 --- a/crates/spatialrust/tests/tiles3d_smoke.rs +++ b/crates/spatialrust/tests/tiles3d_smoke.rs @@ -1,3 +1,41 @@ +#[cfg(all(feature = "interchange-tiles3d-copc", feature = "io-copc"))] +#[test] +fn tiles3d_copc_public_api_end_to_end() { + use spatialrust::interchange::{decode_pnts, export_copc_tileset, CopcTilesetOptions}; + use spatialrust::{write_copc_file_with_params, CopcWriterParams, PointCloudBuilder}; + + let mut builder = PointCloudBuilder::xyz(); + for index in 0..7_000 { + let x = (index % 31) as f32 - 15.0; + let y = ((index / 31) % 29) as f32 - 14.0; + let z = ((index / (31 * 29)) % 23) as f32 - 11.0; + builder.push_point([x, y, z]).unwrap(); + } + let cloud = builder.build().unwrap(); + + let copc_path = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_it_{}.copc.laz", std::process::id())); + write_copc_file_with_params( + &copc_path, + &cloud, + &CopcWriterParams { max_points_per_node: 96, max_depth: 8 }, + ) + .unwrap(); + + let out_dir = std::env::temp_dir() + .join(format!("spatialrust_tiles3d_copc_it_out_{}", std::process::id())); + let receipt = + export_copc_tileset(&copc_path, &out_dir, &CopcTilesetOptions::default()).unwrap(); + assert_eq!(receipt.point_count, cloud.len() as u64); + assert!(receipt.tile_count > 1); + for tile in 0..receipt.tile_count { + let pnts = std::fs::read(out_dir.join(format!("{tile}.pnts"))).unwrap(); + assert!(decode_pnts(&pnts).is_ok()); + } + let _ = std::fs::remove_dir_all(&out_dir); + let _ = std::fs::remove_file(&copc_path); +} + #[cfg(all(feature = "interchange-tiles3d", feature = "io-pcd"))] #[test] fn tiles3d_public_api_end_to_end() { diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 4d9fca4..6f43126 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -27,7 +27,7 @@ workspace because its build requires a Python toolchain. | --- | --- | --- | --- | | `spatialrust-core` | schema, metadata, `PointCloud`, tensors, execution contracts | serde, AoSoA packing | none | | `spatialrust-math` | vector/matrix/pose math | serde | none | -| `spatialrust-io` | no format enabled by default | PCD, PLY, LAS/LAZ, E57, COPC, HTTP COPC, explicit roots/manifests | format and checksum crates are optional | +| `spatialrust-io` | no format enabled by default | PCD, PLY, LAS/LAZ, E57, COPC, HTTP COPC, explicit roots/manifests, per-node `CopcNodeReader` hierarchy walk | format and checksum crates are optional | | `spatialrust-ros2` | ROS 2 type contracts through `spatialrust-runtime` | read-only rosbag2 SQLite PointCloud2 CDR streaming with optional float32 intensity, plus source-bound TFMessage inventory | `rusqlite` is isolated behind `rosbag2-sqlite` | | `spatialrust-search` | KD-tree | graph, parallel queries | none | | `spatialrust-filtering` | voxel | GPU voxel, outlier, crop, FPS, MLS | wgpu/search optional | @@ -36,7 +36,7 @@ workspace because its build requires a Python toolchain. | `spatialrust-registration` | ICP | point-to-plane, GICP, GPU covariance, NDT, FPFH | wgpu/search optional | | `spatialrust-gpu` | device markers only | wgpu runtime, AoSoA staging | wgpu/bytemuck/pollster optional | | `spatialrust-pipeline` | MVP pipeline | GPU MVP stages | algorithm crates only | -| `spatialrust-interchange` | `interchange-gltf`, `interchange-openusd` | `tiles3d`: deterministic OGC 3D Tiles 1.1 `tileset.json` + `pnts` octree export | no external JSON or scene dependency | +| `spatialrust-interchange` | `interchange-gltf`, `interchange-openusd` | `tiles3d`: deterministic OGC 3D Tiles 1.1 `tileset.json` + `pnts` octree export; `tiles3d-copc`: bounded COPC hierarchy → tileset | `tiles3d-copc` pulls `spatialrust-io` + `spatialrust-core` for COPC node reads | | `spatialrust-py` | Python binding surface | selected meta-crate features | PyO3/NumPy | ## Execution contract diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bd08c66..548e115 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -91,12 +91,15 @@ on `spatialrust-core`, a GPU backend, or serde. | 147B | Complete | `tileset.json` model: box bounding volumes, geometric error, refine, content URIs; strict JSON parse/serialize | `tiles3d` | | 147C | Complete | Deterministic octree tileset builder from interleaved positions with point budgets, per-tile RTC_CENTER, and write receipt | `tiles3d` | | 147D | Complete | Facade `interchange-tiles3d`, runnable example, FEATURE_MATRIX/CHANGELOG/notes | facade | +| 147E | Complete | Bounded COPC → 3D Tiles exporter: `CopcNodeReader` per-node hierarchy walk in `spatialrust-io` plus `export_copc_tileset` in `spatialrust-interchange` | `tiles3d-copc` | The builder splits octants in a fixed bit order and writes one `pnts` payload per BFS tile id; leaf geometric error is zero and internal errors halve each level. Public APIs take plain `&[f32]`/`&[u8]` slices so the codec stays independent of `spatialrust-core`. Data is always owned host memory and no -hidden host/device copy is introduced. +hidden host/device copy is introduced. The COPC exporter (147E) walks a COPC +file's octree hierarchy one node at a time, re-centers each node to its own +`RTC_CENTER`, and never materializes the whole cloud. ## Program invariants diff --git a/notes/2026-08-06_epic147_tiles3d.md b/notes/2026-08-06_epic147_tiles3d.md index c911086..7fb5cec 100644 --- a/notes/2026-08-06_epic147_tiles3d.md +++ b/notes/2026-08-06_epic147_tiles3d.md @@ -25,10 +25,18 @@ differentiator beyond glTF/USDA interchange. octree builder. Fixed octant bit order, per-tile `RTC_CENTER` recentering, internal geometric error halved each level, leaf error zero, and a `TilesetWriteReceipt` with byte/point/tile counts. +- `crates/spatialrust-io/src/copc/reader.rs` — `CopcNodeReader`: opens a COPC + file once, loads only hierarchy metadata, and yields one `PointCloud` per + node in deterministic `(level, x, y, z)` order. +- `crates/spatialrust-interchange/src/tiles3d/copc.rs` — `export_copc_tileset` + (feature `tiles3d-copc`): mirrors the COPC octree into a `tileset.json` plus + one `pnts` tile per node with a per-tile `RTC_CENTER`, bounded by `max_level`, + without materializing the whole cloud. - `crates/spatialrust-interchange/src/json.rs` — small dependency-free JSON parser/serializer (number text preserved, deterministic output). -- Facade feature `interchange-tiles3d`, `tiles3d_export` example, and - `tests/tiles3d_smoke.rs` end-to-end test. +- Facade features `interchange-tiles3d`/`interchange-tiles3d-copc`, + `tiles3d_export` and `tiles3d_copc_export` examples, and + `tests/tiles3d_smoke.rs` end-to-end tests. ## Contract decisions @@ -46,13 +54,15 @@ differentiator beyond glTF/USDA interchange. ## Verification - `cargo test -p spatialrust-interchange --features tiles3d` — 20 tests. +- `cargo test -p spatialrust-interchange --features tiles3d-copc` — 22 tests. - `cargo test -p spatialrust --features "interchange-tiles3d io-pcd" --test tiles3d_smoke`. - Manual run on synthetic PCD: 400,000 points → 8 tiles / 399,989 points after voxel downsample, tileset.json 1,874 B + pnts 4,801,100 B. +- Manual COPC run: 200,000-point `grid.copc.laz` → 9 tiles / 200,000 points, + tileset.json 1,362 B + pnts 2,401,044 B, without full-cloud materialization. ## Next slices -Epic 147 is the codec/builder substrate. A COPC-driven bounded export slice can -reuse `spatialrust-lod` node selection and `spatialrust-io` COPC queries to -write tiles without materializing the full cloud, and Python bindings can -expose the same surface through the meta-crate feature. +Epic 147 is the codec/builder substrate. The COPC exporter (147E) already +streams one node at a time; RGB preservation from LAS color fields and Python +bindings remain as follow-up slices through the meta-crate feature.