Highly optimised spatial indexing

Fast spatial search
for .NET 10.

Tedd.RTree indexes axis-aligned 2D rectangles and 3D boxes for intersection queries. Choose int, long, float, or double coordinates, with individual updates, packed bulk loading, and two concurrent update models.

Fastest in the published five-package comparison: lowest measured mean for both bulk construction and aggregate intersection queries in all four 1,000- and 10,000-entry uniform and clustered fixtures. This ranking applies to the tested 2D double workload.

In the measured 10,000-entry uniform fixture, bulk build took 2.15 ms versus 5.81 ms for RBush and 4.96 ms for NTS STRtree. A batch of 64 queries took 253.94 µs versus 1148.05 µs and 1777.96 µs. See all five packages and benchmark conditions.

NuGet package version Build and test status Pages deployment status GitHub source
  • .NET 10
  • 2D and 3D bounds
  • LGPL-2.1
SPATIAL VIEWOne query, four matches
R-tree rectangle intersection diagram A root bounding rectangle contains two leaf groups and six numbered item rectangles. The amber query intersects items 1, 2, 4, and 5. It touches item 1 at its edge. ROOT BOUNDS LEAF A LEAF B 01 02 03 04 05 06 QUERY
INTERSECTING ENTRIES01 · 02 · 04 · 05
Node bounds prune the search. Entries are returned when their rectangles intersect the query, including edge contact.

Incremental insertionLeast enlargement selection and quadratic node splits

Bulk loadingSort-Tile-Recursive packing on an empty tree

Inclusive searchBoundary contact is included in 2D and 3D

Result ownershipReturn a list or append to a caller-owned list

Under the hood

Algorithms built for fast spatial search.

The index prunes nonintersecting branches before testing entries. Packed construction and incremental updates use different algorithms for their respective workloads.

Sort-Tile-Recursive packing

BulkLoad sorts entry centers by axis, tiles them into capacity-bounded nodes, then packs parent levels. Use it to build an empty tree from a batch.

Least enlargement insertion

Individual inserts descend through the child whose bounds need the smallest enlargement, breaking ties by current area. Overflow uses quadratic seed selection and distribution.

Bounded intersection traversal

Search skips nodes whose bounds do not intersect the query. Reuse a caller-owned result list to avoid a fresh result collection; SearchBatch processes several queries with caller-owned outputs.

The benchmark ranking measures the original 2D double tree. Coordinate-generic 2D and 3D variants, concurrent updates, and individual query widths have separate performance characteristics. Review the measured fixtures and limitations.

Choose an index

Three APIs. Three update contracts.

The difference is when a search can see a change. Each update model supports coordinate-generic 2D and 3D bounds.

How the three index types handle changes and searches
TypeHow to change itWhat a search seesUse when
RTree<T>Plain treeInsert, remove, or move entries directly. Bulk load an empty tree.Current contents. Do not change the tree while any search is running.One caller controls changes, or your application provides its own synchronisation.
ConcurrentRTree<T>Immediate updatesAdd, move, or remove one value at a time. Readers and writers use a reader/writer lock.Each completed change is visible to later searches. A search and a change may wait for each other.Separate threads search and move individual entries, and each move must become visible promptly.
SnapshotRTree<T>Published batchesBuild a complete replacement with ReplaceAll; publish it in one step.One complete version. A search already running may finish on the previous version; readers do not wait for the build.Most entries move together and searches can use the latest published batch.
01 / Plain

RTree<T>

Call Insert, Remove(bounds, item), or Update(oldBounds, item, newBounds). Duplicate values are allowed. Simultaneous searches are safe while the tree remains unchanged, with a separate result list per search.

02 / Concurrent

ConcurrentRTree<T>

Call Add, Move(item, newBounds), or Remove(item); an empty index also accepts an initial BulkLoad. Values are unique, stable keys. A write holds the lock until it completes; later searches see the new position. Dispose the index after callers stop using it.

03 / Snapshot

SnapshotRTree<T>

Call ReplaceAll(entries) with every current position. The next tree is built separately, then published. Searches remain lock-free and never traverse a partly built tree. Old versions may remain while searches finish; the GC reclaims them later.

Coordinate variants. Use RTree2D<TCoordinate, T> with Rectangle2D<TCoordinate>, or RTree3D<TCoordinate, T> with Box<TCoordinate>. The snapshot and concurrent variants follow the same 2D/3D names. A voxel at (x, y, z) is a point query with new Box<int>(x, y, z, x, y, z). The original unsuffixed types remain available for 2D double coordinates. Search(bounds) returns a list; Search(bounds, results) appends to a caller-owned list. Each simultaneous search needs its own list. Result order is unspecified.

Batch searches. All tree variants accept SearchBatch(queries, results, counts). Supply one non-null result list per query and at least one count slot per query. Matches append to the lists; counts report the number appended per query, and the return value is the total as a long. A concurrent batch holds one read lock until all queries complete. A snapshot batch uses one published version throughout. Batches run sequentially; simultaneous batches need separate output storage.

