Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/spatialrust-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 12 additions & 0 deletions crates/spatialrust-py/spatialrust.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/spatialrust-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use std::path::PathBuf;

#[allow(unsafe_code)]
mod dlpack_capsule;
mod tiles3d;
mod viewer;

use viewer::{PyViewerPointSource, PyViewerState};
Expand Down Expand Up @@ -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(())
}
96 changes: 96 additions & 0 deletions crates/spatialrust-py/src/tiles3d.rs
Original file line number Diff line number Diff line change
@@ -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<Bound<'py, PyDict>> {
let cloud = cloud.extract::<PyRef<'_, crate::PyPointCloud>>()?;
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<i32>,
) -> PyResult<Bound<'py, PyDict>> {
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
}
17 changes: 17 additions & 0 deletions crates/spatialrust-py/tests/test_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion docs/FEATURE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions notes/2026-08-06_epic147_tiles3d.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Loading