diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37e2471..ecdd6c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,10 +266,12 @@ jobs: run: | python -m venv .venv . .venv/bin/activate - pip install maturin pytest numpy mypy + pip install maturin pytest numpy mypy "anywidget>=0.9,<0.10" nbclient nbformat ipykernel hatchling maturin develop --release --manifest-path crates/spatialrust-py/Cargo.toml + pip install --no-deps -e python/spatialrust-jupyter python crates/spatialrust-py/examples/video_tracking_e2e.py --no-gif pytest crates/spatialrust-py/tests + pytest python/spatialrust-jupyter/tests # The comparison report contract stays stdlib-only. OpenCV itself is # intentionally not a production or default-CI dependency. python bench/opencv_comparison/test_report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e850f59..dbfd78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,13 @@ removed no sooner than the next major (see `docs/API_STABILITY.md`). exact HTTP Range fetches plus deterministic request/cache budgets, response length validation, explicit JS/WASM copies, and LRU eviction receipts. +- **Python and Jupyter viewer adapters (Epic 139)**: the abi3 Python wheel + exposes the canonical viewer state, shared input reducer, validated native + launch, retained zero-copy NumPy SoA columns, and byte-exact explicit-copy + receipts. `spatialrust-jupyter` adds a versioned AnyWidget transport with + strict origin/source checks, canonical frontend state validation, and an + executable notebook smoke fixture for the Web viewer embed. + ## [1.2.0] — 2026-07-27 ### Added diff --git a/crates/spatialrust-py/Cargo.toml b/crates/spatialrust-py/Cargo.toml index 0597e49..12149a6 100644 --- a/crates/spatialrust-py/Cargo.toml +++ b/crates/spatialrust-py/Cargo.toml @@ -21,6 +21,7 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.22", features = ["extension-module", "abi3-py38"] } numpy = "0.22" +serde_json = "1" spatialrust = { path = "../spatialrust", features = [ "mvp", "search-graph", @@ -51,6 +52,8 @@ spatialrust = { path = "../spatialrust", features = [ "pipeline-streaming", "io-laz", "records-receipt-json", + "viewer-native", + "web", ] } # Keep this crate out of the main Rust workspace so `cargo test --workspace` diff --git a/crates/spatialrust-py/README.md b/crates/spatialrust-py/README.md index 07e8583..0d5e6c3 100644 --- a/crates/spatialrust-py/README.md +++ b/crates/spatialrust-py/README.md @@ -44,6 +44,34 @@ cross-check, and maximum-distance filters. Geometry bindings expose `estimate_homography_ransac`, `solve_pnp`, and `stereo_block_match` for NumPy `float64` / grayscale workflows. +## Viewer and NumPy ownership + +`ViewerState` uses the same strict, versioned JSON contract as the native, +WebAssembly, and Jupyter viewers. Input messages are applied by the shared Rust +reducer. `launch_native()` opens the opt-in native shell on the calling thread; +it does not upload geometry. + +```python +import numpy as np +import spatialrust as sr + +state = sr.ViewerState(1280, 720) +state.apply_input_json('{"kind":"zoom","delta":1.0}') + +x = np.arange(100, dtype=np.float32) +points = sr.ViewerPointSource.borrow_numpy(x, x + 1, x + 2) +assert points.source_pointers[0] == x.__array_interface__["data"][0] +print(points.transfer_receipt_json()) # zero host-to-host bytes + +owned = sr.ViewerPointSource.copy_from_numpy(np.column_stack([x, x, x])) +snapshot, copy_receipt = owned.copy_to_numpy() +``` + +Borrowed sources retain the three NumPy owners and require contiguous +`float32` SoA columns. Copying is never inferred: `copy_from_numpy()` and +`copy_to_numpy()` report exact host bytes, while all CPU/GPU transfer counters +remain zero until a separate renderer upload is requested. + ## Test The bindings have a pytest suite (`tests/`) that exercises the NumPy ⇄ Rust diff --git a/crates/spatialrust-py/spatialrust.pyi b/crates/spatialrust-py/spatialrust.pyi index 5965d66..a17ab0c 100644 --- a/crates/spatialrust-py/spatialrust.pyi +++ b/crates/spatialrust-py/spatialrust.pyi @@ -15,6 +15,7 @@ __all__: list[str] = [ "__version__", "ImageMetadata", "Tensor", "Keypoint2", "OnnxRuntimeSession", "GaussianBlurWorkspace", "DLPackTensorView", "PointCloud", "PointCloudStream", "PipelineResult", "RegionResult", + "ViewerState", "ViewerPointSource", "DbscanResult", "GroundResult", "MultiPlaneResult", "SphereResult", "CylinderResult", "RegistrationResult", "MultiObjectTracker", "read_image", "tensor_copy_from_numpy", "tensor_view_from_dlpack", "harris_keypoints", @@ -57,6 +58,42 @@ _Vec3 = tuple[float, float, float] _U8Array = NDArray[np.uint8] _U16Array = NDArray[np.uint16] +@final +class ViewerState: + """Portable viewer state shared with native, Web, and Jupyter adapters.""" + + def __new__( + cls, width: int = ..., height: int = ... + ) -> ViewerState: ... + @staticmethod + def from_json(state_json: str) -> ViewerState: ... + def to_json(self) -> str: ... + def apply_input_json(self, input_json: str) -> None: ... + @property + def version(self) -> int: ... + @property + def revision(self) -> int: ... + def native_launch_receipt(self, title: str) -> str: ... + def launch_native(self, title: str = ...) -> None: ... + +@final +class ViewerPointSource: + """Explicit borrowed or copied point columns for viewer adapters.""" + + @staticmethod + def borrow_numpy( + x: _F32Array, y: _F32Array, z: _F32Array + ) -> ViewerPointSource: ... + @staticmethod + def copy_from_numpy(positions: _F32Array) -> ViewerPointSource: ... + @property + def ownership(self) -> str: ... + @property + def source_pointers(self) -> tuple[int, int, int]: ... + def __len__(self) -> int: ... + def transfer_receipt_json(self) -> str: ... + def copy_to_numpy(self) -> tuple[_F32Array, str]: ... + @final class ImageMetadata: """Container, sample type, and Exif orientation from image decoding.""" diff --git a/crates/spatialrust-py/src/lib.rs b/crates/spatialrust-py/src/lib.rs index 2436a12..789621e 100644 --- a/crates/spatialrust-py/src/lib.rs +++ b/crates/spatialrust-py/src/lib.rs @@ -10,6 +10,9 @@ #[allow(unsafe_code)] mod dlpack_capsule; +mod viewer; + +use viewer::{PyViewerPointSource, PyViewerState}; use numpy::ndarray::{Array2, Array3}; use numpy::{ @@ -4451,6 +4454,8 @@ fn spatialrust_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(read_image, m)?)?; m.add_function(wrap_pyfunction!(tensor_copy_from_numpy, m)?)?; m.add_function(wrap_pyfunction!(tensor_view_from_dlpack, m)?)?; diff --git a/crates/spatialrust-py/src/viewer.rs b/crates/spatialrust-py/src/viewer.rs new file mode 100644 index 0000000..65ed818 --- /dev/null +++ b/crates/spatialrust-py/src/viewer.rs @@ -0,0 +1,258 @@ +use numpy::ndarray::Array2; +use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods, PyReadonlyArray1, PyReadonlyArray2}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +use spatialrust::math::Vec3; +use spatialrust::viewer::{NativeViewer, NativeViewerOptions, ViewerState, ViewportSize}; +use spatialrust::viz::{Camera, PositionColumns3, Projection}; +use spatialrust::web::{BrowserInput, WebViewerState}; + +use crate::to_py_err; + +#[pyclass(name = "ViewerState")] +#[derive(Clone)] +pub(crate) struct PyViewerState { + inner: WebViewerState, +} + +#[pymethods] +impl PyViewerState { + #[new] + #[pyo3(signature = (width=1280, height=720))] + fn new(width: u32, height: u32) -> PyResult { + let camera = Camera::try_new( + Vec3::new(0.0, 0.0, 5.0), + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Projection::Perspective { vertical_fov_radians: 1.0, near: 0.1, far: 100.0 }, + ) + .map_err(to_py_err)?; + let viewer = + ViewerState::try_new(camera, ViewportSize::try_new(width, height).map_err(to_py_err)?) + .map_err(to_py_err)?; + Ok(Self { inner: WebViewerState::try_new(viewer).map_err(to_py_err)? }) + } + + #[staticmethod] + fn from_json(state_json: &str) -> PyResult { + Ok(Self { inner: WebViewerState::from_json(state_json).map_err(to_py_err)? }) + } + + fn to_json(&self) -> PyResult { + self.inner.to_json().map_err(to_py_err) + } + + fn apply_input_json(&mut self, input_json: &str) -> PyResult<()> { + let input: BrowserInput = serde_json::from_str(input_json).map_err(to_py_err)?; + self.inner.apply(input).map_err(to_py_err) + } + + #[getter] + fn version(&self) -> u32 { + self.inner.version + } + + #[getter] + fn revision(&self) -> u64 { + self.inner.revision + } + + fn native_launch_receipt(&self, title: &str) -> PyResult { + let options = NativeViewerOptions { + title: title.to_owned(), + width: self.inner.viewer.viewport.width, + height: self.inner.viewer.viewport.height, + }; + NativeViewer::try_new(self.inner.viewer.clone(), options).map_err(to_py_err)?; + serde_json::to_string(&serde_json::json!({ + "state_version": self.inner.version, + "state_revision": self.inner.revision, + "width": self.inner.viewer.viewport.width, + "height": self.inner.viewer.viewport.height, + "host_to_device_bytes": 0, + "device_to_host_bytes": 0, + })) + .map_err(to_py_err) + } + + #[pyo3(signature = (title="SpatialRust Viewer"))] + fn launch_native(&self, py: Python<'_>, title: &str) -> PyResult<()> { + let state = self.inner.viewer.clone(); + let options = NativeViewerOptions { + title: title.to_owned(), + width: state.viewport.width, + height: state.viewport.height, + }; + py.allow_threads(move || NativeViewer::try_new(state, options).and_then(NativeViewer::run)) + .map_err(to_py_err) + } + + fn __repr__(&self) -> String { + format!( + "ViewerState(version={}, revision={}, viewport={}x{})", + self.inner.version, + self.inner.revision, + self.inner.viewer.viewport.width, + self.inner.viewer.viewport.height + ) + } +} + +enum PointStorage { + Borrowed { x: Py, y: Py, z: Py, pointers: [usize; 3], len: usize }, + Owned { x: Vec, y: Vec, z: Vec, source_bytes: u64 }, +} + +#[pyclass(name = "ViewerPointSource", unsendable)] +pub(crate) struct PyViewerPointSource { + storage: PointStorage, +} + +#[pymethods] +impl PyViewerPointSource { + #[staticmethod] + fn borrow_numpy( + x: PyReadonlyArray1<'_, f32>, + y: PyReadonlyArray1<'_, f32>, + z: PyReadonlyArray1<'_, f32>, + ) -> PyResult { + let x_slice = x + .as_slice() + .map_err(|_| PyValueError::new_err("x must be a contiguous float32 array"))?; + let y_slice = y + .as_slice() + .map_err(|_| PyValueError::new_err("y must be a contiguous float32 array"))?; + let z_slice = z + .as_slice() + .map_err(|_| PyValueError::new_err("z must be a contiguous float32 array"))?; + PositionColumns3::try_new(x_slice, y_slice, z_slice).map_err(to_py_err)?; + let pointers = + [x_slice.as_ptr() as usize, y_slice.as_ptr() as usize, z_slice.as_ptr() as usize]; + let len = x_slice.len(); + let x = x.as_untyped().clone().into_any().unbind(); + let y = y.as_untyped().clone().into_any().unbind(); + let z = z.as_untyped().clone().into_any().unbind(); + Ok(Self { storage: PointStorage::Borrowed { x, y, z, pointers, len } }) + } + + #[staticmethod] + fn copy_from_numpy(positions: PyReadonlyArray2<'_, f32>) -> PyResult { + let positions = positions.as_array(); + if positions.shape().len() != 2 || positions.shape()[1] != 3 { + return Err(PyValueError::new_err("positions must have shape (N, 3)")); + } + let len = positions.shape()[0]; + let source_bytes = u64::try_from(len) + .ok() + .and_then(|count| count.checked_mul(12)) + .ok_or_else(|| PyValueError::new_err("point byte count overflow"))?; + let mut x = Vec::with_capacity(len); + let mut y = Vec::with_capacity(len); + let mut z = Vec::with_capacity(len); + for row in positions.rows() { + x.push(row[0]); + y.push(row[1]); + z.push(row[2]); + } + PositionColumns3::try_new(&x, &y, &z).map_err(to_py_err)?; + Ok(Self { storage: PointStorage::Owned { x, y, z, source_bytes } }) + } + + #[getter] + fn ownership(&self) -> &'static str { + match self.storage { + PointStorage::Borrowed { .. } => "borrowed_numpy", + PointStorage::Owned { .. } => "owned_rust", + } + } + + fn __len__(&self) -> usize { + match &self.storage { + PointStorage::Borrowed { len, .. } => *len, + PointStorage::Owned { x, .. } => x.len(), + } + } + + #[getter] + fn source_pointers(&self) -> (usize, usize, usize) { + let pointers = match &self.storage { + PointStorage::Borrowed { pointers, .. } => *pointers, + PointStorage::Owned { x, y, z, .. } => { + [x.as_ptr() as usize, y.as_ptr() as usize, z.as_ptr() as usize] + } + }; + (pointers[0], pointers[1], pointers[2]) + } + + fn transfer_receipt_json(&self) -> PyResult { + let point_count = self.__len__(); + let source_bytes = u64::try_from(point_count) + .ok() + .and_then(|count| count.checked_mul(12)) + .ok_or_else(|| PyValueError::new_err("point byte count overflow"))?; + let host_to_host_bytes = match &self.storage { + PointStorage::Borrowed { .. } => 0, + PointStorage::Owned { source_bytes, .. } => *source_bytes, + }; + serde_json::to_string(&serde_json::json!({ + "ownership": self.ownership(), + "point_count": point_count, + "source_bytes": source_bytes, + "host_to_host_bytes": host_to_host_bytes, + "host_to_device_bytes": 0, + "device_to_host_bytes": 0, + })) + .map_err(to_py_err) + } + + fn copy_to_numpy<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyArray2>, String)> { + let mut values = Vec::with_capacity(self.__len__().saturating_mul(3)); + match &self.storage { + PointStorage::Borrowed { x, y, z, .. } => { + let x = x.bind(py).downcast::>()?.readonly(); + let y = y.bind(py).downcast::>()?.readonly(); + let z = z.bind(py).downcast::>()?.readonly(); + let x = x.as_slice().map_err(|_| { + PyValueError::new_err("retained x array is no longer contiguous") + })?; + let y = y.as_slice().map_err(|_| { + PyValueError::new_err("retained y array is no longer contiguous") + })?; + let z = z.as_slice().map_err(|_| { + PyValueError::new_err("retained z array is no longer contiguous") + })?; + PositionColumns3::try_new(x, y, z).map_err(to_py_err)?; + append_interleaved(&mut values, x, y, z); + } + PointStorage::Owned { x, y, z, .. } => { + append_interleaved(&mut values, x, y, z); + } + } + let byte_count = u64::try_from(values.len()) + .ok() + .and_then(|count| count.checked_mul(4)) + .ok_or_else(|| PyValueError::new_err("snapshot byte count overflow"))?; + let array = Array2::from_shape_vec((self.__len__(), 3), values) + .map_err(to_py_err)? + .into_pyarray_bound(py); + let receipt = serde_json::to_string(&serde_json::json!({ + "direction": "rust_to_numpy", + "copied_bytes": byte_count, + "host_to_device_bytes": 0, + "device_to_host_bytes": 0, + })) + .map_err(to_py_err)?; + Ok((array, receipt)) + } + + fn __repr__(&self) -> String { + format!("ViewerPointSource(ownership='{}', len={})", self.ownership(), self.__len__()) + } +} + +fn append_interleaved(output: &mut Vec, x: &[f32], y: &[f32], z: &[f32]) { + for ((x, y), z) in x.iter().zip(y).zip(z) { + output.extend_from_slice(&[*x, *y, *z]); + } +} diff --git a/crates/spatialrust-py/tests/test_bindings.py b/crates/spatialrust-py/tests/test_bindings.py index 40cc7a4..25efd2c 100644 --- a/crates/spatialrust-py/tests/test_bindings.py +++ b/crates/spatialrust-py/tests/test_bindings.py @@ -9,6 +9,9 @@ """ import math +import gc +import json +import weakref import numpy as np import pytest @@ -59,6 +62,77 @@ def test_module_has_version(): assert sr.__version__ +def test_viewer_state_roundtrip_input_and_native_launch_receipt(): + state = sr.ViewerState(640, 480) + before = json.loads(state.to_json()) + assert before["version"] == 1 + assert before["revision"] == 0 + assert before["viewer"]["viewport"] == {"width": 640, "height": 480} + + state.apply_input_json(json.dumps({"kind": "zoom", "delta": 1.0})) + assert state.revision == 1 + assert sr.ViewerState.from_json(state.to_json()).to_json() == state.to_json() + + receipt = json.loads(state.native_launch_receipt("Python viewer smoke")) + assert receipt["state_revision"] == 1 + assert receipt["width"] == 640 + assert receipt["host_to_device_bytes"] == 0 + assert receipt["device_to_host_bytes"] == 0 + + invalid = json.loads(state.to_json()) + invalid["unknown"] = True + with pytest.raises(ValueError): + sr.ViewerState.from_json(json.dumps(invalid)) + + +def test_viewer_point_source_borrows_numpy_and_retains_lifetime(): + x = np.arange(4, dtype=np.float32) + y = x + 10 + z = x + 20 + pointers = tuple(array.__array_interface__["data"][0] for array in (x, y, z)) + references = tuple(weakref.ref(array) for array in (x, y, z)) + + source = sr.ViewerPointSource.borrow_numpy(x, y, z) + assert source.ownership == "borrowed_numpy" + assert source.source_pointers == pointers + receipt = json.loads(source.transfer_receipt_json()) + assert receipt["point_count"] == 4 + assert receipt["host_to_host_bytes"] == 0 + + del x, y, z + gc.collect() + assert all(reference() is not None for reference in references) + snapshot, copy_receipt_json = source.copy_to_numpy() + np.testing.assert_array_equal( + snapshot, + np.array( + [[0, 10, 20], [1, 11, 21], [2, 12, 22], [3, 13, 23]], + dtype=np.float32, + ), + ) + assert json.loads(copy_receipt_json)["copied_bytes"] == 48 + + del source + gc.collect() + assert all(reference() is None for reference in references) + + +def test_viewer_point_source_copy_is_independent_and_explicit(): + positions = np.arange(15, dtype=np.float32).reshape(5, 3) + source = sr.ViewerPointSource.copy_from_numpy(positions) + positions[:] = -1 + snapshot, copy_receipt_json = source.copy_to_numpy() + np.testing.assert_array_equal(snapshot, np.arange(15, dtype=np.float32).reshape(5, 3)) + receipt = json.loads(source.transfer_receipt_json()) + assert source.ownership == "owned_rust" + assert receipt["host_to_host_bytes"] == 60 + assert json.loads(copy_receipt_json)["copied_bytes"] == 60 + + base = np.arange(8, dtype=np.float32) + with pytest.raises(ValueError): + sr.ViewerPointSource.borrow_numpy(base[::2], base[:4], base[4:]) + + def test_exports_present(): for name in ( "PointCloud", "PointCloudStream", "open_point_cloud_stream", diff --git a/crates/spatialrust-web/web/widget_embed.html b/crates/spatialrust-web/web/widget_embed.html new file mode 100644 index 0000000..a4615c1 --- /dev/null +++ b/crates/spatialrust-web/web/widget_embed.html @@ -0,0 +1,45 @@ + + +SpatialRust Web viewer widget embed +
Waiting for SpatialRust viewer state
+ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 723b0e2..43a9bc4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -220,3 +220,13 @@ wgpu sharing is unavailable on wasm32; browser callers construct a main-thread runtime asynchronously. Remote byte access requires a bounded range plan before fetch, an abort signal, `206` plus exact response-length evidence, and explicit admission into the bounded WASM cache. + +The standalone `spatialrust-py` wheel wraps that same Web viewer envelope +instead of defining another state model. NumPy viewer geometry either retains +three contiguous SoA owners without copying or enters owned Rust storage through +an explicitly named, byte-receipted copy. Native launch creates only the winit +state/input shell; renderer uploads remain separate. The independently packaged +`spatialrust-jupyter` AnyWidget validates state with the Rust binding and sends +it to `spatialrust-web` through a versioned iframe protocol with exact +source/origin checks. Neither adapter chooses a device, range source, upload, or +readback. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fa0ad7e..ebfc5f7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -793,7 +793,7 @@ COPC/index-driven LOD follows the Epics 127–132 contracts. | 136 | Complete | 92–94, 135 | Mesh, surfel, Gaussian, trajectory, pose-graph, camera-frustum, RGB-D, and semantic scene inspection | | 137 | Complete | 127–132, 134 | Bounded COPC/index-driven frustum and LOD streaming with cancellation, progressive refinement, and strict memory/upload/point budgets | | 138 | Complete | 134–137 | WebAssembly/WebGPU viewer with portable scene state, browser input, and bounded remote data access | -| 139 | Planned | 135–138 | Python and Jupyter adapters using the same viewer state and explicit ownership/transfer contracts | +| 139 | Complete | 135–138 | Python and Jupyter adapters using the same viewer state and explicit ownership/transfer contracts | | 140 | Planned | 133–139 | Cross-platform headless image conformance, native/Web/Python smoke tests, performance receipts, documentation, migration guidance, and Visual release gate | ### Visual delivery slices @@ -855,6 +855,13 @@ COPC/index-driven LOD follows the Epics 127–132 contracts. | 138A | Complete | Strict versioned viewer-state JSON, shared browser input reducer, async WebGPU runtime construction, same-backend 64×64 pixel parity, wasm32 `wasm,webgpu` cross-check, and executable browser smoke fixture | | 138B | Complete | AbortController-backed 206 Range fetch with exact Content-Length/body validation, deterministic request/byte admission, cancellation, exact-range cache hits, bounded LRU eviction, and JS/WASM copy receipts | +### Epic 139 progress + +| Slice | Status | Evidence | +| --- | --- | --- | +| 139A | Complete | abi3 wheel import, strict shared viewer-state round-trip, shared reducer input, validated native launch receipt, retained contiguous NumPy SoA pointer identity/lifetime, explicit AoS copy isolation, and exact byte receipts | +| 139B | Complete | AnyWidget transport with Rust state validation, exact source/origin/version checks, Web embed handshake, Python round-trip tests, and executable nbclient notebook smoke | + ### Visual completion gates - `spatialrust-viz` builds without wgpu, windowing, image codecs, ROS 2, ONNX, diff --git a/notes/2026-07-28_visual_epic_139.md b/notes/2026-07-28_visual_epic_139.md new file mode 100644 index 0000000..e06867d --- /dev/null +++ b/notes/2026-07-28_visual_epic_139.md @@ -0,0 +1,35 @@ +# Visual Epic 139 receipt + +Date: 2026-07-28 + +## Scope + +- Added Python `ViewerState` over the exact `spatialrust-web` JSON envelope and + shared browser input reducer. +- Added validated native viewer launch without geometry upload. +- Added retained zero-copy NumPy SoA point columns and an explicit owned-copy + path with byte-exact receipts. +- Added `spatialrust-jupyter`, a versioned AnyWidget iframe transport, plus the + Web embed endpoint and executable notebook fixture. + +## Ownership and transport evidence + +- Borrowed `float32` X/Y/Z arrays retain their Python owners, preserve all + three data pointers, reject non-contiguous columns, and record zero copied + bytes. +- Owned point sources are isolated from later NumPy mutation and record exactly + `N * 3 * sizeof(float32)` host-copy bytes. +- Copying back to NumPy is explicitly named and returns its own exact receipt. +- Native launch records zero host/device transfer bytes. +- Notebook and frontend updates are revalidated by the Rust viewer-state + parser. The iframe protocol checks source window, exact origin, and transport + version in both directions. + +## Validation + +- `cargo check --manifest-path crates/spatialrust-py/Cargo.toml` +- abi3 Python 3.8+ release wheel build and isolated installation +- full `crates/spatialrust-py/tests` pytest suite +- `mypy.stubtest spatialrust` +- `python/spatialrust-jupyter/tests` pytest suite +- nbclient execution of `tests/viewer_smoke.ipynb` diff --git a/python/spatialrust-jupyter/README.md b/python/spatialrust-jupyter/README.md new file mode 100644 index 0000000..eb36263 --- /dev/null +++ b/python/spatialrust-jupyter/README.md @@ -0,0 +1,33 @@ +# spatialrust-jupyter + +`spatialrust-jupyter` is the notebook transport for the SpatialRust Web viewer. +It validates every state transition through the native `spatialrust.ViewerState` +binding, then synchronizes that canonical JSON with a separately served +`spatialrust-web` iframe. + +```python +import spatialrust as sr +from spatialrust_jupyter import ViewerWidget + +state = sr.ViewerState(1280, 720) +widget = ViewerWidget( + state, + viewer_url="https://viewer.example.test/spatialrust/widget_embed.html", +) +widget +``` + +The iframe URL must be absolute HTTP(S) and must not contain credentials. The +frontend appends the notebook's exact origin, uses that origin as the +`postMessage` target, and rejects messages from any other source, origin, or +transport version. Build the `spatialrust-web` WASM package beside +`widget_embed.html`; no remote data source or GPU transfer is selected +implicitly. + +`ViewerWidget.apply_input()` runs orbit/pan/zoom/resize/layer input through the +same Rust reducer used by native and browser adapters. `set_state()` and +frontend state messages reject unknown fields, unsupported versions, and +invalid camera/layer state before publication. + +Tests include Python transport contracts and executable +`tests/viewer_smoke.ipynb` coverage through `nbclient`. diff --git a/python/spatialrust-jupyter/pyproject.toml b/python/spatialrust-jupyter/pyproject.toml new file mode 100644 index 0000000..e71965f --- /dev/null +++ b/python/spatialrust-jupyter/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "spatialrust-jupyter" +version = "1.2.0" +description = "Jupyter widget transport for the SpatialRust Web viewer" +requires-python = ">=3.8" +license = { text = "MIT OR Apache-2.0" } +authors = [{ name = "SpatialRust Contributors" }] +dependencies = [ + "anywidget>=0.9,<0.10", + "spatialrust>=1.2.0", + "traitlets>=5", +] + +[project.optional-dependencies] +test = [ + "nbclient>=0.10", + "nbformat>=5.10", + "pytest>=7", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/spatialrust_jupyter"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/python/spatialrust-jupyter/src/spatialrust_jupyter/__init__.py b/python/spatialrust-jupyter/src/spatialrust_jupyter/__init__.py new file mode 100644 index 0000000..acbec97 --- /dev/null +++ b/python/spatialrust-jupyter/src/spatialrust_jupyter/__init__.py @@ -0,0 +1,6 @@ +"""Jupyter transport for the versioned SpatialRust viewer state.""" + +from .widget import VIEWER_TRANSPORT_VERSION, ViewerWidget + +__all__ = ["VIEWER_TRANSPORT_VERSION", "ViewerWidget"] +__version__ = "1.2.0" diff --git a/python/spatialrust-jupyter/src/spatialrust_jupyter/viewer_widget.js b/python/spatialrust-jupyter/src/spatialrust_jupyter/viewer_widget.js new file mode 100644 index 0000000..082289b --- /dev/null +++ b/python/spatialrust-jupyter/src/spatialrust_jupyter/viewer_widget.js @@ -0,0 +1,49 @@ +function render({ model, el }) { + const viewerUrl = new URL(model.get("viewer_url")); + const targetOrigin = viewerUrl.origin; + viewerUrl.searchParams.set("parent_origin", window.location.origin); + const iframe = document.createElement("iframe"); + iframe.src = viewerUrl.href; + iframe.title = "SpatialRust Web viewer"; + iframe.sandbox = "allow-scripts allow-same-origin"; + iframe.style.width = "100%"; + iframe.style.height = "540px"; + iframe.style.border = "0"; + el.appendChild(iframe); + + const publishState = () => { + if (!iframe.contentWindow) return; + iframe.contentWindow.postMessage( + { + kind: "spatialrust.viewer.state", + transport_version: model.get("transport_version"), + state: JSON.parse(model.get("state_json")), + }, + targetOrigin, + ); + }; + + const receiveState = (event) => { + if ( + event.source !== iframe.contentWindow || + event.origin !== targetOrigin || + event.data?.kind !== "spatialrust.viewer.state" || + event.data?.transport_version !== model.get("transport_version") + ) { + return; + } + model.set("state_json", JSON.stringify(event.data.state)); + model.save_changes(); + }; + + iframe.addEventListener("load", publishState); + model.on("change:state_json", publishState); + window.addEventListener("message", receiveState); + + return () => { + model.off("change:state_json", publishState); + window.removeEventListener("message", receiveState); + }; +} + +export default { render }; diff --git a/python/spatialrust-jupyter/src/spatialrust_jupyter/widget.py b/python/spatialrust-jupyter/src/spatialrust_jupyter/widget.py new file mode 100644 index 0000000..bd19ccf --- /dev/null +++ b/python/spatialrust-jupyter/src/spatialrust_jupyter/widget.py @@ -0,0 +1,105 @@ +"""AnyWidget bridge to a separately served SpatialRust Web viewer.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping +from urllib.parse import urlsplit + +import anywidget +import spatialrust +import traitlets + +VIEWER_TRANSPORT_VERSION = 1 +_ESM = Path(__file__).with_name("viewer_widget.js").read_text(encoding="utf-8") + + +def _viewer_url(value: str) -> str: + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("viewer_url must be an absolute http(s) URL") + if parsed.username is not None or parsed.password is not None: + raise ValueError("viewer_url must not contain credentials") + return value + + +def _state_json(value: Any) -> str: + if isinstance(value, spatialrust.ViewerState): + serialized = value.to_json() + elif isinstance(value, str): + serialized = value + elif isinstance(value, Mapping): + serialized = json.dumps(value, separators=(",", ":"), sort_keys=True) + else: + raise TypeError("state must be ViewerState, JSON text, or a mapping") + return spatialrust.ViewerState.from_json(serialized).to_json() + + +class ViewerWidget(anywidget.AnyWidget): + """Bidirectional, versioned state transport to the Web viewer iframe.""" + + _esm = _ESM + + transport_version = traitlets.Int(VIEWER_TRANSPORT_VERSION).tag(sync=True) + state_json = traitlets.Unicode().tag(sync=True) + viewer_url = traitlets.Unicode().tag(sync=True) + state_revision = traitlets.Int().tag(sync=True) + + @traitlets.validate("transport_version") + def _validate_transport_version(self, proposal: dict[str, Any]) -> int: + if proposal["value"] != VIEWER_TRANSPORT_VERSION: + raise traitlets.TraitError("unsupported viewer transport version") + return VIEWER_TRANSPORT_VERSION + + @traitlets.validate("state_json") + def _validate_state(self, proposal: dict[str, Any]) -> str: + return _state_json(proposal["value"]) + + @traitlets.validate("viewer_url") + def _validate_url(self, proposal: dict[str, Any]) -> str: + return _viewer_url(proposal["value"]) + + @traitlets.observe("state_json") + def _track_revision(self, change: dict[str, Any]) -> None: + self.state_revision = json.loads(change["new"])["revision"] + + def __init__(self, state: Any, *, viewer_url: str, **kwargs: Any) -> None: + serialized = _state_json(state) + parsed = json.loads(serialized) + super().__init__( + state_json=serialized, + state_revision=parsed["revision"], + viewer_url=viewer_url, + **kwargs, + ) + + def set_state(self, state: Any) -> None: + """Validate and atomically publish canonical state.""" + + serialized = _state_json(state) + parsed = json.loads(serialized) + with self.hold_trait_notifications(): + self.state_json = serialized + self.state_revision = parsed["revision"] + + def apply_input(self, input_message: Mapping[str, Any]) -> None: + """Apply one browser-compatible input through the Rust reducer.""" + + state = spatialrust.ViewerState.from_json(self.state_json) + state.apply_input_json(json.dumps(input_message, separators=(",", ":"), sort_keys=True)) + self.set_state(state) + + def handle_frontend_state(self, state_json: str) -> None: + """Validate a state message received from the embedded Web viewer.""" + + self.set_state(state_json) + + def transport_envelope(self) -> dict[str, Any]: + """Return the exact message mirrored by the widget frontend.""" + + return { + "transport_version": self.transport_version, + "state": json.loads(self.state_json), + "viewer_url": self.viewer_url, + } diff --git a/python/spatialrust-jupyter/tests/test_notebook.py b/python/spatialrust-jupyter/tests/test_notebook.py new file mode 100644 index 0000000..98c563a --- /dev/null +++ b/python/spatialrust-jupyter/tests/test_notebook.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import nbformat +from nbclient import NotebookClient + + +def test_viewer_notebook_executes_end_to_end(): + path = Path(__file__).with_name("viewer_smoke.ipynb") + notebook = nbformat.read(path, as_version=4) + executed = NotebookClient(notebook, timeout=60, kernel_name="python3").execute() + outputs = executed.cells[0].outputs + text = "".join(output.get("text", "") for output in outputs) + assert "SpatialRust Jupyter viewer smoke: PASS" in text diff --git a/python/spatialrust-jupyter/tests/test_widget.py b/python/spatialrust-jupyter/tests/test_widget.py new file mode 100644 index 0000000..bc4777e --- /dev/null +++ b/python/spatialrust-jupyter/tests/test_widget.py @@ -0,0 +1,51 @@ +import json + +import pytest +import spatialrust as sr +from traitlets import TraitError + +from spatialrust_jupyter import VIEWER_TRANSPORT_VERSION, ViewerWidget + + +VIEWER_URL = "https://viewer.example.test/spatialrust/widget_embed.html" + + +def test_widget_roundtrips_the_canonical_state_and_reducer(): + state = sr.ViewerState(800, 600) + widget = ViewerWidget(state, viewer_url=VIEWER_URL) + envelope = widget.transport_envelope() + assert envelope["transport_version"] == VIEWER_TRANSPORT_VERSION + assert envelope["state"]["viewer"]["viewport"] == {"width": 800, "height": 600} + assert widget.state_revision == 0 + + widget.apply_input({"kind": "zoom", "delta": 1.0}) + assert widget.state_revision == 1 + assert json.loads(widget.state_json)["revision"] == 1 + + widget.handle_frontend_state(widget.state_json) + assert widget.transport_envelope()["state"]["revision"] == 1 + + +def test_widget_fails_closed_on_state_and_url_errors(): + state = json.loads(sr.ViewerState().to_json()) + state["unknown"] = True + with pytest.raises(ValueError): + ViewerWidget(state, viewer_url=VIEWER_URL) + widget = ViewerWidget(sr.ViewerState(), viewer_url=VIEWER_URL) + with pytest.raises(ValueError): + widget.handle_frontend_state(json.dumps(state)) + with pytest.raises(TraitError): + widget.transport_version = 2 + with pytest.raises(ValueError): + ViewerWidget(sr.ViewerState(), viewer_url="javascript:alert(1)") + with pytest.raises(ValueError): + ViewerWidget(sr.ViewerState(), viewer_url="https://user:secret@example.test/viewer") + + +def test_frontend_transport_checks_source_origin_and_version(): + widget = ViewerWidget(sr.ViewerState(), viewer_url=VIEWER_URL) + source = widget._esm + assert 'viewerUrl.searchParams.set("parent_origin", window.location.origin)' in source + assert 'event.source !== iframe.contentWindow' in source + assert 'event.origin !== targetOrigin' in source + assert 'event.data?.transport_version' in source diff --git a/python/spatialrust-jupyter/tests/viewer_smoke.ipynb b/python/spatialrust-jupyter/tests/viewer_smoke.ipynb new file mode 100644 index 0000000..a6f5542 --- /dev/null +++ b/python/spatialrust-jupyter/tests/viewer_smoke.ipynb @@ -0,0 +1,37 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "viewer-smoke", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import spatialrust as sr\n", + "from spatialrust_jupyter import ViewerWidget\n", + "state = sr.ViewerState(640, 480)\n", + "widget = ViewerWidget(state, viewer_url='https://viewer.example.test/spatialrust/widget_embed.html')\n", + "widget.apply_input({'kind': 'zoom', 'delta': 1.0})\n", + "envelope = widget.transport_envelope()\n", + "assert envelope['transport_version'] == 1\n", + "assert envelope['state']['revision'] == 1\n", + "assert envelope['state']['viewer']['viewport'] == {'width': 640, 'height': 480}\n", + "print('SpatialRust Jupyter viewer smoke: PASS')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}