1use std::path::Path;
10
11use crate::tiles3d::pnts::{encode_pnts, PntsFeatureTable};
12use crate::tiles3d::tileset::{
13 serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent, Tileset,
14};
15use crate::{InterchangeError, InterchangeResult};
16
17const OCTANTS: usize = 8;
18const BITS: [u8; 8] = [0b000, 0b001, 0b010, 0b011, 0b100, 0b101, 0b110, 0b111];
19
20#[derive(Clone, Debug)]
22pub struct TilesetBuilderOptions {
23 pub max_points_per_tile: usize,
25 pub max_depth: u32,
27 pub root_geometric_error: Option<f64>,
29 pub geometric_error_scale: f64,
31 pub refine: Refinement,
33}
34
35impl Default for TilesetBuilderOptions {
36 fn default() -> Self {
37 Self {
38 max_points_per_tile: 100_000,
39 max_depth: 12,
40 root_geometric_error: None,
41 geometric_error_scale: 0.5,
42 refine: Refinement::Replace,
43 }
44 }
45}
46
47impl TilesetBuilderOptions {
48 fn validate(&self) -> InterchangeResult<()> {
49 if self.max_points_per_tile == 0 {
50 return Err(InterchangeError::InvalidConfiguration(
51 "max_points_per_tile must be positive".into(),
52 ));
53 }
54 if let Some(error) = self.root_geometric_error {
55 if !error.is_finite() || error < 0.0 {
56 return Err(InterchangeError::InvalidConfiguration(
57 "root_geometric_error must be finite and non-negative".into(),
58 ));
59 }
60 }
61 if !self.geometric_error_scale.is_finite()
62 || !(0.0..=1.0).contains(&self.geometric_error_scale)
63 {
64 return Err(InterchangeError::InvalidConfiguration(
65 "geometric_error_scale must be within [0, 1]".into(),
66 ));
67 }
68 Ok(())
69 }
70}
71
72#[derive(Clone, Debug, PartialEq)]
74pub struct BuiltTile {
75 pub uri: String,
77 pub pnts: Vec<u8>,
79 pub point_count: usize,
81}
82
83#[derive(Clone, Debug)]
85pub struct BuiltTileset {
86 pub tileset: Tileset,
88 pub tiles: Vec<BuiltTile>,
90}
91
92pub fn build_point_tileset(
97 positions: &[f32],
98 rgb: Option<&[u8]>,
99 options: &TilesetBuilderOptions,
100) -> InterchangeResult<BuiltTileset> {
101 options.validate()?;
102 if positions.is_empty() || positions.len() % 3 != 0 {
103 return Err(InterchangeError::InvalidConfiguration(
104 "point positions must be a non-empty multiple of 3".into(),
105 ));
106 }
107 let point_count = positions.len() / 3;
108 if let Some(rgb) = rgb {
109 if rgb.len() != point_count * 3 {
110 return Err(InterchangeError::InvalidConfiguration(
111 "RGB length must equal three times the point count".into(),
112 ));
113 }
114 }
115 if positions.iter().any(|value| !value.is_finite()) {
116 return Err(InterchangeError::InvalidConfiguration(
117 "point positions must contain finite values".into(),
118 ));
119 }
120
121 let (min, max) = compute_bounds(positions);
122 let mut indices: Vec<u32> = (0..point_count).map(|index| index as u32).collect();
123 let root_error = options.root_geometric_error.unwrap_or_else(|| bounds_diagonal(min, max));
124 let node = build_node(&mut indices, min, max, root_error, options, positions)?;
125
126 let mut tiles = Vec::new();
127 let mut tileset_root = materialize(node, positions, rgb, &mut tiles)?;
128 tileset_root.geometric_error = root_error;
129 tileset_root.refine = Some(options.refine);
130
131 Ok(BuiltTileset { tileset: Tileset { geometric_error: root_error, root: tileset_root }, tiles })
132}
133
134pub fn write_point_tileset(
136 dir: impl AsRef<Path>,
137 built: &BuiltTileset,
138) -> InterchangeResult<TilesetWriteReceipt> {
139 let dir = dir.as_ref();
140 std::fs::create_dir_all(dir).map_err(io_error)?;
141 let document = serialize_tileset_json(&built.tileset)?;
142 std::fs::write(dir.join("tileset.json"), document.as_bytes()).map_err(io_error)?;
143
144 let mut tile_count = 0u64;
145 let mut point_count = 0u64;
146 let mut pnts_bytes = 0u64;
147 for tile in &built.tiles {
148 std::fs::write(dir.join(&tile.uri), &tile.pnts).map_err(io_error)?;
149 tile_count += 1;
150 point_count = point_count
151 .checked_add(tile.point_count as u64)
152 .ok_or_else(|| InterchangeError::InvalidConfiguration("point count overflow".into()))?;
153 pnts_bytes = pnts_bytes.checked_add(tile.pnts.len() as u64).ok_or_else(|| {
154 InterchangeError::InvalidConfiguration("pnts byte count overflow".into())
155 })?;
156 }
157 Ok(TilesetWriteReceipt {
158 tileset_json_bytes: document.len() as u64,
159 tile_count,
160 point_count,
161 pnts_bytes,
162 })
163}
164
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub struct TilesetWriteReceipt {
168 pub tileset_json_bytes: u64,
170 pub tile_count: u64,
172 pub point_count: u64,
174 pub pnts_bytes: u64,
176}
177
178struct Node {
179 min: [f64; 3],
180 max: [f64; 3],
181 geometric_error: f64,
182 points: Vec<u32>,
183 children: Vec<Node>,
184}
185
186fn build_node(
187 indices: &mut Vec<u32>,
188 min: [f64; 3],
189 max: [f64; 3],
190 geometric_error: f64,
191 options: &TilesetBuilderOptions,
192 positions: &[f32],
193) -> InterchangeResult<Node> {
194 let leaf = indices.len() <= options.max_points_per_tile;
195 if leaf || indices.is_empty() {
196 return Ok(Node {
197 min,
198 max,
199 geometric_error: 0.0,
200 points: std::mem::take(indices),
201 children: Vec::new(),
202 });
203 }
204
205 let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
206
207 let mut buckets: [Vec<u32>; OCTANTS] = std::array::from_fn(|_| Vec::new());
208 for &point in indices.iter() {
209 let index = point as usize;
210 let x = f64::from(positions[index * 3]);
211 let y = f64::from(positions[index * 3 + 1]);
212 let z = f64::from(positions[index * 3 + 2]);
213 let mut octant = 0u8;
214 if x >= center[0] {
215 octant |= 0b100;
216 }
217 if y >= center[1] {
218 octant |= 0b010;
219 }
220 if z >= center[2] {
221 octant |= 0b001;
222 }
223 buckets[octant as usize].push(point);
224 }
225 indices.clear();
226
227 let child_error = geometric_error * options.geometric_error_scale;
228 let mut children = Vec::new();
229 for (octant_index, bucket) in buckets.iter_mut().enumerate() {
230 if bucket.is_empty() {
231 continue;
232 }
233 let bits = BITS[octant_index];
234 let child_min = [
235 if bits & 0b100 != 0 { center[0] } else { min[0] },
236 if bits & 0b010 != 0 { center[1] } else { min[1] },
237 if bits & 0b001 != 0 { center[2] } else { min[2] },
238 ];
239 let child_max = [
240 if bits & 0b100 != 0 { max[0] } else { center[0] },
241 if bits & 0b010 != 0 { max[1] } else { center[1] },
242 if bits & 0b001 != 0 { max[2] } else { center[2] },
243 ];
244 let child = build_node(bucket, child_min, child_max, child_error, options, positions)?;
245 if !child.points.is_empty() || !child.children.is_empty() {
246 children.push(child);
247 }
248 }
249
250 Ok(Node { min, max, geometric_error, points: Vec::new(), children })
251}
252
253fn materialize(
254 node: Node,
255 positions: &[f32],
256 rgb: Option<&[u8]>,
257 tiles: &mut Vec<BuiltTile>,
258) -> InterchangeResult<Tile> {
259 let center = [
260 (node.min[0] + node.max[0]) * 0.5,
261 (node.min[1] + node.max[1]) * 0.5,
262 (node.min[2] + node.max[2]) * 0.5,
263 ];
264
265 let mut content = None;
266 if !node.points.is_empty() {
267 let mut local_positions = Vec::with_capacity(node.points.len() * 3);
268 for &point in &node.points {
269 let index = point as usize;
270 local_positions.push(positions[index * 3] - center[0] as f32);
271 local_positions.push(positions[index * 3 + 1] - center[1] as f32);
272 local_positions.push(positions[index * 3 + 2] - center[2] as f32);
273 }
274 let tile_rgb = rgb.map(|rgb| {
275 node.points
276 .iter()
277 .flat_map(|&point| {
278 let index = point as usize * 3;
279 rgb[index..index + 3].to_vec()
280 })
281 .collect::<Vec<u8>>()
282 });
283 let table = PntsFeatureTable {
284 positions: local_positions,
285 rgb: tile_rgb,
286 rtc_center: Some(center),
287 };
288 let pnts = encode_pnts(&table)?;
289 let uri = format!("{}.pnts", tiles.len());
290 let point_count = table.point_count();
291 tiles.push(BuiltTile { uri: uri.clone(), pnts, point_count });
292 content = Some(TileContent { uri });
293 }
294
295 let mut children = Vec::new();
296 for child in node.children {
297 children.push(materialize(child, positions, rgb, tiles)?);
298 }
299
300 let bounding_volume = BoundingVolume::box_from_bounds(node.min, node.max)?;
301 Ok(Tile {
302 bounding_volume,
303 geometric_error: node.geometric_error,
304 refine: None,
305 content,
306 children,
307 })
308}
309
310fn compute_bounds(positions: &[f32]) -> ([f64; 3], [f64; 3]) {
311 let mut min = [f64::INFINITY; 3];
312 let mut max = [f64::NEG_INFINITY; 3];
313 for (index, value) in positions.iter().enumerate() {
314 let axis = index % 3;
315 min[axis] = min[axis].min(f64::from(*value));
316 max[axis] = max[axis].max(f64::from(*value));
317 }
318 for axis in 0..3 {
319 if !(max[axis] - min[axis]).is_normal() {
320 min[axis] -= 1.0e-6;
321 max[axis] += 1.0e-6;
322 }
323 }
324 (min, max)
325}
326
327fn bounds_diagonal(min: [f64; 3], max: [f64; 3]) -> f64 {
328 let dx = max[0] - min[0];
329 let dy = max[1] - min[1];
330 let dz = max[2] - min[2];
331 (dx * dx + dy * dy + dz * dz).sqrt()
332}
333
334fn io_error(error: std::io::Error) -> InterchangeError {
335 InterchangeError::InvalidConfiguration(format!("tileset IO failure: {error}"))
336}
337
338#[cfg(test)]
339mod tests {
340 use super::{build_point_tileset, write_point_tileset, TilesetBuilderOptions};
341 use crate::tiles3d::pnts::decode_pnts;
342
343 fn grid_points(size: usize, step: f32) -> Vec<f32> {
344 let mut out = Vec::new();
345 for x in 0..size {
346 for y in 0..size {
347 for z in 0..size {
348 out.push(x as f32 * step);
349 out.push(y as f32 * step);
350 out.push(z as f32 * step);
351 }
352 }
353 }
354 out
355 }
356
357 #[test]
358 fn splits_into_multiple_tiles() {
359 let positions = grid_points(16, 1.0);
360 let built = build_point_tileset(
361 &positions,
362 None,
363 &TilesetBuilderOptions { max_points_per_tile: 64, ..Default::default() },
364 )
365 .unwrap();
366 assert!(built.tiles.len() > 1);
367 let total: usize = built.tiles.iter().map(|tile| tile.point_count).sum();
368 assert_eq!(total, positions.len() / 3);
369 for tile in &built.tiles {
370 let decoded = decode_pnts(&tile.pnts).unwrap();
371 assert_eq!(decoded.point_count(), tile.point_count);
372 assert!(decoded.rtc_center.is_some());
373 }
374 }
375
376 #[test]
377 fn single_tile_within_budget() {
378 let positions = grid_points(2, 1.0);
379 let built =
380 build_point_tileset(&positions, None, &TilesetBuilderOptions::default()).unwrap();
381 assert_eq!(built.tiles.len(), 1);
382 assert_eq!(built.tiles[0].point_count, 8);
383 }
384
385 #[test]
386 fn preserves_rgb_per_tile() {
387 let positions = grid_points(8, 1.0);
388 let rgb: Vec<u8> = (0..positions.len()).map(|index| (index % 251) as u8).collect();
389 let built = build_point_tileset(
390 &positions,
391 Some(&rgb),
392 &TilesetBuilderOptions { max_points_per_tile: 16, ..Default::default() },
393 )
394 .unwrap();
395 let mut rgb_points = 0usize;
396 for tile in &built.tiles {
397 let decoded = decode_pnts(&tile.pnts).unwrap();
398 assert_eq!(decoded.rgb.as_ref().unwrap().len(), decoded.point_count() * 3);
399 rgb_points += decoded.point_count();
400 }
401 assert_eq!(rgb_points, positions.len() / 3);
402 }
403
404 #[test]
405 fn deterministic_output() {
406 let positions = grid_points(12, 0.5);
407 let options = TilesetBuilderOptions { max_points_per_tile: 32, ..Default::default() };
408 let first = build_point_tileset(&positions, None, &options).unwrap();
409 let second = build_point_tileset(&positions, None, &options).unwrap();
410 assert_eq!(first.tiles, second.tiles);
411 assert_eq!(first.tileset, second.tileset);
412 }
413
414 #[test]
415 fn rejects_invalid_input() {
416 assert!(build_point_tileset(&[0.0, 0.0], None, &TilesetBuilderOptions::default()).is_err());
417 assert!(build_point_tileset(
418 &[f32::NAN, 0.0, 0.0],
419 None,
420 &TilesetBuilderOptions::default()
421 )
422 .is_err());
423 let options = TilesetBuilderOptions { max_points_per_tile: 0, ..Default::default() };
424 assert!(build_point_tileset(&[0.0, 0.0, 0.0], None, &options).is_err());
425 }
426
427 #[test]
428 fn writes_files_and_receipt() {
429 let positions = grid_points(6, 1.0);
430 let built =
431 build_point_tileset(&positions, None, &TilesetBuilderOptions::default()).unwrap();
432 let dir = std::env::temp_dir().join(format!("spatialrust-tiles3d-{}", std::process::id()));
433 let receipt = write_point_tileset(&dir, &built).unwrap();
434 assert_eq!(receipt.tile_count as usize, built.tiles.len());
435 assert_eq!(receipt.point_count, (positions.len() / 3) as u64);
436 assert!(dir.join("tileset.json").exists());
437 assert!(dir.join(&built.tiles[0].uri).exists());
438 let _ = std::fs::remove_dir_all(&dir);
439 }
440}