C# examples

Store, search, reuse.

These examples use the original 2D double API. The first returns a new result list; the second appends to a list owned by the caller.

PlainTree.cs
using Tedd.RTree;

var tree = new RTree<string>(maxEntries: 16);
var oldBounds = new Rectangle(0, 0, 10, 10);
tree.Insert(oldBounds, "A");
tree.Insert(new Rectangle(20, 20, 30, 30), "B");
tree.Update(oldBounds, "A", new Rectangle(5, 5, 15, 15));

List<string> matches = tree.Search(
    new Rectangle(8, 8, 12, 12));
// matches contains "A"
BulkLoad.cs
using Tedd.RTree;

var entries = new[]
{
    new SpatialEntry<int>(new Rectangle(0, 0, 4, 4), 1),
    new SpatialEntry<int>(new Rectangle(5, 5, 9, 9), 2)
};

var tree = new RTree<int>();
tree.BulkLoad(entries);

var results = new List<int>();
int added = tree.Search(new Rectangle(3, 3, 6, 6), results);
// added == 2
Install from NuGet

Run dotnet add package Tedd.RTree in a .NET 10 project.

Package details

Moving entries

Choose when moves become visible.

Use SnapshotRTree<T> when searches may use the latest published positions while the next batch is built. Use ConcurrentRTree<T> when each completed move must be visible to the next search. At 10,000 entries, rebuilding took 1.75 ms; moving all entries individually took 14.77 ms in the local update benchmark.

PublishedBatch.cs
using Tedd.RTree;

var index = new SnapshotRTree<int>();
index.ReplaceAll(new[]
{
    new SpatialEntry<int>(new Rectangle(0, 0, 10, 10), 42)
});

List<int> matches = index.Search(new Rectangle(5, 5, 6, 6));
ImmediateMove.cs
using Tedd.RTree;

using var index = new ConcurrentRTree<int>();
index.Add(new Rectangle(0, 0, 10, 10), 42);
index.Move(42, new Rectangle(20, 20, 30, 30));

List<int> matches = index.Search(new Rectangle(20, 20, 21, 21));

Five validated packages · .NET 10

R-tree package comparison.

Build and intersection-query throughput on identical rectangles. Teal identifies Tedd.RTree; grey identifies competing packages. Higher bars mean more work per second. Each query creates its result collection.

Tables show every fixture. Bold times mark the lowest measured mean.
10,000 uniform rectanglesBuild throughputM entries/s · higher is better
Tedd.RTree
4.644
NTS STRtree
2.017
RBush
1.721
Enyim RTree
1.538
RTree
0.838
10,000 uniform rectanglesQuery throughputM windows/s · higher is better
Tedd.RTree
0.252
RBush
0.056
NTS STRtree
0.036
RTree
0.032
Enyim RTree
0.006

All measured fixtures

Mean ± sample standard deviation · managed allocation per operation

Build and query time across five validated R-tree packages
EntriesDistributionOperation Tedd.RTree RBush NTS STRtree RTree Enyim RTree
1,000UniformBuild 0.123 ± 0.004 ms133.72 KiB allocated 0.230 ± 0.001 ms156.99 KiB allocated 0.234 ± 0.049 ms129.86 KiB allocated 0.883 ± 0.025 ms314.43 KiB allocated 0.401 ± 0.011 ms68.11 KiB allocated
1,000ClusteredBuild 0.114 ± 0.003 ms133.72 KiB allocated 0.325 ± 0.005 ms156.99 KiB allocated 0.364 ± 0.005 ms129.86 KiB allocated 0.618 ± 0.019 ms313.09 KiB allocated 0.377 ± 0.004 ms68.11 KiB allocated
10,000UniformBuild 2.153 ± 0.081 ms1,308.81 KiB allocated 5.811 ± 0.126 ms2,165.52 KiB allocated 4.959 ± 0.291 ms1,399.61 KiB allocated 11.937 ± 0.627 ms3,023.33 KiB allocated 6.501 ± 0.376 ms862.78 KiB allocated
10,000ClusteredBuild 2.226 ± 0.046 ms1,308.81 KiB allocated 5.803 ± 0.309 ms2,165.52 KiB allocated 4.390 ± 0.011 ms1,399.61 KiB allocated 11.539 ± 0.247 ms3,018.80 KiB allocated 5.657 ± 0.256 ms862.78 KiB allocated
1,000Uniform64 queries 29.290 ± 3.099 µs50.67 KiB allocated 81.347 ± 1.327 µs108.04 KiB allocated 68.640 ± 4.009 µs50.67 KiB allocated 120.743 ± 2.025 µs56.67 KiB allocated 793.258 ± 61.230 µs116.62 KiB allocated
1,000Clustered64 queries 25.580 ± 1.622 µs55.02 KiB allocated 75.435 ± 1.331 µs117.44 KiB allocated 84.799 ± 1.431 µs55.02 KiB allocated 114.663 ± 3.932 µs61.02 KiB allocated 1076.541 ± 18.530 µs129.25 KiB allocated
10,000Uniform64 queries 253.936 ± 3.108 µs702.32 KiB allocated 1148.046 ± 34.031 µs1,509.62 KiB allocated 1777.963 ± 19.346 µs702.32 KiB allocated 2001.055 ± 104.507 µs708.32 KiB allocated 11443.148 ± 210.574 µs1,435.88 KiB allocated
10,000Clustered64 queries 197.635 ± 5.934 µs634.80 KiB allocated 959.356 ± 21.793 µs1,377.21 KiB allocated 1352.807 ± 84.770 µs634.80 KiB allocated 1731.322 ± 49.235 µs640.80 KiB allocated 11627.599 ± 604.620 µs1,297.76 KiB allocated

