diff --git a/CHANGELOG.md b/CHANGELOG.md index 7edaf75..0f2d632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). ### Added +- **Epic 147F Python 3D Tiles bindings**: `export_tiles3d` converts a + `PointCloud` into a tileset directory and `export_copc_tiles3d` converts a + `.copc.laz` file into a bounded tileset, both returning a + `tileset_json_bytes`/`tile_count`/`point_count`/`pnts_bytes` dict. Typed + `.pyi` stubs and smoke tests gate the wheel build. - **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 diff --git a/crates/spatialrust-py/Cargo.toml b/crates/spatialrust-py/Cargo.toml index 12149a6..d959bfc 100644 --- a/crates/spatialrust-py/Cargo.toml +++ b/crates/spatialrust-py/Cargo.toml @@ -54,6 +54,8 @@ spatialrust = { path = "../spatialrust", features = [ "records-receipt-json", "viewer-native", "web", + "interchange-tiles3d", + "interchange-tiles3d-copc", ] } # Keep this crate out of the main Rust workspace so `cargo test --workspace` diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index b3cb766..1bbe1a8 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -822,6 +822,18 @@ def register_fpfh_keypoints( ransac_iterations: int = ..., k_neighbors: int = ..., ) -> RegistrationResult: ... + +def export_tiles3d( + cloud: PointCloud, + out_dir: str, + max_points_per_tile: int = ..., + max_depth: int = ..., +) -> dict[str, int]: ... +def export_copc_tiles3d( + copc_path: str, + out_dir: str, + max_level: int | None = ..., +) -> dict[str, int]: ... @final class Tensor: @property diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index c16a8fb..cb12fe5 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -12,6 +12,7 @@ use std::path::PathBuf; #[allow(unsafe_code)] mod dlpack_capsule; +mod tiles3d; mod viewer; use viewer::{PyViewerPointSource, PyViewerState}; @@ -4693,5 +4694,7 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(register_ndt, m)?)?; m.add_function(wrap_pyfunction!(register_fpfh_ransac, m)?)?; m.add_function(wrap_pyfunction!(register_fpfh_keypoints, m)?)?; + m.add_function(wrap_pyfunction!(tiles3d::export_tiles3d, m)?)?; + m.add_function(wrap_pyfunction!(tiles3d::export_copc_tiles3d, m)?)?; Ok(()) } diff --git a/crates/spatialrust-py/src/tiles3d.rs b/crates/spatialrust-py/src/tiles3d.rs new file mode 100644 index 0000000..5febc82 --- /dev/null +++ b/crates/spatialrust-py/src/tiles3d.rs @@ -0,0 +1,96 @@ +//! Python bindings for the OGC 3D Tiles 1.1 tileset export surface. + +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use pyo3::PyRef; + +use spatialrust::interchange::{ + build_point_tileset, export_copc_tileset, write_point_tileset, CopcTilesetOptions, + TilesetBuilderOptions, TilesetWriteReceipt, +}; +use spatialrust::HasPositions3; + +/// Exports a point cloud into a 3D Tiles 1.1 tileset directory. +/// +/// Args: +/// cloud: spatialrust.PointCloud +/// out_dir: str — directory that receives `tileset.json` plus `.pnts` files +/// max_points_per_tile: int (default 100000) — octree split budget +/// max_depth: int (default 12) — maximum octree depth +/// +/// Returns a dict with `tileset_json_bytes`, `tile_count`, `point_count`, +/// and `pnts_bytes`. +#[pyfunction] +#[pyo3(signature = (cloud, out_dir, max_points_per_tile = 100_000, max_depth = 12))] +pub fn export_tiles3d<'py>( + py: Python<'py>, + cloud: &Bound<'_, PyAny>, + out_dir: &str, + max_points_per_tile: usize, + max_depth: u32, +) -> PyResult> { + let cloud = cloud.extract::>()?; + let (x, y, z) = cloud + .inner + .positions3() + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + let mut positions = Vec::with_capacity(cloud.inner.len() * 3); + for index in 0..cloud.inner.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, + max_depth, + ..Default::default() + }, + ) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + let receipt = write_point_tileset(out_dir, &built) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + Ok(receipt_to_dict(py, &receipt)) +} + +/// Exports a COPC file into a 3D Tiles 1.1 tileset directory without +/// materializing the whole cloud. +/// +/// Args: +/// copc_path: str — path to a `.copc.laz` file +/// out_dir: str — directory that receives `tileset.json` plus `.pnts` files +/// max_level: int | None (default None) — maximum octree level to export +/// +/// Returns a dict with `tileset_json_bytes`, `tile_count`, `point_count`, +/// and `pnts_bytes`. +#[pyfunction] +#[pyo3(signature = (copc_path, out_dir, max_level = None))] +pub fn export_copc_tiles3d<'py>( + py: Python<'py>, + copc_path: &str, + out_dir: &str, + max_level: Option, +) -> PyResult> { + let receipt = export_copc_tileset( + copc_path, + out_dir, + &CopcTilesetOptions { max_level, ..Default::default() }, + ) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + Ok(receipt_to_dict(py, &receipt)) +} + +fn receipt_to_dict<'py>( + py: Python<'py>, + receipt: &TilesetWriteReceipt, +) -> Bound<'py, PyDict> { + let dict = PyDict::new_bound(py); + dict.set_item("tileset_json_bytes", receipt.tileset_json_bytes).unwrap(); + dict.set_item("tile_count", receipt.tile_count).unwrap(); + dict.set_item("point_count", receipt.point_count).unwrap(); + dict.set_item("pnts_bytes", receipt.pnts_bytes).unwrap(); + dict +} diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 8ec5919..3d0b8ad 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -1228,3 +1228,20 @@ def test_multi_object_tracker_preserves_ids_and_confirms(): assert first[0][0] == second[0][0] == 1 assert first[0][10] is False assert second[0][10] is True + + +def test_export_tiles3d_writes_tileset_and_pnts(tmp_path): + cloud = sr.PointCloud.from_xyz(grid_plane(24, spacing=1.0)) + out = str(tmp_path / "tiles") + receipt = sr.export_tiles3d(cloud, out, max_points_per_tile=16, max_depth=8) + assert receipt["point_count"] == 24 * 24 + assert receipt["tile_count"] >= 1 + assert (tmp_path / "tiles" / "tileset.json").exists() + assert (tmp_path / "tiles" / "0.pnts").exists() + + +def test_export_copc_tiles3d_fails_closed_on_missing_input(tmp_path): + out = str(tmp_path / "copc-tiles") + with pytest.raises(RuntimeError): + sr.export_copc_tiles3d("/nonexistent/cloud.copc.laz", out) + assert not (tmp_path / "copc-tiles" / "tileset.json").exists() diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index 6f43126..c746b7e 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -37,7 +37,7 @@ workspace because its build requires a Python toolchain. | `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; `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 | +| `spatialrust-py` | Python binding surface | selected meta-crate features, including `export_tiles3d` / `export_copc_tiles3d` | PyO3/NumPy | ## Execution contract diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ba48cd6..6050562 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -92,6 +92,7 @@ on `spatialrust-core`, a GPU backend, or serde. | 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`, with LAS color preserved as 8-bit `pnts` RGB | `tiles3d-copc` | +| 147F | Complete | Python `export_tiles3d` / `export_copc_tiles3d` bindings with typed stubs and smoke tests | Python tiles3d surface | 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 diff --git a/notes/2026-08-06_epic147_tiles3d.md b/notes/2026-08-06_epic147_tiles3d.md index e0e03c4..6e1cfdf 100644 --- a/notes/2026-08-06_epic147_tiles3d.md +++ b/notes/2026-08-06_epic147_tiles3d.md @@ -38,6 +38,8 @@ differentiator beyond glTF/USDA interchange. - Facade features `interchange-tiles3d`/`interchange-tiles3d-copc`, `tiles3d_export` and `tiles3d_copc_export` examples, and `tests/tiles3d_smoke.rs` end-to-end tests. +- Python bindings: `export_tiles3d` / `export_copc_tiles3d` in + `crates/spatialrust-py/src/tiles3d.rs`, typed `.pyi` stubs, and smoke tests. ## Contract decisions @@ -64,6 +66,6 @@ differentiator beyond glTF/USDA interchange. ## Next slices -Epic 147 is the codec/builder substrate. The COPC exporter (147E) already -streams one node at a time and preserves LAS color; Python bindings remain as a -follow-up slice through the meta-crate feature. +Epic 147 is the codec/builder substrate. The COPC exporter (147E) streams one +node at a time and preserves LAS color, and the Python bindings (147F) expose +both the point-cloud and COPC tileset surfaces through typed stubs.