packed_spatial_index
packed_spatial_index is a packed static spatial index for 2D and 3D
axis-aligned bounding boxes.
It is built for read-heavy workloads where the full set of boxes is known up
front: build once, then run many range/intersection searches. The default
Index2D and Index3D use packed Hilbert R-tree layouts. With the simd
feature, SimdIndex2D and SimdIndex3D store boxes in structure-of-arrays form
and use SIMD intersection checks. Scalar and SIMD indexes share the same
canonical byte format for owned persistence.
use ;
let mut builder = new;
builder.add;
builder.add;
let index = builder.finish?;
let hits = index.search;
assert_eq!;
# Ok::
Installation
Requires Rust 1.89 or newer.
[]
= "0.4"
When To Use It
Use this crate when:
- your geometry is static or rebuilt in batches;
- search results can be returned as insertion-order indices into your own payload array;
- you want a compact in-memory index with reusable buffers for repeated searches;
- batch search throughput matters.
It is not a dynamic R-tree: there are no insert/delete operations after build.
Limitations
These are the main API contracts and trade-offs to keep in mind.
- The index is static: rebuild it when the dataset changes.
- 2D and 3D axis-aligned bounding boxes are supported.
- Search results are item indices, not stored payloads or geometries.
- Result ordering is not a stable API guarantee.
- Persistence uses canonical
Index2DandIndex3Dbyte layouts.SimdIndex2DandSimdIndex3Dcan save and load those same bytes, but there is no separate persisted SoA byte format. Withf32-storage, thef32indexes use their ownf32box layout (a distinct format flag). - With
f32-storage,SimdIndex2DF32andSimdIndex3DF32store outward-rounded boxes. Plain range search returns every exact hit, but can also return extra near-edge hits. search_exactandvisit_exactuse your originalf64boxes for exact range hits. They are useful with compact indexes when exact queries return few hits. For exact range queries with many hits, prefer thef64indexes.f64nearest-neighbor search is exact over indexed boxes.f32KNN can useneighbors_exactfor exact results, but fastest exact KNN is usually thef64indexes. Dynamic spatial joins are out of scope for now.
Main Types
Geometry
Box2DandBox3Dare public AABB types, with inclusiveoverlaps,contains,contains_point, andfrom_pointhelpers.Point2DandPoint3Dare public point types for KNN and point queries.
Builders
Index2DBuilderandIndex3DBuilderbuild scalar indexes, or SIMD indexes with thesimdfeature.
Indexes
Index2DandIndex3Dare scalar read-only indexes.SimdIndex2DandSimdIndex3Dare available with thesimdfeature and have the same search API and owned persistence API as their scalar counterparts.SimdIndex2DF32andSimdIndex3DF32(withf32-storage, built viafinish_simd_f32) store coordinates asf32rounded outward, halving box memory. Plain range queries may include extra near-boundary hits; exact range and KNN are available through*_exactcallbacks to your source boxes (see Limitations).
Views
Index2DViewandIndex3DVieware zero-copy read-only views over bytes produced by scalar or SIMDto_bytesmethods.SimdIndex2DViewandSimdIndex3DVieware zero-copy SIMD views over the canonical byte format.SimdIndex2DF32ViewandSimdIndex3DF32Vieware zero-copy views over f32 index bytes.
Workspaces
SearchWorkspacereuses result and traversal buffers.NeighborWorkspacereuses result and priority-queue buffers for KNN.
Sorting
SortKey2Dselects the public build ordering curve.Hilbertis the stable default.SortKey3Ddoes the same for 3D.Hilbertis the stable default.
Errors
BoundsErroris returned by checked box constructors.BuildErroris returned when build inputs are invalid.LoadErroris returned when loading invalid or unsupported bytes.
Querying
Searches take a Box2D or Box3D and return indices into the item list you
added to the builder. Result order is intentionally unspecified.
search
Allocates a fresh Vec<usize> and returns all overlaps. This is the simplest
choice for one-off queries.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let hits = index.search;
assert_eq!;
# Ok::
search_into
Reuses your result Vec, clearing it before writing new hits.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let mut results = Vecnew;
index.search_into;
assert_eq!;
# Ok::
search_with
Reuses both the result buffer and the internal traversal stack through a
SearchWorkspace. This is the best fit for hot query loops.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let mut workspace = new;
let hits = index.search_with;
assert_eq!;
# Ok::
any
Checks whether at least one item overlaps the query.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let query = new;
assert!;
# Ok::
first
Returns one matching item index found by traversal.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let query = new;
assert_eq!;
# Ok::
visit
Calls your visitor for each match and lets it stop early with
ControlFlow::Break.
# use ;
use ControlFlow;
#
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let query = new;
let first_even = index.visit;
assert_eq!;
# Ok::
f32 exact search
finish_simd_f32() stores outward-rounded f32 boxes. Plain range search
may include extra near-boundary hits. search_exact checks your original f64
boxes.
# use ;
let boxes = ;
let mut builder = new;
for &b in &boxes
let index = builder.finish_simd_f32?;
let query = new;
let mut rounded_hits = index.search;
rounded_hits.sort_unstable;
assert_eq!;
let exact = index.search_exact;
assert_eq!;
# Ok::
The same pattern applies to exact KNN through neighbors_exact. See
examples/f32_exact_2d.rs.
extent
extent() returns the total box covering every item, or None for an empty
index.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
assert_eq!;
let empty = new.finish?;
assert_eq!;
# Ok::
Nearest Neighbors
Nearest-neighbor queries are exact over boxes for the f64 indexes. Distance is
zero when the point is inside a box, otherwise it is the Euclidean distance to
the nearest point on the box. The f32 indexes also expose rounded-box KNN.
For exact KNN on source f64 boxes, use their neighbors_exact methods.
For fastest exact KNN, prefer the f64 indexes.
neighbors
Returns the nearest item indices with no distance limit.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let point = new;
let nearest = index.neighbors;
assert_eq!;
# Ok::
neighbors_within
Returns nearest item indices within a maximum distance.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let point = new;
let nearby = index.neighbors_within;
assert_eq!;
# Ok::
neighbors_into
Reuses your result Vec for repeated KNN queries.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let mut results = Vecnew;
index.neighbors_into;
assert_eq!;
# Ok::
neighbors_with
Reuses both result and queue buffers through a NeighborWorkspace.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let mut workspace = new;
let hits = index.neighbors_with;
assert_eq!;
# Ok::
visit_neighbors
Visits (index, distance_squared) pairs in nearest-first order and can stop
early with ControlFlow::Break.
# use ;
use ControlFlow;
#
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let close = index.visit_neighbors;
assert_eq!;
# Ok::
Results are returned in nondecreasing distance order. Ties between equal-distance items are not stable across index layouts.
Common Tasks
Find boxes that contain a point
Search with a zero-size query box at that point. Box overlap is inclusive, so items touching the point are included.
# use ;
# let mut builder = new;
# builder.add;
# builder.add;
# let index = builder.finish?;
let point = new;
assert_eq!;
# Ok::
For 3D, use Box3D::from_point(point) in the same way.
Keep payloads outside the index
The index returns item indices. Store your own payloads in the same order as the boxes you add to the builder.
# use ;
let payloads = ;
let boxes = ;
let mut builder = new;
for bounds in boxes
let index = builder.finish?;
let names: = index
.search
.into_iter
.map
.collect;
assert_eq!;
# Ok::
Choose a query method
- Use
searchfor simple one-off queries. - Use
search_withorneighbors_withinside tight loops. - Use
any,first,visit, orvisit_neighborswhen you can stop early. - Use
Index2DVieworIndex3DViewwhen loading persisted bytes without allocating an owned index.
Builder
use ;
let mut builder = new
.node_size
.sort_key;
builder.add;
builder.add;
With parallel enabled:
# use ;
let builder = new
.parallel
.parallel_min_items;
With simd enabled:
# use ;
let mut builder = new;
builder.add;
let simd_index = builder.finish_simd?;
# Ok::
The same finish_simd() method is available on Index3DBuilder and returns
SimdIndex3D. finish_simd_f32() (on both builders) returns the f32-storage
SimdIndex2DF32 / SimdIndex3DF32: half the box memory, with range results
that may include extra near-boundary hits. Exact range/KNN is available when you
pass back your source boxes.
Prefer f64 indexes for exact range queries with many hits and fastest exact
KNN.
3D uses the same builder/search shape:
use ;
let mut builder = new;
builder.add;
builder.add;
let index = builder.finish?;
assert_eq!;
assert_eq!;
# Ok::
Persistence
Index2D and Index3D can be serialized to stable little-endian byte formats
and loaded back either as owned indexes or as zero-copy views:
use ;
let mut builder = new;
builder.add;
let index = builder.finish?;
let bytes = index.to_bytes;
let mut reusable = Vecnew;
index.to_bytes_into;
assert_eq!;
let owned = from_bytes?;
let view = from_bytes?;
assert_eq!;
assert_eq!;
# Ok::
3D persistence uses the same header and sections, with a dimension flag and
six f64 coordinates per stored box. With the simd feature,
SimdIndex2D and SimdIndex3D read and write the same canonical bytes as the
scalar indexes. Loading a SIMD index is an owned load that scatters canonical
box records into SoA columns. SimdIndex2DView and SimdIndex3DView borrow the
same canonical bytes for zero-copy SIMD-over-AoS queries.
The f32 indexes persist to their own f32 box layout (distinct format flags),
with matching from_bytes loaders and zero-copy views.
The binary layout is documented in FORMAT.md.
Examples
Runnable examples cover the public paths:
WASM Demo
Live demo: https://filyus.github.io/packed_spatial_index/
The repository includes a Vite + TypeScript demo that builds SimdIndex2D and
SimdIndex3D WASM wrappers for interactive 2D and 3D box and point searches:
Production build:
The demo uses wasm-pack with RUSTFLAGS=-Ctarget-feature=+simd128 and
packed_spatial_index with default-features = false, features = ["simd"].
It supports range search and nearest-neighbor modes, renders with WebGL2, and
is excluded from the published crates.io package.
Benchmarking Layout
Performance-related code lives under benches:
benches/*.rsare Criterion benchmark suites run withcargo bench.benches/toolsis a local developer package for quick comparisons of encoder variants, sort strategies, node sizes, parallel builds, and SoA layouts.
The local tools use the hidden bench-internals feature and are excluded from
the published crate.
Features
Runtime acceleration features:
parallel: adaptive rayon-based index builds throughIndex2DBuilder::parallelandIndex3DBuilder::parallel. Enabled by default.simd: SoA index and SIMD search paths throughSimdIndex2DandSimdIndex3D, plus owned and zero-copy SIMD persistence through the canonical byte format. Enabled by default.f32-storage: compact f32-storage SIMD indexes. It enablessimdand is not enabled by default.bench-internals: hidden support API for this crate's own benchmarks and local performance tools. It is not enabled by default and is not part of the stable user-facing API.
Minimal build:
Feature-specific builds:
Safety
The public API is safe Rust; users do not need unsafe to build, load, search,
or query neighbors.
Internally, the crate keeps unsafe limited to narrow performance-sensitive
paths:
- unaligned little-endian reads for validated
Index2DViewandIndex3DViewbyte buffers; - bulk byte copies for
repr(C)boxes and index sections when serializing on compatible little-endian targets; - x86/x86_64 prefetch intrinsics used only by hidden benchmark/performance-tool paths;
- AVX-512 loads in the
simdfeature, guarded by runtime CPU feature detection.
Loaded buffers are validated before they can be searched, so malformed input is
reported as LoadError instead of relying on caller-side invariants.
Prior Art
This crate builds on ideas from existing packed spatial index work.
flatbushby Vladimir Agafonkin is a static packed Hilbert R-tree for 2D rectangles in JavaScript.static_aabb2d_indexby Jedidiah McCready is the Rust Flatbush port.- FlatGeobuf by Pirmin Kalberer and Björn Harrtell is a geospatial format inspired by Flatbush.
Performance Notes
2D Competitors
Lower is better. The 2D competitor workload uses 100,000 random AABBs and 1,000 random query boxes; build and search competitors are measured in the same benchmark suite on the same generated inputs. Persistence rows use the canonical byte format for 100,000 boxes.
| Benchmark | FlatGeobuf | static_aabb2d_index |
Index2D |
SimdIndex2D |
|---|---|---|---|---|
| Full build | 70.18 ms | 8.95 ms | 3.18 ms serial / 2.20 ms parallel | - |
| Search batch | 555.83 us | 341.56 us | 416.15 us | 128.64 us |
| Serialize built tree (fresh buffer) | - | - | 407.64 us | 689.42 us |
| Serialize built tree (reused buffer) | 131.93 us | - | 68.78 us | 140.21 us |
| Load owned tree | 740.23 us | - | 607.62 us | 935.51 us |
| Load zero-copy view | - | - | 37.59 us | n/a |
Scalar Index2D search versus static_aabb2d_index is dataset-sensitive.
Two search runs with the same item/query counts but different generated inputs
showed opposite scalar ordering:
| Search batch | static_aabb2d_index |
Index2D |
SimdIndex2D |
|---|---|---|---|
flatgeobuf2d_bench, seed 0xF6B |
341.56 us | 416.15 us | 128.64 us |
index2d_bench, seed 0xB0B |
643.85 us | 311.72 us | 126.21 us |
2D vs 3D
Lower latency is better. The 3D speed column is 2D latency / 3D latency, so
values above 1.00x mean 3D is faster. The build workload uses 100,000 boxes
with node_size = 16; search and KNN use 1,000 query boxes or points.
| Stage | Dataset / mode | Index2D |
Index3D |
3D speed |
|---|---|---|---|---|
| Hilbert encode | production 2D LUT vs 3D nibble LUT | 739.90 us | 983.42 us | 0.75x |
| Build | planar XY | 2.7433 ms | 4.5660 ms | 0.60x |
| Build | uniform XYZ | 3.4270 ms | 5.1252 ms | 0.67x |
| Search batch | planar XY | 466.41 us | 636.37 us | 0.73x |
| Search batch | uniform XYZ | 495.82 us | 391.32 us | 1.27x |
| KNN batch | Dataset / mode | Index2D |
Index3D |
3D speed |
|---|---|---|---|---|
| Top-1 | planar XY | 973.85 us | 1.2445 ms | 0.78x |
| Top-10 | planar XY | 1.9378 ms | 2.5625 ms | 0.76x |
| Top-1 | uniform XYZ | 1.0028 ms | 1.8530 ms | 0.54x |
| Top-10 | uniform XYZ | 2.0221 ms | 4.1143 ms | 0.49x |
| Persistence | Index2D |
Index3D |
3D speed |
|---|---|---|---|
| Serialize built tree (fresh buffer) | 407.64 us | 562.55 us | 0.72x |
| Serialize built tree (reused buffer) | 68.78 us | 96.51 us | 0.71x |
| Load owned tree | 607.62 us | 819.04 us | 0.74x |
| Load zero-copy view | 37.59 us | 37.66 us | 1.00x |
| SIMD persistence | SimdIndex2D |
SimdIndex3D |
3D speed |
|---|---|---|---|
| Serialize built tree (fresh buffer) | 689.42 us | 973.17 us | 0.71x |
| Serialize built tree (reused buffer) | 140.21 us | 240.51 us | 0.58x |
| Load owned tree | 935.51 us | 1.3083 ms | 0.72x |
3D SIMD
The speed column is scalar/serial latency divided by SIMD/parallel latency, so
values above 1.00x mean the SIMD or parallel path is faster.
| Stage | Dataset / mode | Baseline | SIMD / parallel | Speed |
|---|---|---|---|---|
| Search batch | uniform XYZ | Index3D 389.13 us |
SimdIndex3D 129.08 us |
3.01x |
| Search batch | flat Z | Index3D 1.8443 ms |
SimdIndex3D 1.1514 ms |
1.60x |
Build finish_simd |
uniform XYZ, 200k boxes | serial 10.632 ms | parallel 6.5412 ms | 1.63x |
Large-Window Range Search
When a query fully contains a tree node, the covered-range fast path collects the
whole subtree by copying its contiguous leaf-index range instead of running
per-item overlap tests. This keeps the SIMD indexes from regressing against the
scalar indexes as the window grows: without it, full-extent SIMD searches were
several times slower than Index2D/Index3D; with it they reach parity on
full-extent windows and stay ahead on everything smaller. Workload: 100,000
boxes over a 10,000-wide space, 1,000 query boxes per window class. Lower is
better.
| Window (2D) | Index2D |
SimdIndex2D |
|---|---|---|
| small (10–200) | 337.61 us | 144.21 us |
| large (2,000–5,000) | 5.94 ms | 5.69 ms |
| wide sliver | 2.33 ms | 1.58 ms |
| full extent | 10.46 ms | 10.86 ms |
| Window (3D) | Index3D |
SimdIndex3D |
|---|---|---|
| small (50–300) | 382.86 us | 146.43 us |
| large (2,000–5,000) | 9.49 ms | 7.29 ms |
| thin slab | 3.33 ms | 1.67 ms |
| full extent | 10.54 ms | 10.91 ms |
f32 Storage vs f64
The coord_precision suite compares compact f32 storage with f64 storage.
Lower is better. Range rows run search(Box2D) for 1,000 random query boxes.
Small query boxes cover 0.1% of the coordinate extent per axis; large query
boxes cover 5%. KNN rows use 200 query points with top-8 results.
Quick selector:
SimdIndex2D: 32-byte f64 boxes. Use for exact range queries with many hits and fastest exact KNN.SimdIndex2DF32::search: 16-byte rounded f32 boxes. Use for compact first-pass filtering, or when near-boundary false positives are OK.SimdIndex2DF32::*_exact: 16-byte f32 index plus source f64 boxes. Use when exact range queries return few hits and compact storage matters. Exact KNN is available, but f64 is faster in these runs.
| Range query | Items | f64 exact |
f32 rounded |
f32 exact |
|---|---|---|---|---|
| small query boxes | 10k | 84 us | 72 us | 73 us |
| small query boxes | 100k | 115 us | 87 us | 96 us |
| small query boxes | 1M | 156 us | 125 us | 134 us |
| large query boxes | 10k | 149 us | 117 us | 292 us |
| large query boxes | 100k | 1.06 ms | 704 us | 1.79 ms |
| large query boxes | 1M | 9.61 ms | 6.09 ms | 16.59 ms |
| KNN workload | f64 exact |
f32 rounded |
f32 exact |
|---|---|---|---|
| 10k items | 243 us | 282 us | 302 us |
| 100k items | 357 us | 376 us | 410 us |
Summary
Index2Dis the general-purpose path;SimdIndex2DandSimdIndex3Dare best for heavier query batches where SIMD work amortizes well;- scalar
Index2Dsearch versusstatic_aabb2d_indexdepends on the generated data and query distribution, whileIndex2Dbuild is faster in these runs; Index3Dbuild and KNN are still slower thanIndex2D, but uniform 3D search can be faster when Z meaningfully prunes the tree;- f32 storage halves box memory; exact callbacks trade source-box lookup for exact results;
- SIMD persistence uses the same canonical bytes as scalar persistence; it pays an SoA gather/scatter cost but avoids a second file format;
anyis often much faster than collecting full result sets when all you need is existence;- AVX-512 is not always the fastest path in parallel workloads because CPU frequency behavior matters.
flatgeobuf2d_benchcompares against FlatGeobuf's packed Hilbert R-tree;index2d_benchcompares build/search paths againststatic_aabb2d_index;index3d_benchcovers 3D build/search/KNN, SIMD search/build, dimension comparisons, node sizes, and hidden Morton baseline;persistence_knn2d_benchcovers 2D scalar/SIMD persistence, loaded views, and KNN;persistence_knn3d_benchcovers 3D scalar/SIMD persistence, loaded views, and KNN.
Reproducing
Run the focused benchmark suites with:
Status
Major API changes are not planned, but remain possible before a 1.0 release.
AI Usage Note
AI assistance is part of my development process for this project. I guide the architecture, review generated output carefully and take responsibility for the crate as published.
License
Licensed under the Apache License, Version 2.0.