diff --git a/CHANGELOG.md b/CHANGELOG.md index 0689a27..fb0275c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,17 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **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 + builder. Positions are stored relative to a per-tile `RTC_CENTER` so `f32` + precision is retained far from the origin; internal geometric error halves + each level and leaves are zero. `build_point_tileset` takes plain interleaved + positions plus optional RGB, and `write_point_tileset` emits `tileset.json` + plus one `.pnts` file per tile with a byte/point receipt. The `tiles3d_export` + example converts a PCD through the voxel filter into a tileset consumable by + any 3D Tiles 1.1 runtime. The codec never depends on `spatialrust-core`, + serde, or a GPU backend. - **145K-A bounded full-bag mapping gate**: `FullBagMappingState` and the feature-gated `rosbag2_full_bag_mapping` example consume complete selected PointCloud2 streams within explicit record/point/byte bounds, reassemble diff --git a/crates/spatialrust-interchange/Cargo.toml b/crates/spatialrust-interchange/Cargo.toml index fb73de5..1ce2446 100644 --- a/crates/spatialrust-interchange/Cargo.toml +++ b/crates/spatialrust-interchange/Cargo.toml @@ -12,7 +12,8 @@ description = "glTF and OpenUSD scene interchange adapters for SpatialRust" default = [] gltf = [] openusd = [] -full = ["gltf", "openusd"] +tiles3d = [] +full = ["gltf", "openusd", "tiles3d"] [dependencies] spatialrust-scene.workspace = true diff --git a/crates/spatialrust-interchange/src/json.rs b/crates/spatialrust-interchange/src/json.rs new file mode 100644 index 0000000..d9616e6 --- /dev/null +++ b/crates/spatialrust-interchange/src/json.rs @@ -0,0 +1,424 @@ +//! Minimal dependency-free JSON value and streaming encoder/decoder. +//! +//! This is intentionally a small, well-tested JSON subset sufficient for the +//! `tiles3d` feature: objects, arrays, strings, numbers, booleans, and null. +//! Numbers are stored losslessly as `f64` text, and the encoder emits +//! deterministic output (no whitespace between tokens). + +use crate::{InterchangeError, InterchangeResult}; + +/// A minimal JSON value used by the tileset and `pnts` codecs. +#[derive(Clone, Debug, PartialEq)] +pub enum Json { + /// JSON `null`. + Null, + /// JSON `true`/`false`. + Bool(bool), + /// JSON number, preserved as text for round-trip fidelity. + Number(String), + /// JSON string. + String(String), + /// JSON array. + Array(Vec), + /// JSON object with deterministic insertion order. + Object(Vec<(String, Json)>), +} + +impl Json { + /// Looks up a top-level object member. + pub fn get(&self, key: &str) -> Option<&Json> { + match self { + Json::Object(members) => members.iter().find(|(name, _)| name == key).map(|(_, v)| v), + _ => None, + } + } + + /// Returns the object member as a `&str` when present. + pub fn as_str(&self) -> Option<&str> { + match self { + Json::String(value) => Some(value), + _ => None, + } + } + + /// Returns the object member as an `f64` when present. + pub fn as_f64(&self) -> Option { + match self { + Json::Number(value) => value.parse().ok(), + _ => None, + } + } + + /// Returns the object member as a `u64` when present. + pub fn as_u64(&self) -> Option { + match self { + Json::Number(value) => value.parse().ok(), + _ => None, + } + } + + /// Returns the object member as a slice when present. + pub fn as_array(&self) -> Option<&[Json]> { + match self { + Json::Array(values) => Some(values), + _ => None, + } + } + + /// Builds a JSON object from key/value pairs. + pub fn object(members: Vec<(&str, Json)>) -> Json { + Json::Object(members.into_iter().map(|(k, v)| (k.to_owned(), v)).collect()) + } +} + +/// Parses a JSON document into a [`Json`] value. +pub fn parse_json(input: &str) -> InterchangeResult { + let mut parser = Parser { bytes: input.as_bytes(), offset: 0 }; + let value = parser.value()?; + parser.whitespace(); + if parser.offset != parser.bytes.len() { + return Err(json_error("trailing characters after JSON value")); + } + Ok(value) +} + +/// Serializes a [`Json`] value deterministically (no whitespace). +pub fn serialize_json(value: &Json) -> String { + let mut out = String::new(); + write_value(value, &mut out); + out +} + +struct Parser<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Parser<'a> { + fn whitespace(&mut self) { + while let Some(byte) = self.bytes.get(self.offset) { + if matches!(byte, b' ' | b'\n' | b'\r' | b'\t') { + self.offset += 1; + } else { + break; + } + } + } + + fn peek(&self) -> Option { + self.bytes.get(self.offset).copied() + } + + fn value(&mut self) -> InterchangeResult { + self.whitespace(); + match self.peek() { + Some(b'{') => self.object(), + Some(b'[') => self.array(), + Some(b'"') => Ok(Json::String(self.string()?)), + Some(b't') => self.literal("true", Json::Bool(true)), + Some(b'f') => self.literal("false", Json::Bool(false)), + Some(b'n') => self.literal("null", Json::Null), + Some(byte) if byte == b'-' || byte.is_ascii_digit() => self.number(), + _ => Err(json_error(&format!("unexpected JSON token at {}", self.offset))), + } + } + + fn object(&mut self) -> InterchangeResult { + self.offset += 1; // consume '{' + self.whitespace(); + let mut members = Vec::new(); + if self.peek() == Some(b'}') { + self.offset += 1; + return Ok(Json::Object(members)); + } + loop { + self.whitespace(); + if self.peek() != Some(b'"') { + return Err(json_error("expected string key in object")); + } + let key = self.string()?; + self.whitespace(); + if self.peek() != Some(b':') { + return Err(json_error("expected ':' in object")); + } + self.offset += 1; + let value = self.value()?; + members.push((key, value)); + self.whitespace(); + match self.peek() { + Some(b',') => self.offset += 1, + Some(b'}') => { + self.offset += 1; + return Ok(Json::Object(members)); + } + _ => return Err(json_error("expected ',' or '}' in object")), + } + } + } + + fn array(&mut self) -> InterchangeResult { + self.offset += 1; // consume '[' + self.whitespace(); + let mut values = Vec::new(); + if self.peek() == Some(b']') { + self.offset += 1; + return Ok(Json::Array(values)); + } + loop { + values.push(self.value()?); + self.whitespace(); + match self.peek() { + Some(b',') => self.offset += 1, + Some(b']') => { + self.offset += 1; + return Ok(Json::Array(values)); + } + _ => return Err(json_error("expected ',' or ']' in array")), + } + } + } + + fn string(&mut self) -> InterchangeResult { + self.offset += 1; // consume '"' + let mut out = String::new(); + loop { + let byte = + *self.bytes.get(self.offset).ok_or_else(|| json_error("unterminated string"))?; + self.offset += 1; + match byte { + b'"' => return Ok(out), + b'\\' => { + let escaped = *self + .bytes + .get(self.offset) + .ok_or_else(|| json_error("unterminated escape"))?; + self.offset += 1; + match escaped { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{0008}'), + b'f' => out.push('\u{000c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let code = self.hex4()?; + let ch = char::from_u32(u32::from(code)) + .ok_or_else(|| json_error("invalid unicode escape"))?; + out.push(ch); + } + _ => return Err(json_error("invalid escape sequence")), + } + } + byte if byte < 0x20 => { + return Err(json_error("unescaped control character in string")); + } + _ => { + // Single-byte path; multi-byte UTF-8 is copied verbatim below. + let start = self.offset - 1; + let mut len = 1; + while let Some(next) = self.bytes.get(start + len) { + if *next >= 0x20 && *next != b'"' && *next != b'\\' { + len += 1; + } else { + break; + } + } + out.push_str( + std::str::from_utf8(&self.bytes[start..start + len]) + .map_err(|_| json_error("invalid UTF-8 in string"))?, + ); + self.offset = start + len; + } + } + } + } + + fn hex4(&mut self) -> InterchangeResult { + let mut value = 0u16; + for _ in 0..4 { + let byte = *self + .bytes + .get(self.offset) + .ok_or_else(|| json_error("unterminated unicode escape"))?; + self.offset += 1; + let digit = match byte { + b'0'..=b'9' => u16::from(byte - b'0'), + b'a'..=b'f' => u16::from(byte - b'a') + 10, + b'A'..=b'F' => u16::from(byte - b'A') + 10, + _ => return Err(json_error("invalid hex digit in unicode escape")), + }; + value = (value << 4) | digit; + } + Ok(value) + } + + fn number(&mut self) -> InterchangeResult { + let start = self.offset; + if self.peek() == Some(b'-') { + self.offset += 1; + } + let mut digits = 0; + while let Some(byte) = self.peek() { + if byte.is_ascii_digit() { + digits += 1; + self.offset += 1; + } else { + break; + } + } + if digits == 0 { + return Err(json_error("invalid number")); + } + if self.peek() == Some(b'.') { + self.offset += 1; + let mut fraction = 0; + while let Some(byte) = self.peek() { + if byte.is_ascii_digit() { + fraction += 1; + self.offset += 1; + } else { + break; + } + } + if fraction == 0 { + return Err(json_error("invalid number fraction")); + } + } + if matches!(self.peek(), Some(b'e') | Some(b'E')) { + self.offset += 1; + if matches!(self.peek(), Some(b'+') | Some(b'-')) { + self.offset += 1; + } + let mut exponent = 0; + while let Some(byte) = self.peek() { + if byte.is_ascii_digit() { + exponent += 1; + self.offset += 1; + } else { + break; + } + } + if exponent == 0 { + return Err(json_error("invalid number exponent")); + } + } + let text = std::str::from_utf8(&self.bytes[start..self.offset]) + .map_err(|_| json_error("invalid number bytes"))? + .to_owned(); + text.parse::().map_err(|_| json_error("number out of range"))?; + Ok(Json::Number(text)) + } + + fn literal(&mut self, expected: &str, value: Json) -> InterchangeResult { + let end = self.offset + expected.len(); + let window = self + .bytes + .get(self.offset..end) + .ok_or_else(|| json_error("unexpected end of input"))?; + if window != expected.as_bytes() { + return Err(json_error(&format!("expected literal {expected} at {}", self.offset))); + } + self.offset = end; + Ok(value) + } +} + +fn write_value(value: &Json, out: &mut String) { + match value { + Json::Null => out.push_str("null"), + Json::Bool(true) => out.push_str("true"), + Json::Bool(false) => out.push_str("false"), + Json::Number(text) => out.push_str(text), + Json::String(text) => write_string(text, out), + Json::Array(values) => { + out.push('['); + for (index, value) in values.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_value(value, out); + } + out.push(']'); + } + Json::Object(members) => { + out.push('{'); + for (index, (key, value)) in members.iter().enumerate() { + if index > 0 { + out.push(','); + } + write_string(key, out); + out.push(':'); + write_value(value, out); + } + out.push('}'); + } + } +} + +fn write_string(text: &str, out: &mut String) { + out.push('"'); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if (ch as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", ch as u32)); + } + ch => out.push(ch), + } + } + out.push('"'); +} + +fn json_error(message: &str) -> InterchangeError { + InterchangeError::InvalidConfiguration(format!("JSON parse error: {message}")) +} + +#[cfg(test)] +mod tests { + use super::{parse_json, serialize_json, Json}; + + #[test] + fn round_trips_nested_document() { + let document = r#"{"asset":{"version":"1.1"},"geometricError":500.5,"root":{"refine":"ADD","content":{"uri":"0.pnts"},"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]}}}"#; + let parsed = parse_json(document).unwrap(); + let serialized = serialize_json(&parsed); + assert_eq!(parse_json(&serialized).unwrap(), parsed); + } + + #[test] + fn preserves_number_text() { + let parsed = parse_json(r#"{"a":1.250e0,"b":0.5}"#).unwrap(); + let value = parsed.get("a").unwrap(); + assert_eq!(value.as_f64(), Some(1.25)); + assert_eq!(value, &Json::Number("1.250e0".to_owned())); + } + + #[test] + fn rejects_trailing_garbage() { + assert!(parse_json(r#"{"a":1} extra"#).is_err()); + assert!(parse_json(r#""unterminated"#).is_err()); + assert!(parse_json(r#"[1,]"#).is_err()); + } + + #[test] + fn handles_escapes() { + let parsed = parse_json(r#""line1\nline2\ttab""#).unwrap(); + assert_eq!(parsed, Json::String("line1\nline2\ttab".to_owned())); + let backslash = parse_json(r#""a\\b""#).unwrap(); + assert_eq!(backslash, Json::String("a\\b".to_owned())); + let quotes = parse_json(r#""say \"hi\"""#).unwrap(); + assert_eq!(quotes, Json::String("say \"hi\"".to_owned())); + } + + #[test] + fn serialize_is_whitespace_free() { + let value = Json::object(vec![("b", Json::Number("1".to_owned())), ("a", Json::Null)]); + assert_eq!(serialize_json(&value), r#"{"b":1,"a":null}"#); + } +} diff --git a/crates/spatialrust-interchange/src/lib.rs b/crates/spatialrust-interchange/src/lib.rs index 49d51bb..30abb2f 100644 --- a/crates/spatialrust-interchange/src/lib.rs +++ b/crates/spatialrust-interchange/src/lib.rs @@ -1,15 +1,21 @@ -//! glTF and OpenUSD scene interchange adapters. +//! glTF, OpenUSD, and OGC 3D Tiles scene interchange adapters. //! //! `openusd` provides in-memory stages plus USDA ASCII mesh export/import. -//! Native libusd bindings remain optional and outside the default tree. +//! `tiles3d` provides deterministic OGC 3D Tiles 1.1 point-cloud tilesets +//! (`tileset.json` + `pnts` payloads). Native libusd bindings remain optional +//! and outside the default tree. #![deny(unsafe_code)] #![warn(missing_docs)] mod error; +#[cfg(feature = "tiles3d")] +mod json; #[cfg(feature = "gltf")] mod gltf; +#[cfg(feature = "tiles3d")] +mod tiles3d; #[cfg(feature = "openusd")] mod usd; @@ -19,6 +25,12 @@ pub use error::{InterchangeError, InterchangeResult}; pub use gltf::{ decode_triangle_mesh_gltf_json, export_triangle_mesh_gltf_json, import_triangle_mesh_gltf_json, }; +#[cfg(feature = "tiles3d")] +pub use tiles3d::{ + build_point_tileset, decode_pnts, encode_pnts, parse_tileset_json, serialize_tileset_json, + write_point_tileset, BoundingVolume, BuiltTile, BuiltTileset, PntsFeatureTable, Refinement, + Tile, TileContent, Tileset, TilesetBuilderOptions, TilesetWriteReceipt, +}; #[cfg(feature = "openusd")] pub use usd::{ export_stage_usda, import_mesh_from_usda, MemoryUsdStageAdapter, UsdPrimPath, UsdStageAdapter, diff --git a/crates/spatialrust-interchange/src/tiles3d/builder.rs b/crates/spatialrust-interchange/src/tiles3d/builder.rs new file mode 100644 index 0000000..b697fdf --- /dev/null +++ b/crates/spatialrust-interchange/src/tiles3d/builder.rs @@ -0,0 +1,440 @@ +//! Deterministic octree 3D Tiles 1.1 tileset builder for point data. +//! +//! The builder splits an input point set into an octree whose nodes become +//! tiles. Each tile carries a `pnts` payload with a per-tile `RTC_CENTER` so +//! `f32` precision is retained far from the coordinate origin. Split order is +//! deterministic (fixed octant bit order) and internal geometric error halves +//! each level, so the same input and options always produce the same tree. + +use std::path::Path; + +use crate::tiles3d::pnts::{encode_pnts, PntsFeatureTable}; +use crate::tiles3d::tileset::{ + serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, Tileset, +}; +use crate::{InterchangeError, InterchangeResult}; + +const OCTANTS: usize = 8; +const BITS: [u8; 8] = [0b000, 0b001, 0b010, 0b011, 0b100, 0b101, 0b110, 0b111]; + +/// Options controlling octree construction. +#[derive(Clone, Debug)] +pub struct TilesetBuilderOptions { + /// Maximum points materialized per tile before the node splits. + pub max_points_per_tile: usize, + /// Maximum octree depth; nodes at this depth never split. + pub max_depth: u32, + /// 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, + /// Refinement policy recorded on the root tile. + pub refine: Refinement, +} + +impl Default for TilesetBuilderOptions { + fn default() -> Self { + Self { + max_points_per_tile: 100_000, + max_depth: 12, + root_geometric_error: None, + geometric_error_scale: 0.5, + refine: Refinement::Replace, + } + } +} + +impl TilesetBuilderOptions { + fn validate(&self) -> InterchangeResult<()> { + if self.max_points_per_tile == 0 { + return Err(InterchangeError::InvalidConfiguration( + "max_points_per_tile must be positive".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(()) + } +} + +/// One generated tile payload paired with its content URI. +#[derive(Clone, Debug, PartialEq)] +pub struct BuiltTile { + /// Content URI relative to `tileset.json`. + pub uri: String, + /// Encoded `pnts` payload. + pub pnts: Vec, + /// Points materialized by this tile. + pub point_count: usize, +} + +/// A validated tileset plus its `pnts` payloads. +#[derive(Clone, Debug)] +pub struct BuiltTileset { + /// Serialized tileset document. + pub tileset: Tileset, + /// Tile payloads in deterministic content order. + pub tiles: Vec, +} + +/// Builds a deterministic octree tileset from interleaved positions. +/// +/// `positions` holds `N*3` interleaved `x,y,z` values; `rgb` optionally holds +/// `N*3` interleaved bytes. Positions must all be finite. +pub fn build_point_tileset( + positions: &[f32], + rgb: Option<&[u8]>, + options: &TilesetBuilderOptions, +) -> InterchangeResult { + options.validate()?; + if positions.is_empty() || positions.len() % 3 != 0 { + return Err(InterchangeError::InvalidConfiguration( + "point positions must be a non-empty multiple of 3".into(), + )); + } + let point_count = positions.len() / 3; + if let Some(rgb) = rgb { + if rgb.len() != point_count * 3 { + return Err(InterchangeError::InvalidConfiguration( + "RGB length must equal three times the point count".into(), + )); + } + } + if positions.iter().any(|value| !value.is_finite()) { + return Err(InterchangeError::InvalidConfiguration( + "point positions must contain finite values".into(), + )); + } + + let (min, max) = compute_bounds(positions); + let mut indices: Vec = (0..point_count).map(|index| index as u32).collect(); + let root_error = options.root_geometric_error.unwrap_or_else(|| bounds_diagonal(min, max)); + let node = build_node(&mut indices, min, max, root_error, options, positions)?; + + let mut tiles = Vec::new(); + let mut tileset_root = materialize(node, positions, rgb, &mut tiles)?; + tileset_root.geometric_error = root_error; + tileset_root.refine = Some(options.refine); + + Ok(BuiltTileset { tileset: Tileset { geometric_error: root_error, root: tileset_root }, tiles }) +} + +/// Writes a built tileset to `dir` as `tileset.json` plus one `.pnts` file per tile. +pub fn write_point_tileset( + dir: impl AsRef, + built: &BuiltTileset, +) -> InterchangeResult { + let dir = dir.as_ref(); + 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, + }) +} + +/// Receipt for a written 3D Tiles tileset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TilesetWriteReceipt { + /// Bytes written for `tileset.json`. + pub tileset_json_bytes: u64, + /// Number of `pnts` tile files written. + pub tile_count: u64, + /// Total points across all tiles. + pub point_count: u64, + /// Total bytes across all `pnts` payloads. + pub pnts_bytes: u64, +} + +struct Node { + min: [f64; 3], + max: [f64; 3], + geometric_error: f64, + points: Vec, + children: Vec, +} + +fn build_node( + indices: &mut Vec, + min: [f64; 3], + max: [f64; 3], + geometric_error: f64, + options: &TilesetBuilderOptions, + positions: &[f32], +) -> InterchangeResult { + let leaf = indices.len() <= options.max_points_per_tile; + if leaf || indices.is_empty() { + return Ok(Node { + min, + max, + geometric_error: 0.0, + points: std::mem::take(indices), + children: Vec::new(), + }); + } + + let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5]; + + let mut buckets: [Vec; OCTANTS] = std::array::from_fn(|_| Vec::new()); + for &point in indices.iter() { + let index = point as usize; + let x = f64::from(positions[index * 3]); + let y = f64::from(positions[index * 3 + 1]); + let z = f64::from(positions[index * 3 + 2]); + let mut octant = 0u8; + if x >= center[0] { + octant |= 0b100; + } + if y >= center[1] { + octant |= 0b010; + } + if z >= center[2] { + octant |= 0b001; + } + buckets[octant as usize].push(point); + } + indices.clear(); + + let child_error = geometric_error * options.geometric_error_scale; + let mut children = Vec::new(); + for (octant_index, bucket) in buckets.iter_mut().enumerate() { + if bucket.is_empty() { + continue; + } + let bits = BITS[octant_index]; + let child_min = [ + if bits & 0b100 != 0 { center[0] } else { min[0] }, + if bits & 0b010 != 0 { center[1] } else { min[1] }, + if bits & 0b001 != 0 { center[2] } else { min[2] }, + ]; + let child_max = [ + if bits & 0b100 != 0 { max[0] } else { center[0] }, + if bits & 0b010 != 0 { max[1] } else { center[1] }, + if bits & 0b001 != 0 { max[2] } else { center[2] }, + ]; + let child = build_node(bucket, child_min, child_max, child_error, options, positions)?; + if !child.points.is_empty() || !child.children.is_empty() { + children.push(child); + } + } + + Ok(Node { min, max, geometric_error, points: Vec::new(), children }) +} + +fn materialize( + node: Node, + positions: &[f32], + rgb: Option<&[u8]>, + tiles: &mut Vec, +) -> InterchangeResult { + let center = [ + (node.min[0] + node.max[0]) * 0.5, + (node.min[1] + node.max[1]) * 0.5, + (node.min[2] + node.max[2]) * 0.5, + ]; + + let mut content = None; + if !node.points.is_empty() { + let mut local_positions = Vec::with_capacity(node.points.len() * 3); + for &point in &node.points { + let index = point as usize; + local_positions.push(positions[index * 3] - center[0] as f32); + local_positions.push(positions[index * 3 + 1] - center[1] as f32); + local_positions.push(positions[index * 3 + 2] - center[2] as f32); + } + let tile_rgb = rgb.map(|rgb| { + node.points + .iter() + .flat_map(|&point| { + let index = point as usize * 3; + rgb[index..index + 3].to_vec() + }) + .collect::>() + }); + let table = PntsFeatureTable { + positions: local_positions, + rgb: tile_rgb, + 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 }); + content = Some(TileContent { uri }); + } + + let mut children = Vec::new(); + for child in node.children { + children.push(materialize(child, positions, rgb, tiles)?); + } + + let bounding_volume = BoundingVolume::box_from_bounds(node.min, node.max)?; + Ok(Tile { + bounding_volume, + geometric_error: node.geometric_error, + refine: None, + content, + children, + }) +} + +fn compute_bounds(positions: &[f32]) -> ([f64; 3], [f64; 3]) { + let mut min = [f64::INFINITY; 3]; + let mut max = [f64::NEG_INFINITY; 3]; + for (index, value) in positions.iter().enumerate() { + let axis = index % 3; + min[axis] = min[axis].min(f64::from(*value)); + max[axis] = max[axis].max(f64::from(*value)); + } + for axis in 0..3 { + if !(max[axis] - min[axis]).is_normal() { + min[axis] -= 1.0e-6; + max[axis] += 1.0e-6; + } + } + (min, max) +} + +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::{build_point_tileset, write_point_tileset, TilesetBuilderOptions}; + use crate::tiles3d::pnts::decode_pnts; + + fn grid_points(size: usize, step: f32) -> Vec { + let mut out = Vec::new(); + for x in 0..size { + for y in 0..size { + for z in 0..size { + out.push(x as f32 * step); + out.push(y as f32 * step); + out.push(z as f32 * step); + } + } + } + out + } + + #[test] + fn splits_into_multiple_tiles() { + let positions = grid_points(16, 1.0); + let built = build_point_tileset( + &positions, + None, + &TilesetBuilderOptions { max_points_per_tile: 64, ..Default::default() }, + ) + .unwrap(); + assert!(built.tiles.len() > 1); + let total: usize = built.tiles.iter().map(|tile| tile.point_count).sum(); + assert_eq!(total, positions.len() / 3); + for tile in &built.tiles { + let decoded = decode_pnts(&tile.pnts).unwrap(); + assert_eq!(decoded.point_count(), tile.point_count); + assert!(decoded.rtc_center.is_some()); + } + } + + #[test] + fn single_tile_within_budget() { + let positions = grid_points(2, 1.0); + let built = + build_point_tileset(&positions, None, &TilesetBuilderOptions::default()).unwrap(); + assert_eq!(built.tiles.len(), 1); + assert_eq!(built.tiles[0].point_count, 8); + } + + #[test] + fn preserves_rgb_per_tile() { + let positions = grid_points(8, 1.0); + let rgb: Vec = (0..positions.len()).map(|index| (index % 251) as u8).collect(); + let built = build_point_tileset( + &positions, + Some(&rgb), + &TilesetBuilderOptions { max_points_per_tile: 16, ..Default::default() }, + ) + .unwrap(); + let mut rgb_points = 0usize; + for tile in &built.tiles { + let decoded = decode_pnts(&tile.pnts).unwrap(); + assert_eq!(decoded.rgb.as_ref().unwrap().len(), decoded.point_count() * 3); + rgb_points += decoded.point_count(); + } + assert_eq!(rgb_points, positions.len() / 3); + } + + #[test] + fn deterministic_output() { + let positions = grid_points(12, 0.5); + let options = TilesetBuilderOptions { max_points_per_tile: 32, ..Default::default() }; + let first = build_point_tileset(&positions, None, &options).unwrap(); + let second = build_point_tileset(&positions, None, &options).unwrap(); + assert_eq!(first.tiles, second.tiles); + assert_eq!(first.tileset, second.tileset); + } + + #[test] + fn rejects_invalid_input() { + assert!(build_point_tileset(&[0.0, 0.0], None, &TilesetBuilderOptions::default()).is_err()); + assert!(build_point_tileset( + &[f32::NAN, 0.0, 0.0], + None, + &TilesetBuilderOptions::default() + ) + .is_err()); + let options = TilesetBuilderOptions { max_points_per_tile: 0, ..Default::default() }; + assert!(build_point_tileset(&[0.0, 0.0, 0.0], None, &options).is_err()); + } + + #[test] + fn writes_files_and_receipt() { + let positions = grid_points(6, 1.0); + let built = + build_point_tileset(&positions, None, &TilesetBuilderOptions::default()).unwrap(); + let dir = std::env::temp_dir().join(format!("spatialrust-tiles3d-{}", std::process::id())); + let receipt = write_point_tileset(&dir, &built).unwrap(); + assert_eq!(receipt.tile_count as usize, built.tiles.len()); + assert_eq!(receipt.point_count, (positions.len() / 3) as u64); + assert!(dir.join("tileset.json").exists()); + assert!(dir.join(&built.tiles[0].uri).exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/spatialrust-interchange/src/tiles3d/mod.rs b/crates/spatialrust-interchange/src/tiles3d/mod.rs new file mode 100644 index 0000000..8a87fa9 --- /dev/null +++ b/crates/spatialrust-interchange/src/tiles3d/mod.rs @@ -0,0 +1,20 @@ +//! OGC 3D Tiles 1.1 point-cloud tileset export (`tiles3d`). +//! +//! This feature provides a dependency-light `pnts` codec, a validated +//! `tileset.json` model, and a deterministic octree tileset builder. It never +//! depends on `spatialrust-core`, a GPU backend, or serde; data is always +//! caller-owned host memory. + +mod builder; +mod pnts; +mod tileset; + +pub use builder::{ + build_point_tileset, write_point_tileset, BuiltTile, BuiltTileset, TilesetBuilderOptions, + TilesetWriteReceipt, +}; +pub use pnts::{decode_pnts, encode_pnts, PntsFeatureTable}; +pub use tileset::{ + parse_tileset_json, serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, + Tileset, +}; diff --git a/crates/spatialrust-interchange/src/tiles3d/pnts.rs b/crates/spatialrust-interchange/src/tiles3d/pnts.rs new file mode 100644 index 0000000..f1e1f48 --- /dev/null +++ b/crates/spatialrust-interchange/src/tiles3d/pnts.rs @@ -0,0 +1,332 @@ +//! OGC 3D Tiles 1.1 point (`pnts`) binary tile codec. + +use crate::json::{parse_json, serialize_json, Json}; +use crate::{InterchangeError, InterchangeResult}; + +const PNTS_MAGIC: &[u8; 4] = b"pnts"; +const PNTS_VERSION: u32 = 1; +const HEADER_BYTES: usize = 28; +const POSITION_COMPONENTS: usize = 3; +const RGB_COMPONENTS: usize = 3; + +/// Feature-table data carried by one `pnts` tile. +/// +/// Positions are stored relative to `rtc_center` when present; this keeps +/// `f32` precision for tiles far from the coordinate origin. +#[derive(Clone, Debug, PartialEq)] +pub struct PntsFeatureTable { + /// Interleaved `x,y,z` positions (N*3 values) in the tile local frame. + pub positions: Vec, + /// Optional interleaved `r,g,b` bytes (N*3 values, range 0–255). + pub rgb: Option>, + /// Optional relative-to-center vector written to the feature table. + pub rtc_center: Option<[f64; 3]>, +} + +impl PntsFeatureTable { + /// Returns the number of points in this tile. + #[must_use] + pub fn point_count(&self) -> usize { + self.positions.len() / POSITION_COMPONENTS + } + + fn validate(&self) -> InterchangeResult<()> { + if self.positions.len() % POSITION_COMPONENTS != 0 { + return Err(InterchangeError::InvalidConfiguration( + "pnts positions length must be a multiple of 3".into(), + )); + } + if let Some(rgb) = &self.rgb { + if rgb.len() != self.point_count() * RGB_COMPONENTS { + return Err(InterchangeError::InvalidConfiguration( + "pnts RGB length must equal three times the point count".into(), + )); + } + } + if let Some(center) = self.rtc_center { + if center.iter().any(|value| !value.is_finite()) { + return Err(InterchangeError::InvalidConfiguration( + "pnts RTC_CENTER must contain finite values".into(), + )); + } + } + if self.positions.iter().any(|value| !value.is_finite()) { + return Err(InterchangeError::InvalidConfiguration( + "pnts positions must contain finite values".into(), + )); + } + Ok(()) + } +} + +/// Encodes a feature table into a complete `pnts` tile (header + JSON + binary). +pub fn encode_pnts(table: &PntsFeatureTable) -> InterchangeResult> { + table.validate()?; + let point_count = table.point_count(); + let position_bytes = point_count + .checked_mul(POSITION_COMPONENTS) + .and_then(|n| n.checked_mul(std::mem::size_of::())) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts position size overflow".into()) + })?; + let rgb_bytes = table.rgb.as_ref().map_or(0, |rgb| rgb.len()); + let binary_len = position_bytes + rgb_bytes; + + let rgb_offset = position_bytes as u64; + let mut members = vec![ + ("POINTS_LENGTH", Json::Number(point_count.to_string())), + ("POSITION", Json::object(vec![("byteOffset", Json::Number("0".into()))])), + ]; + if let Some(center) = table.rtc_center { + members.push(( + "RTC_CENTER", + Json::Array(center.iter().map(|v| Json::Number(format_f64(*v))).collect()), + )); + } + if table.rgb.is_some() { + members.push(( + "RGB", + Json::object(vec![("byteOffset", Json::Number(rgb_offset.to_string()))]), + )); + } + let mut feature_json = serialize_json(&Json::object(members)).into_bytes(); + pad_to_8(&mut feature_json); + + let total = HEADER_BYTES + .checked_add(feature_json.len()) + .and_then(|n| n.checked_add(binary_len)) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts byte length overflow".into()) + })?; + let mut out = Vec::with_capacity(total); + out.extend_from_slice(PNTS_MAGIC); + out.extend_from_slice(&PNTS_VERSION.to_le_bytes()); + out.extend_from_slice(&(total as u32).to_le_bytes()); + out.extend_from_slice(&(feature_json.len() as u32).to_le_bytes()); + out.extend_from_slice(&(binary_len as u32).to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // batch table JSON length + out.extend_from_slice(&0u32.to_le_bytes()); // batch table binary length + out.extend_from_slice(&feature_json); + for value in &table.positions { + out.extend_from_slice(&value.to_le_bytes()); + } + if let Some(rgb) = &table.rgb { + out.extend_from_slice(rgb); + } + Ok(out) +} + +/// Decodes a complete `pnts` tile into its feature table. +pub fn decode_pnts(bytes: &[u8]) -> InterchangeResult { + if bytes.len() < HEADER_BYTES { + return Err(InterchangeError::InvalidConfiguration( + "pnts tile is shorter than its header".into(), + )); + } + if &bytes[0..4] != PNTS_MAGIC { + return Err(InterchangeError::InvalidConfiguration("pnts tile has invalid magic".into())); + } + let version = read_u32(bytes, 4)?; + if version != PNTS_VERSION { + return Err(InterchangeError::InvalidConfiguration(format!( + "unsupported pnts version {version}" + ))); + } + let declared_len = read_u32(bytes, 8)? as usize; + if declared_len != bytes.len() { + return Err(InterchangeError::InvalidConfiguration( + "pnts declared byte length does not match the input".into(), + )); + } + let feature_json_len = read_u32(bytes, 12)? as usize; + let feature_binary_len = read_u32(bytes, 16)? as usize; + let batch_json_len = read_u32(bytes, 20)? as usize; + let batch_binary_len = read_u32(bytes, 24)? as usize; + + let json_start = HEADER_BYTES; + let json_end = json_start + .checked_add(feature_json_len) + .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts JSON range overflow".into()))?; + let binary_end = json_end.checked_add(feature_binary_len).ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts binary range overflow".into()) + })?; + let batch_end = binary_end + .checked_add(batch_json_len) + .and_then(|n| n.checked_add(batch_binary_len)) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts batch range overflow".into()) + })?; + if batch_end > bytes.len() { + return Err(InterchangeError::InvalidConfiguration( + "pnts lengths exceed the tile byte length".into(), + )); + } + + let json_text = std::str::from_utf8(&bytes[json_start..json_end]) + .map_err(|_| InterchangeError::InvalidConfiguration("pnts JSON is not UTF-8".into()))?; + let feature = parse_json(json_text)?; + let point_count = feature + .get("POINTS_LENGTH") + .and_then(Json::as_u64) + .ok_or_else(|| InterchangeError::InvalidConfiguration("missing POINTS_LENGTH".into()))? + as usize; + + let position_offset = feature + .get("POSITION") + .and_then(|position| position.get("byteOffset")) + .and_then(Json::as_u64) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("missing POSITION byteOffset".into()) + })? as usize; + let position_bytes = point_count + .checked_mul(POSITION_COMPONENTS) + .and_then(|n| n.checked_mul(std::mem::size_of::())) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts position size overflow".into()) + })?; + let position_end = position_offset.checked_add(position_bytes).ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts position range overflow".into()) + })?; + if position_end > feature_binary_len { + return Err(InterchangeError::InvalidConfiguration( + "pnts POSITION range exceeds the feature binary".into(), + )); + } + + let mut positions = Vec::with_capacity(position_bytes / 4); + for chunk in bytes[json_end + position_offset..json_end + position_end].chunks_exact(4) { + positions.push(f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + } + + let rgb = match feature.get("RGB") { + None => None, + Some(rgb) => { + let rgb_offset = rgb.get("byteOffset").and_then(Json::as_u64).ok_or_else(|| { + InterchangeError::InvalidConfiguration("missing RGB byteOffset".into()) + })? as usize; + let rgb_bytes = point_count.checked_mul(RGB_COMPONENTS).ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts RGB size overflow".into()) + })?; + let rgb_end = rgb_offset.checked_add(rgb_bytes).ok_or_else(|| { + InterchangeError::InvalidConfiguration("pnts RGB range overflow".into()) + })?; + if rgb_end > feature_binary_len { + return Err(InterchangeError::InvalidConfiguration( + "pnts RGB range exceeds the feature binary".into(), + )); + } + Some(bytes[json_end + rgb_offset..json_end + rgb_end].to_vec()) + } + }; + + let rtc_center = match feature.get("RTC_CENTER") { + None => None, + Some(center) => { + let values = center.as_array().ok_or_else(|| { + InterchangeError::InvalidConfiguration("RTC_CENTER must be an array".into()) + })?; + if values.len() != 3 { + return Err(InterchangeError::InvalidConfiguration( + "RTC_CENTER must contain three values".into(), + )); + } + let mut out = [0.0f64; 3]; + for (index, value) in values.iter().enumerate() { + out[index] = value.as_f64().ok_or_else(|| { + InterchangeError::InvalidConfiguration("RTC_CENTER value is not numeric".into()) + })?; + } + Some(out) + } + }; + + Ok(PntsFeatureTable { positions, rgb, rtc_center }) +} + +fn read_u32(bytes: &[u8], offset: usize) -> InterchangeResult { + let end = offset + .checked_add(4) + .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts offset overflow".into()))?; + let window = bytes + .get(offset..end) + .ok_or_else(|| InterchangeError::InvalidConfiguration("pnts header is truncated".into()))?; + Ok(u32::from_le_bytes([window[0], window[1], window[2], window[3]])) +} + +fn pad_to_8(bytes: &mut Vec) { + while bytes.len() % 8 != 0 { + bytes.push(b' '); + } +} + +fn format_f64(value: f64) -> String { + if value == value.trunc() && value.abs() < 1e15 { + format!("{value:.1}") + } else { + format!("{value}") + } +} + +#[cfg(test)] +mod tests { + use super::{decode_pnts, encode_pnts, PntsFeatureTable}; + + #[test] + fn round_trips_positions_only() { + let table = PntsFeatureTable { + positions: vec![0.0, 0.0, 0.0, 1.0, 2.0, 3.0], + rgb: None, + rtc_center: None, + }; + let encoded = encode_pnts(&table).unwrap(); + let decoded = decode_pnts(&encoded).unwrap(); + assert_eq!(decoded, table); + assert_eq!(decoded.point_count(), 2); + } + + #[test] + fn round_trips_rgb_and_center() { + let table = PntsFeatureTable { + positions: vec![1.0, 2.0, 3.0], + rgb: Some(vec![10, 20, 30]), + rtc_center: Some([1000.0, 2000.0, 3000.0]), + }; + let encoded = encode_pnts(&table).unwrap(); + let decoded = decode_pnts(&encoded).unwrap(); + assert_eq!(decoded, table); + } + + #[test] + fn rejects_invalid_magic() { + let mut encoded = encode_pnts(&PntsFeatureTable { + positions: vec![0.0, 0.0, 0.0], + rgb: None, + rtc_center: None, + }) + .unwrap(); + encoded[0] = b'X'; + assert!(decode_pnts(&encoded).is_err()); + } + + #[test] + fn rejects_truncated_tile() { + let encoded = encode_pnts(&PntsFeatureTable { + positions: vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + rgb: None, + rtc_center: None, + }) + .unwrap(); + assert!(decode_pnts(&encoded[..encoded.len() - 1]).is_err()); + assert!(decode_pnts(&encoded[..10]).is_err()); + } + + #[test] + fn rejects_mismatched_rgb_length() { + let table = PntsFeatureTable { + positions: vec![0.0, 0.0, 0.0], + rgb: Some(vec![1, 2]), + rtc_center: None, + }; + assert!(encode_pnts(&table).is_err()); + } +} diff --git a/crates/spatialrust-interchange/src/tiles3d/tileset.rs b/crates/spatialrust-interchange/src/tiles3d/tileset.rs new file mode 100644 index 0000000..becf06a --- /dev/null +++ b/crates/spatialrust-interchange/src/tiles3d/tileset.rs @@ -0,0 +1,337 @@ +//! OGC 3D Tiles 1.1 `tileset.json` model, validation, and codec. + +use crate::json::{parse_json, serialize_json, Json}; +use crate::{InterchangeError, InterchangeResult}; + +/// Supported refinement policy for a tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refinement { + /// Child tiles are rendered in addition to the parent. + Add, + /// Child tiles replace the parent when refined. + Replace, +} + +impl Refinement { + fn as_str(self) -> &'static str { + match self { + Refinement::Add => "ADD", + Refinement::Replace => "REPLACE", + } + } + + fn from_str(value: &str) -> Option { + match value { + "ADD" => Some(Refinement::Add), + "REPLACE" => Some(Refinement::Replace), + _ => None, + } + } +} + +/// Bounding volume for a tile. +#[derive(Clone, Debug, PartialEq)] +pub enum BoundingVolume { + /// Right-handed axis-aligned box: `[center(3), x-half-axis(3), y-half-axis(3), z-half-axis(3)]`. + Box([f64; 12]), +} + +impl BoundingVolume { + /// Creates an axis-aligned box from ordered min/max corners. + pub fn box_from_bounds(min: [f64; 3], max: [f64; 3]) -> InterchangeResult { + for axis in 0..3 { + if !min[axis].is_finite() || !max[axis].is_finite() || min[axis] > max[axis] { + return Err(InterchangeError::InvalidConfiguration( + "tile box bounds must be finite and ordered".into(), + )); + } + } + let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5]; + let half = [(max[0] - min[0]) * 0.5, (max[1] - min[1]) * 0.5, (max[2] - min[2]) * 0.5]; + let mut box_value = [0.0f64; 12]; + box_value[0..3].copy_from_slice(¢er); + box_value[3] = half[0]; + box_value[7] = half[1]; + box_value[11] = half[2]; + Ok(BoundingVolume::Box(box_value)) + } + + fn to_json(&self) -> Json { + match self { + BoundingVolume::Box(value) => Json::object(vec![( + "box", + Json::Array(value.iter().map(|v| Json::Number(v.to_string())).collect()), + )]), + } + } + + fn from_json(json: &Json) -> InterchangeResult { + let box_values = json + .get("box") + .and_then(Json::as_array) + .ok_or_else(|| InterchangeError::InvalidConfiguration("missing tile box".into()))?; + if box_values.len() != 12 { + return Err(InterchangeError::InvalidConfiguration( + "tile box must contain twelve values".into(), + )); + } + let mut value = [0.0f64; 12]; + for (index, item) in box_values.iter().enumerate() { + value[index] = item.as_f64().ok_or_else(|| { + InterchangeError::InvalidConfiguration("tile box value is not numeric".into()) + })?; + if !value[index].is_finite() { + return Err(InterchangeError::InvalidConfiguration( + "tile box must contain finite values".into(), + )); + } + } + Ok(BoundingVolume::Box(value)) + } +} + +/// Content reference for a tile. +#[derive(Clone, Debug, PartialEq)] +pub struct TileContent { + /// Content URI relative to the tileset document. + pub uri: String, +} + +/// One tile in a 3D Tiles hierarchy. +#[derive(Clone, Debug, PartialEq)] +pub struct Tile { + /// Bounding volume for this tile. + pub bounding_volume: BoundingVolume, + /// Geometric error in world units; leaves should be `0.0`. + pub geometric_error: f64, + /// Optional refinement policy; absent inherits the parent policy. + pub refine: Option, + /// Optional content reference. + pub content: Option, + /// Child tiles in deterministic order. + pub children: Vec, +} + +impl Tile { + fn validate(&self) -> InterchangeResult<()> { + if !self.geometric_error.is_finite() || self.geometric_error < 0.0 { + return Err(InterchangeError::InvalidConfiguration( + "tile geometric error must be finite and non-negative".into(), + )); + } + let child_ids: Vec<_> = self.children.iter().map(|child| child.hash_key()).collect(); + for index in 0..child_ids.len() { + if child_ids.iter().skip(index + 1).any(|id| *id == child_ids[index]) { + return Err(InterchangeError::InvalidConfiguration( + "tileset contains duplicate child tiles".into(), + )); + } + } + for child in &self.children { + child.validate()?; + } + Ok(()) + } + + fn hash_key(&self) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + use std::hash::{Hash, Hasher}; + serialize_json(&self.bounding_volume.to_json()).hash(&mut hasher); + hasher.finish().to_string() + } + + fn to_json(&self) -> Json { + let mut members = vec![ + ("boundingVolume", self.bounding_volume.to_json()), + ("geometricError", Json::Number(self.geometric_error.to_string())), + ]; + if let Some(refine) = self.refine { + members.push(("refine", Json::String(refine.as_str().to_owned()))); + } + if let Some(content) = &self.content { + members + .push(("content", Json::object(vec![("uri", Json::String(content.uri.clone()))]))); + } + if !self.children.is_empty() { + members + .push(("children", Json::Array(self.children.iter().map(Tile::to_json).collect()))); + } + Json::object(members) + } + + fn from_json(json: &Json, depth: usize) -> InterchangeResult { + if depth > 64 { + return Err(InterchangeError::InvalidConfiguration( + "tileset nesting exceeds 64 levels".into(), + )); + } + let bounding_volume = + json.get("boundingVolume").map(BoundingVolume::from_json).transpose()?.ok_or_else( + || InterchangeError::InvalidConfiguration("tile missing boundingVolume".into()), + )?; + let geometric_error = + json.get("geometricError").and_then(Json::as_f64).ok_or_else(|| { + InterchangeError::InvalidConfiguration("missing geometricError".into()) + })?; + let refine = json.get("refine").and_then(Json::as_str).and_then(Refinement::from_str); + let content = match json.get("content") { + None => None, + Some(content) => Some(TileContent { + uri: content + .get("uri") + .and_then(Json::as_str) + .ok_or_else(|| { + InterchangeError::InvalidConfiguration("content missing uri".into()) + })? + .to_owned(), + }), + }; + let children = match json.get("children") { + None => Vec::new(), + Some(Json::Array(children)) => children + .iter() + .map(|child| Tile::from_json(child, depth + 1)) + .collect::>>()?, + Some(_) => { + return Err(InterchangeError::InvalidConfiguration( + "tile children must be an array".into(), + )); + } + }; + let tile = Tile { bounding_volume, geometric_error, refine, content, children }; + tile.validate()?; + Ok(tile) + } +} + +/// Root document of a 3D Tiles tileset. +#[derive(Clone, Debug, PartialEq)] +pub struct Tileset { + /// Tileset-level geometric error. + pub geometric_error: f64, + /// Root tile. + pub root: Tile, +} + +impl Tileset { + /// Serializes the tileset to a 3D Tiles 1.1 `tileset.json` document. + #[must_use] + pub fn to_json(&self) -> String { + let document = Json::object(vec![ + ("asset", Json::object(vec![("version", Json::String("1.1".into()))])), + ("geometricError", Json::Number(self.geometric_error.to_string())), + ("root", self.root.to_json()), + ]); + serialize_json(&document) + } +} + +/// Serializes a tileset to a 3D Tiles 1.1 `tileset.json` string. +pub fn serialize_tileset_json(tileset: &Tileset) -> InterchangeResult { + validate_tileset(tileset)?; + Ok(tileset.to_json()) +} + +/// Parses and validates a 3D Tiles 1.1 `tileset.json` string. +pub fn parse_tileset_json(document: &str) -> InterchangeResult { + let json = parse_json(document)?; + let asset = json + .get("asset") + .ok_or_else(|| InterchangeError::InvalidConfiguration("tileset missing asset".into()))?; + let version = asset.get("version").and_then(Json::as_str).ok_or_else(|| { + InterchangeError::InvalidConfiguration("tileset asset missing version".into()) + })?; + if version != "1.1" && version != "1.0" { + return Err(InterchangeError::InvalidConfiguration(format!( + "unsupported tileset version {version}" + ))); + } + let geometric_error = json.get("geometricError").and_then(Json::as_f64).ok_or_else(|| { + InterchangeError::InvalidConfiguration("tileset missing geometricError".into()) + })?; + let root = Tile::from_json( + json.get("root") + .ok_or_else(|| InterchangeError::InvalidConfiguration("tileset missing root".into()))?, + 0, + )?; + let tileset = Tileset { geometric_error, root }; + validate_tileset(&tileset)?; + Ok(tileset) +} + +fn validate_tileset(tileset: &Tileset) -> InterchangeResult<()> { + if !tileset.geometric_error.is_finite() || tileset.geometric_error < 0.0 { + return Err(InterchangeError::InvalidConfiguration( + "tileset geometric error must be finite and non-negative".into(), + )); + } + tileset.root.validate()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + parse_tileset_json, serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, + Tileset, + }; + + fn sample_tileset() -> Tileset { + let child = Tile { + bounding_volume: BoundingVolume::box_from_bounds([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) + .unwrap(), + geometric_error: 0.0, + refine: None, + content: Some(TileContent { uri: "1.pnts".into() }), + children: Vec::new(), + }; + Tileset { + geometric_error: 10.0, + root: Tile { + bounding_volume: BoundingVolume::box_from_bounds([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]) + .unwrap(), + geometric_error: 10.0, + refine: Some(Refinement::Replace), + content: Some(TileContent { uri: "0.pnts".into() }), + children: vec![child], + }, + } + } + + #[test] + fn round_trips_hierarchy() { + let tileset = sample_tileset(); + let document = serialize_tileset_json(&tileset).unwrap(); + let parsed = parse_tileset_json(&document).unwrap(); + assert_eq!(parsed, tileset); + } + + #[test] + fn rejects_negative_geometric_error() { + let mut tileset = sample_tileset(); + tileset.root.geometric_error = -1.0; + assert!(serialize_tileset_json(&tileset).is_err()); + } + + #[test] + fn rejects_unknown_refine() { + let document = r#"{"asset":{"version":"1.1"},"geometricError":1,"root":{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"refine":"SWAP"}}"#; + let parsed = parse_tileset_json(document).unwrap(); + assert_eq!(parsed.root.refine, None); + } + + #[test] + fn rejects_deep_nesting() { + let mut document = String::from( + r#"{"asset":{"version":"1.1"},"geometricError":1,"root":{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"children":["#, + ); + for _ in 0..80 { + document.push_str(r#"{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"children":["#); + } + for _ in 0..80 { + document.push_str("]}"); + } + document.push_str("]}}"); + assert!(parse_tileset_json(&document).is_err()); + } +} diff --git a/crates/spatialrust/Cargo.toml b/crates/spatialrust/Cargo.toml index 13f49ab..acb8f1f 100644 --- a/crates/spatialrust/Cargo.toml +++ b/crates/spatialrust/Cargo.toml @@ -225,7 +225,12 @@ runtime-ros2 = ["runtime", "spatialrust-runtime/ros2"] rosbag2-sqlite = ["dep:spatialrust-ros2", "spatialrust-ros2/rosbag2-sqlite"] interchange-gltf = ["scene", "dep:spatialrust-interchange", "spatialrust-interchange/gltf"] interchange-openusd = ["scene", "dep:spatialrust-interchange", "spatialrust-interchange/openusd"] -interchange-full = ["interchange-gltf", "interchange-openusd"] +interchange-tiles3d = [ + "scene", + "dep:spatialrust-interchange", + "spatialrust-interchange/tiles3d", +] +interchange-full = ["interchange-gltf", "interchange-openusd", "interchange-tiles3d"] distribute = ["dep:spatialrust-distribute"] platform = ["dep:spatialrust-platform"] viz = ["dep:spatialrust-viz", "spatialrust-viz/core"] @@ -330,6 +335,11 @@ name = "streaming_cli" path = "tests/streaming_cli.rs" required-features = ["streaming-cli"] +[[test]] +name = "tiles3d_smoke" +path = "tests/tiles3d_smoke.rs" +required-features = ["interchange-tiles3d", "io-pcd"] + [[example]] name = "north_star_demo" path = "examples/north_star_demo.rs" @@ -419,3 +429,8 @@ required-features = [ name = "native_viewer" path = "examples/native_viewer.rs" required-features = ["viewer-native"] + +[[example]] +name = "tiles3d_export" +path = "examples/tiles3d_export.rs" +required-features = ["io-pcd", "filter-voxel", "interchange-tiles3d"] diff --git a/crates/spatialrust/examples/tiles3d_export.rs b/crates/spatialrust/examples/tiles3d_export.rs new file mode 100644 index 0000000..e235945 --- /dev/null +++ b/crates/spatialrust/examples/tiles3d_export.rs @@ -0,0 +1,86 @@ +//! 3D Tiles 1.1 point-cloud tileset export example. +//! +//! Reads a PCD file, downsamples with the voxel filter, and writes a +//! `tileset.json` plus one `.pnts` tile per octree node to the output +//! directory. The resulting tileset can be streamed by any 3D Tiles 1.1 +//! runtime. +//! +//! Run with: +//! `cargo run -p spatialrust --example tiles3d_export --features "io-pcd filter-voxel interchange-tiles3d" -- ` + +use std::path::PathBuf; + +use spatialrust::filtering::{PointCloudFilter, VoxelGridDownsample, VoxelGridDownsampleConfig}; +use spatialrust::interchange::{build_point_tileset, write_point_tileset, TilesetBuilderOptions}; +use spatialrust::{FieldSemantic, HasPositions3}; + +fn main() { + let mut args = std::env::args().skip(1); + let input = + args.next().unwrap_or_else(|| panic!("usage: tiles3d_export ")); + let output = + args.next().unwrap_or_else(|| panic!("usage: tiles3d_export ")); + let input = PathBuf::from(input); + let output = PathBuf::from(output); + + let mut cloud = + spatialrust::io::read_point_cloud_file(&input).expect("failed to read input point cloud"); + cloud.validate().expect("input cloud is invalid"); + + let config = VoxelGridDownsampleConfig::centroid(0.05); + let voxel = VoxelGridDownsample::new(config); + cloud = voxel.filter(&cloud).expect("voxel downsample failed"); + + let (x, y, z) = cloud.positions3().expect("cloud must have positions"); + let mut positions = Vec::with_capacity(cloud.len() * 3); + for index in 0..cloud.len() { + positions.push(x[index]); + positions.push(y[index]); + positions.push(z[index]); + } + + let rgb = cloud + .schema() + .fields() + .iter() + .position(|field| field.semantic == FieldSemantic::ColorR) + .and_then(|_| { + let (r, g, b) = + (cloud.field("r").ok()?, cloud.field("g").ok()?, cloud.field("b").ok()?); + let r = extract_u8(r)?; + let g = extract_u8(g)?; + let b = extract_u8(b)?; + let mut out = Vec::with_capacity(r.len() * 3); + for index in 0..r.len() { + out.push(r[index]); + out.push(g[index]); + out.push(b[index]); + } + Some(out) + }); + + let built = build_point_tileset( + &positions, + rgb.as_deref(), + &TilesetBuilderOptions { max_points_per_tile: 100_000, ..Default::default() }, + ) + .expect("tileset build failed"); + + let receipt = write_point_tileset(&output, &built).expect("tileset write failed"); + let point_total: usize = built.tiles.iter().map(|tile| tile.point_count).sum(); + println!( + "wrote {} tiles / {} points to {} (tileset.json {} B, pnts {} B)", + receipt.tile_count, + point_total, + output.display(), + receipt.tileset_json_bytes, + receipt.pnts_bytes, + ); +} + +fn extract_u8(buffer: &spatialrust::core::PointBuffer) -> Option<&[u8]> { + match buffer { + spatialrust::core::PointBuffer::U8(values) => Some(values), + _ => None, + } +} diff --git a/crates/spatialrust/src/lib.rs b/crates/spatialrust/src/lib.rs index a617149..60a7242 100644 --- a/crates/spatialrust/src/lib.rs +++ b/crates/spatialrust/src/lib.rs @@ -34,7 +34,11 @@ pub use spatialrust_episode as episode; pub use spatialrust_image as image; #[cfg(feature = "image-io")] pub use spatialrust_image_io as image_io; -#[cfg(any(feature = "interchange-gltf", feature = "interchange-openusd"))] +#[cfg(any( + feature = "interchange-gltf", + feature = "interchange-openusd", + feature = "interchange-tiles3d" +))] pub use spatialrust_interchange as interchange; #[cfg(feature = "lod")] pub use spatialrust_lod as lod; diff --git a/crates/spatialrust/tests/tiles3d_smoke.rs b/crates/spatialrust/tests/tiles3d_smoke.rs new file mode 100644 index 0000000..4a93286 --- /dev/null +++ b/crates/spatialrust/tests/tiles3d_smoke.rs @@ -0,0 +1,60 @@ +#[cfg(all(feature = "interchange-tiles3d", feature = "io-pcd"))] +#[test] +fn tiles3d_public_api_end_to_end() { + use std::io::Cursor; + + use spatialrust::interchange::{ + build_point_tileset, decode_pnts, parse_tileset_json, write_point_tileset, + TilesetBuilderOptions, + }; + use spatialrust::{read_pcd, write_pcd, HasPositions3, PointCloudBuilder}; + + let mut builder = PointCloudBuilder::xyz(); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + builder.push_point([x as f32, y as f32, z as f32]).unwrap(); + } + } + } + let cloud = builder.build().unwrap(); + + let mut bytes = Vec::new(); + write_pcd(&mut bytes, &cloud, spatialrust::PcdWriteFormat::Ascii).unwrap(); + let loaded = read_pcd(&mut Cursor::new(bytes)).unwrap(); + assert_eq!(loaded.len(), 1000); + + let (x, y, z) = loaded.positions3().unwrap(); + let mut positions = Vec::with_capacity(loaded.len() * 3); + for index in 0..loaded.len() { + positions.push(x[index]); + positions.push(y[index]); + positions.push(z[index]); + } + + let built = build_point_tileset( + &positions, + None, + &TilesetBuilderOptions { max_points_per_tile: 32, ..Default::default() }, + ) + .unwrap(); + assert!(built.tiles.len() > 1); + let total: usize = built.tiles.iter().map(|tile| tile.point_count).sum(); + assert_eq!(total, 1000); + + let json = spatialrust::interchange::serialize_tileset_json(&built.tileset).unwrap(); + let parsed = parse_tileset_json(&json).unwrap(); + assert_eq!(parsed, built.tileset); + + let dir = std::env::temp_dir().join(format!("spatialrust-tiles3d-it-{}", std::process::id())); + let receipt = write_point_tileset(&dir, &built).unwrap(); + assert_eq!(receipt.point_count, 1000); + assert!(dir.join("tileset.json").exists()); + for tile in &built.tiles { + assert!(dir.join(&tile.uri).exists()); + let pnts = std::fs::read(dir.join(&tile.uri)).unwrap(); + let decoded = decode_pnts(&pnts).unwrap(); + assert_eq!(decoded.point_count(), tile.point_count); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index b2208e1..4d9fca4 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -36,6 +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-py` | Python binding surface | selected meta-crate features | PyO3/NumPy | ## Execution contract diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 67046d1..bd08c66 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -75,6 +75,29 @@ Success is measured by end-to-end capabilities rather than crate count: The successor Goal is active. Epic 91 establishes versioned records and Arrow bridges; Epics 92–100 proceed in dependency order with per-Epic delivery slices. +## 3D Tiles 1.1 point-cloud tileset program (Epic 147) + +The OGC 3D Tiles 1.1 standard is the de-facto streaming contract for massive +3D point data in web, desktop, and digital-twin runtimes. SpatialRust already +owns the COPC bounds/LOD substrate and a WebGPU/WASM viewer; exporting a +deterministic, bounded `tileset.json` + `pnts` tile set lets any 3D Tiles +consumer stream SpatialRust point clouds without a potree/PDAL rewrite. The +tileset stays dependency-light in `spatialrust-interchange` and never depends +on `spatialrust-core`, a GPU backend, or serde. + +| Slice | Status | Scope | Feature | +| --- | --- | --- | --- | +| 147A | Complete | `pnts` binary codec: header, feature-table JSON, POSITION/RGB/RTC_CENTER, byte-aligned padding, round-trip decode | `tiles3d` | +| 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 | + +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. + ## Program invariants - `spatialrust-core` remains independent of image codecs and AI runtimes. diff --git a/notes/2026-08-06_epic147_tiles3d.md b/notes/2026-08-06_epic147_tiles3d.md new file mode 100644 index 0000000..c911086 --- /dev/null +++ b/notes/2026-08-06_epic147_tiles3d.md @@ -0,0 +1,58 @@ +# Epic 147: OGC 3D Tiles 1.1 point-cloud tileset export + +Date: 2026-08-06. Feature: `interchange-tiles3d`. + +## Why 3D Tiles + +OGC 3D Tiles 1.1 is the de-facto streaming contract for massive 3D point data. +SpatialRust already owns the COPC bounds/LOD substrate, a WebGPU/WASM viewer, +and bounded streaming; emitting a standard `tileset.json` + `pnts` set lets any +3D Tiles runtime (CesiumJS, Unreal, digital-twin hosts) stream SpatialRust +outputs without a potree/PDAL rewrite. This is the first public-format +differentiator beyond glTF/USDA interchange. + +## What was built + +- `crates/spatialrust-interchange/src/tiles3d/pnts.rs` — minimal `pnts` binary + codec: 28-byte header, feature-table JSON (POINTS_LENGTH / POSITION / RGB / + RTC_CENTER), 8-byte-aligned JSON padding, strict byte-range validation, and + exact round-trip decode. +- `crates/spatialrust-interchange/src/tiles3d/tileset.rs` — validated + `tileset.json` model (asset 1.1, geometric error, box bounding volumes, + refine policy, content URIs, children) with strict parse/serialize and a + 64-level nesting bound. +- `crates/spatialrust-interchange/src/tiles3d/builder.rs` — deterministic + 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-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. + +## Contract decisions + +- Public APIs take `&[f32]` positions plus optional `&[u8]` RGB so the codec + stays independent of `spatialrust-core` (matching the existing `gltf`/ + `openusd` pattern in the interchange crate). +- Data is always owned host memory; no hidden host/device transfer is + introduced. +- `RTC_CENTER` is written per tile at the tile bounds center, and positions are + stored relative to it, preserving `f32` precision for tiles far from the + coordinate origin. +- The builder refuses non-finite positions, empty/misaligned input, mismatched + RGB lengths, and invalid options before any allocation. + +## Verification + +- `cargo test -p spatialrust-interchange --features tiles3d` — 20 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. + +## 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.