30 September 2026 · AMD Ryzen 9 5950X · Windows 11 · .NET 10.0.12 · BenchmarkDotNet 0.15.8 · one launch, three warm-ups and three measured iterations. Fixed seed 73211; integer-valued bounds; 64 windows with widths 0, 20, 200 and 1,000 units. Input geometry and package adapters are prepared outside timing. Every query's result IDs are checked against brute force before measurement. These results describe this fixture; small differences require further measurement.

Tedd.RTree, RBush 4.0.0, Enyim.Collections.RTree 1.0.5 and NetTopologySuite 2.6.0 use bulk construction. RTree 1.1.0 uses incremental insertion, with the 2D data embedded at z = 0 in its 3D float index. Node capacity is 16. STRtree does not accept inserts after build. Enyim targets .NET Framework and its published binary has optimizations disabled; it passed the .NET 10 checks and is measured as distributed. The concurrent wrappers have separate measurements.

SharpTrees 1.0.6: excluded from timing

Its first uniform 1,000-entry validation returned 39 matches where brute force found 40, omitting ID 942. The package is included in the reproducible validation probe, but incorrect result sets are not ranked as faster queries.

Coordinate type comparison

Each index holds 10,000 unit bounds at the same integer-valued positions. The generic variants use int, long, float, or double coordinates. Times are means ± sample standard deviation; lower is better within the same dimension and query kind.

Intersection queries

64 windows per operation · reused result list · zero measured allocation

Query time by coordinate type for 10,000 unit bounds
DimensionCoordinates64 point queries64 region queries
2Dint7.85 ± 0.25 µs40.26 ± 2.61 µs
2Dlong7.64 ± 0.57 µs49.07 ± 14.98 µs
2Dfloat7.69 ± 0.77 µs38.27 ± 1.32 µs
2DGeneric double6.65 ± 0.44 µs52.48 ± 3.19 µs
3Dint7.82 ± 0.36 µs20.90 ± 1.67 µs
3Dlong9.21 ± 1.42 µs24.32 ± 3.29 µs
3Dfloat11.43 ± 0.32 µs28.98 ± 5.12 µs
3Ddouble12.57 ± 0.31 µs22.99 ± 1.12 µs

Bulk construction

10,000 prepared entries · one packed tree per operation

Build time and managed allocation by coordinate type
DimensionCoordinatesBuild timeAllocated
2Dint2.37 ± 0.07 ms1,120 KB
2Dlong2.38 ± 0.04 ms1,475 KB
2Dfloat1.97 ± 0.10 ms953 KB
2DGeneric double2.06 ± 0.04 ms1,309 KB
3Dint3.47 ± 0.09 ms1,469 KB
3Dlong3.68 ± 0.05 ms2,002 KB
3Dfloat2.70 ± 0.07 ms1,219 KB
3Ddouble2.82 ± 0.07 ms1,752 KB

AMD Ryzen 9 5950X · Windows 11 · .NET 10.0.12 · BenchmarkDotNet 0.15.8. Query results use ten measured iterations; builds use five. Geometry creation and tree construction are outside query timing. Integer coordinates remain exact, including long positions above 253. The results describe this fixture; close differences and high-variance cases require measurement in the target application.

API boundaries

Use the tree for the work it supports.

Geometry. Coordinates must be finite and ordered. Rectangles are 2D; boxes are 3D. Both are axis-aligned, and search includes boundary contact. Represent a single voxel position with equal minimum and maximum coordinates.

Updates. BulkLoad requires an empty tree. Individual inserts, removals, and moves can follow. When most entries move together, rebuilding a packed tree is faster than moving each entry in place on the measured 10,000-entry fixture.

Concurrency. The API comparison describes the three visibility contracts. SnapshotRTree publishes complete batches for lock-free searches. ConcurrentRTree makes individual moves visible under a reader/writer lock. Measured costs.

Licence and package. The repository and NuGet package use LGPL-2.1. The package targets .NET 10.