fastquadtree 0.6.1__cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,381 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Tuple, overload
4
+
5
+ from ._bimap import BiMap # type: ignore[attr-defined]
6
+ from ._item import Item
7
+
8
+ # Compiled Rust module is provided by maturin (tool.maturin.module-name)
9
+ from ._native import QuadTree as _RustQuadTree
10
+
11
+ Bounds = Tuple[float, float, float, float]
12
+ """Axis-aligned rectangle as (min_x, min_y, max_x, max_y)."""
13
+
14
+ Point = Tuple[float, float]
15
+ """2D point as (x, y)."""
16
+
17
+ _IdCoord = Tuple[int, float, float]
18
+ """Result tuple as (id, x, y)."""
19
+
20
+
21
+ class QuadTree:
22
+ """
23
+ High-level Python wrapper over the Rust quadtree engine.
24
+
25
+ The quadtree stores points with integer IDs. You may attach an arbitrary
26
+ Python object per ID when object tracking is enabled.
27
+
28
+ Performance characteristics:
29
+ Inserts: average O(log n)
30
+ Rect queries: average O(log n + k) where k is matches returned
31
+ Nearest neighbor: average O(log n)
32
+
33
+ Thread-safety:
34
+ Instances are not thread-safe. Use external synchronization if you
35
+ mutate the same tree from multiple threads.
36
+
37
+ Args:
38
+ bounds: World bounds as (min_x, min_y, max_x, max_y).
39
+ capacity: Max number of points per node before splitting.
40
+ max_depth: Optional max tree depth. If omitted, engine decides.
41
+ track_objects: Enable id <-> object mapping inside Python.
42
+ start_id: Starting auto-assigned id when you omit id on insert.
43
+
44
+ Raises:
45
+ ValueError: If parameters are invalid or inserts are out of bounds.
46
+ """
47
+
48
+ __slots__ = ("_bounds", "_count", "_items", "_native", "_next_id")
49
+
50
+ def __init__(
51
+ self,
52
+ bounds: Bounds,
53
+ capacity: int,
54
+ *,
55
+ max_depth: int | None = None,
56
+ track_objects: bool = False,
57
+ start_id: int = 1,
58
+ ):
59
+ if max_depth is None:
60
+ self._native = _RustQuadTree(bounds, capacity)
61
+ else:
62
+ self._native = _RustQuadTree(bounds, capacity, max_depth=max_depth)
63
+ self._items: BiMap | None = BiMap() if track_objects else None
64
+ self._next_id: int = int(start_id)
65
+ self._count: int = 0
66
+ self._bounds = bounds
67
+
68
+ # ---------- inserts ----------
69
+
70
+ def insert(self, xy: Point, *, id_: int | None = None, obj: Any = None) -> int:
71
+ """
72
+ Insert a single point.
73
+
74
+ Args:
75
+ xy: Point (x, y).
76
+ id: Optional integer id. If None, an auto id is assigned.
77
+ obj: Optional Python object to associate with id. Stored only if
78
+ object tracking is enabled.
79
+
80
+ Returns:
81
+ The id used for this insert.
82
+
83
+ Raises:
84
+ ValueError: If the point is outside tree bounds.
85
+ """
86
+ if id_ is None:
87
+ id_ = self._next_id
88
+ self._next_id += 1
89
+ # ensure future auto-ids do not collide
90
+ elif id_ >= self._next_id:
91
+ self._next_id = id_ + 1
92
+
93
+ if not self._native.insert(id_, xy):
94
+ x, y = xy
95
+ bx0, by0, bx1, by1 = self._bounds
96
+ raise ValueError(
97
+ f"Point ({x}, {y}) is outside bounds ({bx0}, {by0}, {bx1}, {by1})"
98
+ )
99
+
100
+ if self._items is not None:
101
+ self._items.add(Item(id_, xy[0], xy[1], obj))
102
+
103
+ self._count += 1
104
+ return id_
105
+
106
+ def insert_many_points(self, points: list[Point]) -> int:
107
+ """
108
+ Bulk insert points with auto-assigned ids.
109
+
110
+ Args:
111
+ points: List of (x, y) points.
112
+
113
+ Returns:
114
+ The number of points inserted
115
+ """
116
+ start_id = self._next_id
117
+ last_id = self._native.insert_many_points(start_id, points)
118
+
119
+ num_inserted = last_id - start_id + 1
120
+
121
+ if num_inserted < len(points):
122
+ raise ValueError("One or more points are outside tree bounds")
123
+
124
+ self._next_id = last_id + 1
125
+
126
+ # Update the item tracker if needed
127
+ if self._items is not None:
128
+ for i, id_ in enumerate(range(start_id, last_id + 1)):
129
+ x, y = points[i]
130
+ self._items.add(Item(id_, x, y, None))
131
+
132
+ return num_inserted
133
+
134
+ def attach(self, id_: int, obj: Any) -> None:
135
+ """
136
+ Attach or replace the Python object for an existing id.
137
+ Tracking must be enabled.
138
+
139
+ Args:
140
+ id_: Target id.
141
+ obj: Object to associate with id.
142
+ """
143
+ if self._items is None:
144
+ raise ValueError("Cannot attach objects when track_objects=False")
145
+
146
+ item = self._items.by_id(id_)
147
+ if item is None:
148
+ raise KeyError(f"Id {id_} not found in quadtree")
149
+ self._items.add(Item(id_, item.x, item.y, obj))
150
+
151
+ def delete(self, id_: int, xy: Point) -> bool:
152
+ """
153
+ Delete an item by id and exact coordinates.
154
+
155
+ Args:
156
+ id_: Integer id to remove.
157
+ xy: Coordinates (x, y) of the item.
158
+
159
+ Returns:
160
+ True if the item was found and deleted, else False.
161
+ """
162
+ deleted = self._native.delete(id_, xy)
163
+ if deleted:
164
+ self._count -= 1
165
+ if self._items is not None:
166
+ self._items.pop_id(id_) # ignore result
167
+ return deleted
168
+
169
+ def delete_by_object(self, obj: Any) -> bool:
170
+ """
171
+ Delete an item by Python object.
172
+
173
+ Requires object tracking to be enabled. Performs an O(1) reverse
174
+ lookup to get the id, then deletes that entry at the given location.
175
+
176
+ Args:
177
+ obj: The tracked Python object to remove.
178
+
179
+ Returns:
180
+ True if the item was found and deleted, else False.
181
+
182
+ Raises:
183
+ ValueError: If object tracking is disabled.
184
+ """
185
+ if self._items is None:
186
+ raise ValueError(
187
+ "Cannot delete by object when track_objects=False. Use delete(id, xy) instead."
188
+ )
189
+
190
+ item = self._items.by_obj(obj)
191
+ if item is None:
192
+ return False
193
+
194
+ return self.delete(item.id_, (item.x, item.y))
195
+
196
+ # ---------- queries ----------
197
+
198
+ @overload
199
+ def query(
200
+ self, rect: Bounds, *, as_items: Literal[False] = ...
201
+ ) -> list[_IdCoord]: ...
202
+
203
+ @overload
204
+ def query(self, rect: Bounds, *, as_items: Literal[True]) -> list[Item]: ...
205
+
206
+ def query(
207
+ self, rect: Bounds, *, as_items: bool = False
208
+ ) -> list[_IdCoord] | list[Item]:
209
+ """
210
+ Return all points inside an axis-aligned rectangle.
211
+
212
+ Args:
213
+ rect: Query rectangle as (min_x, min_y, max_x, max_y).
214
+ as_items: If True, return Item wrappers. If False, return raw tuples.
215
+
216
+ Returns:
217
+ If as_items is False: list of (id, x, y) tuples.
218
+ If as_items is True: list of Item objects.
219
+ """
220
+ raw = self._native.query(rect)
221
+ if not as_items:
222
+ return raw
223
+
224
+ if self._items is None:
225
+ raise ValueError("Cannot return results as items with track_objects=False")
226
+ out: list[Item] = []
227
+ for id_, _, _ in raw:
228
+ item = self._items.by_id(id_)
229
+ if item is None:
230
+ raise RuntimeError(
231
+ f"Internal error: id {id_} found in native tree but missing from object tracker. "
232
+ f"Ensure all inserts/deletes are done via this wrapper."
233
+ )
234
+ out.append(item)
235
+ return out
236
+
237
+ @overload
238
+ def nearest_neighbor(
239
+ self, xy: Point, *, as_item: Literal[False] = ...
240
+ ) -> _IdCoord | None: ...
241
+
242
+ @overload
243
+ def nearest_neighbor(self, xy: Point, *, as_item: Literal[True]) -> Item | None: ...
244
+
245
+ def nearest_neighbor(self, xy: Point, *, as_item: bool = False):
246
+ """
247
+ Return the single nearest neighbor to the query point.
248
+
249
+ Args:
250
+ xy: Query point (x, y).
251
+ as_item: If True, return Item. If False, return (id, x, y).
252
+
253
+ Returns:
254
+ The nearest neighbor or None if the tree is empty.
255
+ """
256
+ t = self._native.nearest_neighbor(xy)
257
+ if t is None or not as_item:
258
+ return t
259
+
260
+ if self._items is None:
261
+ raise ValueError("Cannot return result as item with track_objects=False")
262
+ id_, _x, _y = t
263
+ item = self._items.by_id(id_)
264
+ if item is None:
265
+ raise RuntimeError(
266
+ f"Internal error: id {id_} found in native tree but missing from object tracker. "
267
+ f"Ensure all inserts/deletes are done via this wrapper."
268
+ )
269
+ return item
270
+
271
+ @overload
272
+ def nearest_neighbors(
273
+ self, xy: Point, k: int, *, as_items: Literal[False] = ...
274
+ ) -> list[_IdCoord]: ...
275
+
276
+ @overload
277
+ def nearest_neighbors(
278
+ self, xy: Point, k: int, *, as_items: Literal[True]
279
+ ) -> list[Item]: ...
280
+
281
+ def nearest_neighbors(self, xy: Point, k: int, *, as_items: bool = False):
282
+ """
283
+ Return the k nearest neighbors to the query point.
284
+
285
+ Args:
286
+ xy: Query point (x, y).
287
+ k: Number of neighbors to return.
288
+ as_items: If True, return Item wrappers. If False, return raw tuples.
289
+
290
+ Returns:
291
+ List of results in ascending distance order.
292
+ """
293
+ raw = self._native.nearest_neighbors(xy, k)
294
+ if not as_items:
295
+ return raw
296
+ if self._items is None:
297
+ raise ValueError("Cannot return results as items with track_objects=False")
298
+
299
+ out: list[Item] = []
300
+ for id_, _, _ in raw:
301
+ item = self._items.by_id(id_)
302
+ if item is None:
303
+ raise RuntimeError(
304
+ f"Internal error: id {id_} found in native tree but missing from object tracker. "
305
+ f"Ensure all inserts/deletes are done via this wrapper."
306
+ )
307
+ out.append(item)
308
+ return out
309
+
310
+ # ---------- misc ----------
311
+
312
+ def get(self, id_: int) -> Any | None:
313
+ """
314
+ Return the object associated with id.
315
+
316
+ Returns:
317
+ The tracked object if present and tracking is enabled, else None.
318
+ """
319
+ if self._items is None:
320
+ raise ValueError("Cannot get objects when track_objects=False")
321
+ item = self._items.by_id(id_)
322
+ if item is None:
323
+ return None
324
+ return item.obj
325
+
326
+ def get_all_rectangles(self) -> list[Bounds]:
327
+ """
328
+ Return all node rectangles in the current quadtree.
329
+
330
+ Returns:
331
+ List of (min_x, min_y, max_x, max_y) for each node in the tree.
332
+ """
333
+ return self._native.get_all_rectangles()
334
+
335
+ def get_all_objects(self) -> list[Any]:
336
+ """
337
+ Return all tracked objects.
338
+
339
+ Returns:
340
+ List of objects if tracking is enabled, else an empty list.
341
+ """
342
+ if self._items is None:
343
+ raise ValueError("Cannot get objects when track_objects=False")
344
+ return [t.obj for t in self._items.items() if t.obj is not None]
345
+
346
+ def get_all_items(self) -> list[Item]:
347
+ """
348
+ Return all tracked items.
349
+
350
+ Returns:
351
+ List of Item if tracking is enabled, else an empty list.
352
+ """
353
+ if self._items is None:
354
+ raise ValueError("Cannot get items when track_objects=False")
355
+ return list(self._items.items())
356
+
357
+ def count_items(self) -> int:
358
+ """
359
+ Return the number of items stored in the native tree.
360
+
361
+ Notes:
362
+ This calls the native engine and may differ from len(self) if
363
+ you create multiple wrappers around the same native structure.
364
+ """
365
+ return self._native.count_items()
366
+
367
+ def __len__(self) -> int:
368
+ """
369
+ Return the number of successful inserts done via this wrapper.
370
+
371
+ Notes:
372
+ This is the Python-side counter that tracks calls that returned True.
373
+ use count_items() to get the authoritative native-side count.
374
+ """
375
+ return self._count
376
+
377
+ # Power users can access the raw class
378
+ NativeQuadTree = _RustQuadTree
379
+
380
+
381
+ __all__ = ["Bounds", "Item", "Point", "QuadTree"]
@@ -0,0 +1,70 @@
1
+ from typing import (
2
+ Any,
3
+ Iterable,
4
+ Literal as _Literal, # avoid polluting public namespace
5
+ overload,
6
+ )
7
+
8
+ Bounds = tuple[float, float, float, float]
9
+ Point = tuple[float, float]
10
+
11
+ class Item:
12
+ id_: int
13
+ x: float
14
+ y: float
15
+ obj: Any | None
16
+
17
+ class QuadTree:
18
+ # Expose the raw native class for power users
19
+ NativeQuadTree: type
20
+
21
+ def __init__(
22
+ self,
23
+ bounds: Bounds,
24
+ capacity: int,
25
+ *,
26
+ max_depth: int | None = None,
27
+ track_objects: bool = False,
28
+ start_id: int = 1,
29
+ ) -> None: ...
30
+
31
+ # Inserts
32
+ def insert(self, xy: Point, *, id_: int | None = ..., obj: Any = ...) -> int: ...
33
+ def insert_many_points(self, points: Iterable[Point]) -> int: ...
34
+ def attach(self, id_: int, obj: Any) -> None: ...
35
+
36
+ # Deletions
37
+ def delete(self, id_: int, xy: Point) -> bool: ...
38
+ def delete_by_object(self, obj: Any) -> bool: ...
39
+
40
+ # Queries
41
+ @overload
42
+ def query(
43
+ self, rect: Bounds, *, as_items: _Literal[False] = ...
44
+ ) -> list[tuple[int, float, float]]: ...
45
+ @overload
46
+ def query(self, rect: Bounds, *, as_items: _Literal[True]) -> list[Item]: ...
47
+ @overload
48
+ def nearest_neighbor(
49
+ self, xy: Point, *, as_item: _Literal[False] = ...
50
+ ) -> tuple[int, float, float] | None: ...
51
+ @overload
52
+ def nearest_neighbor(
53
+ self, xy: Point, *, as_item: _Literal[True]
54
+ ) -> Item | None: ...
55
+ @overload
56
+ def nearest_neighbors(
57
+ self, xy: Point, k: int, *, as_items: _Literal[False] = ...
58
+ ) -> list[tuple[int, float, float]]: ...
59
+ @overload
60
+ def nearest_neighbors(
61
+ self, xy: Point, k: int, *, as_items: _Literal[True]
62
+ ) -> list[Item]: ...
63
+
64
+ # Misc
65
+ def get(self, id_: int) -> Any | None: ...
66
+ def get_all_rectangles(self) -> list[Bounds]: ...
67
+ def get_all_objects(self) -> list[Any]: ...
68
+ def get_all_items(self) -> list[Item]: ...
69
+ def count_items(self) -> int: ...
70
+ def __len__(self) -> int: ...
fastquadtree/_bimap.py ADDED
@@ -0,0 +1,112 @@
1
+ # _bimap.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Iterable, Iterator
5
+
6
+ from ._item import Item
7
+
8
+
9
+ class BiMap:
10
+ """
11
+ Bidirectional map to the same Item:
12
+ id -> Item
13
+ obj -> Item (uses object identity)
14
+
15
+ Rules:
16
+ - One-to-one: an id maps to exactly one Item, and an object maps to exactly one Item.
17
+ - add(item): inserts or replaces both sides so they point to 'item'.
18
+ - If item.obj is None, only id -> Item is stored.
19
+ """
20
+
21
+ __slots__ = ("_id_to_item", "_objid_to_item")
22
+
23
+ def __init__(self, items: Iterable[Item] | None = None) -> None:
24
+ self._id_to_item: dict[int, Item] = {}
25
+ self._objid_to_item: dict[int, Item] = {}
26
+ if items:
27
+ for it in items:
28
+ self.add(it)
29
+
30
+ # - core -
31
+
32
+ def add(self, item: Item) -> None:
33
+ """
34
+ Insert or replace mapping for this Item.
35
+ Handles conflicts so that both id and obj point to this exact Item.
36
+ """
37
+ id_ = item.id_
38
+ obj = item.obj
39
+
40
+ # Unlink any old item currently bound to this id
41
+ old = self._id_to_item.get(id_)
42
+ if old is not None and old is not item:
43
+ old_obj = old.obj
44
+ if old_obj is not None:
45
+ self._objid_to_item.pop(id(old_obj), None)
46
+
47
+ # Unlink any old item currently bound to this obj
48
+ if obj is not None:
49
+ prev = self._objid_to_item.get(id(obj))
50
+ if prev is not None and prev is not item:
51
+ self._id_to_item.pop(prev.id_, None)
52
+
53
+ # Link new
54
+ self._id_to_item[id_] = item
55
+ if obj is not None:
56
+ self._objid_to_item[id(obj)] = item
57
+
58
+ def by_id(self, id_: int) -> Item | None:
59
+ return self._id_to_item.get(id_)
60
+
61
+ def by_obj(self, obj: Any) -> Item | None:
62
+ return self._objid_to_item.get(id(obj))
63
+
64
+ def pop_id(self, id_: int) -> Item | None:
65
+ it = self._id_to_item.pop(id_, None)
66
+ if it is not None:
67
+ obj = it.obj
68
+ if obj is not None:
69
+ self._objid_to_item.pop(id(obj), None)
70
+ return it
71
+
72
+ def pop_obj(self, obj: Any) -> Item | None:
73
+ it = self._objid_to_item.pop(id(obj), None)
74
+ if it is not None:
75
+ self._id_to_item.pop(it.id_, None)
76
+ return it
77
+
78
+ def pop_item(self, item: Item) -> Item | None:
79
+ """
80
+ Remove this exact Item if present on either side.
81
+ """
82
+ removed = None
83
+ # Remove by id first
84
+ removed = self._id_to_item.pop(item.id_)
85
+
86
+ # Remove by obj side
87
+ obj = item.obj
88
+ if obj is not None:
89
+ self._objid_to_item.pop(id(obj), None)
90
+ removed = removed or item
91
+ return removed
92
+
93
+ # - convenience -
94
+
95
+ def __len__(self) -> int:
96
+ return len(self._id_to_item)
97
+
98
+ def clear(self) -> None:
99
+ self._id_to_item.clear()
100
+ self._objid_to_item.clear()
101
+
102
+ def contains_id(self, id_: int) -> bool:
103
+ return id_ in self._id_to_item
104
+
105
+ def contains_obj(self, obj: Any) -> bool:
106
+ return id(obj) in self._objid_to_item
107
+
108
+ def items_by_id(self) -> Iterator[tuple[int, Item]]:
109
+ return iter(self._id_to_item.items())
110
+
111
+ def items(self) -> Iterator[Item]:
112
+ return iter(self._id_to_item.values())
fastquadtree/_item.py ADDED
@@ -0,0 +1,24 @@
1
+ # item.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+
7
+ class Item:
8
+ """
9
+ Lightweight view of an index entry.
10
+
11
+ Attributes:
12
+ id_: Integer identifier.
13
+ x: X coordinate.
14
+ y: Y coordinate.
15
+ obj: The attached Python object if available, else None.
16
+ """
17
+
18
+ __slots__ = ("id_", "obj", "x", "y")
19
+
20
+ def __init__(self, id_: int, x: float, y: float, obj: Any | None = None):
21
+ self.id_ = id_
22
+ self.x = x
23
+ self.y = y
24
+ self.obj = obj
Binary file
fastquadtree/py.typed ADDED
File without changes
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastquadtree
3
+ Version: 0.6.1
4
+ Classifier: Programming Language :: Python :: 3
5
+ Classifier: Programming Language :: Python :: 3 :: Only
6
+ Classifier: Programming Language :: Rust
7
+ Classifier: Programming Language :: Python :: Implementation :: CPython
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Scientific/Engineering
10
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
11
+ Classifier: Topic :: Software Development :: Libraries
12
+ Classifier: Typing :: Typed
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Requires-Dist: ruff>=0.6.0 ; extra == 'dev'
15
+ Requires-Dist: pytest>=7.0 ; extra == 'dev'
16
+ Requires-Dist: pytest-cov>=4.1 ; extra == 'dev'
17
+ Requires-Dist: coverage>=7.5 ; extra == 'dev'
18
+ Requires-Dist: mypy>=1.10 ; extra == 'dev'
19
+ Requires-Dist: build>=1.2.1 ; extra == 'dev'
20
+ Provides-Extra: dev
21
+ License-File: LICENSE
22
+ Summary: Rust-accelerated quadtree for Python with fast inserts, range queries, and k-NN search.
23
+ Keywords: quadtree,spatial-index,geometry,rust,pyo3,nearest-neighbor,k-nn
24
+ Author: Ethan Anderson
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ Project-URL: Homepage, https://github.com/Elan456/fastquadtree
28
+ Project-URL: Repository, https://github.com/Elan456/fastquadtree
29
+ Project-URL: Issues, https://github.com/Elan456/fastquadtree/issues
30
+
31
+ # fastquadtree
32
+
33
+ [![PyPI version](https://img.shields.io/pypi/v/fastquadtree.svg)](https://pypi.org/project/fastquadtree/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/fastquadtree.svg)](https://pypi.org/project/fastquadtree/)
35
+ [![Wheels](https://img.shields.io/pypi/wheel/fastquadtree.svg)](https://pypi.org/project/fastquadtree/#files)
36
+ [![License: MIT](https://img.shields.io/pypi/l/fastquadtree.svg)](LICENSE)
37
+
38
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/fastquadtree?period=total&units=INTERNATIONAL_SYSTEM&left_color=GRAY&right_color=BLUE&left_text=Total+Downloads)](https://pepy.tech/projects/fastquadtree)
39
+
40
+ [![Build](https://github.com/Elan456/fastquadtree/actions/workflows/release.yml/badge.svg)](https://github.com/Elan456/fastquadtree/actions/workflows/ci.yml)
41
+ [![Codecov](https://codecov.io/gh/Elan456/fastquadtree/branch/main/graph/badge.svg)](https://codecov.io/gh/Elan456/fastquadtree)
42
+
43
+ [![Rust core via PyO3](https://img.shields.io/badge/Rust-core%20via%20PyO3-orange)](https://pyo3.rs/)
44
+ [![Built with maturin](https://img.shields.io/badge/Built%20with-maturin-1f6feb)](https://www.maturin.rs/)
45
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
46
+
47
+
48
+
49
+ ![Interactive_V2_Screenshot](https://raw.githubusercontent.com/Elan456/fastquadtree/main/assets/interactive_v2_screenshot.png)
50
+
51
+
52
+ Rust-optimized quadtree with a simple Python API.
53
+
54
+ - Python package: **`fastquadtree`**
55
+ - Python ≥ 3.8
56
+ - Import path: `from fastquadtree import QuadTree`
57
+
58
+ ## Benchmarks
59
+
60
+ fastquadtree **outperforms** all other quadtree Python packages, including the Rtree spatial index.
61
+
62
+ ### Library comparison
63
+
64
+ ![Total time](https://raw.githubusercontent.com/Elan456/fastquadtree/main/assets/quadtree_bench_time.png)
65
+ ![Throughput](https://raw.githubusercontent.com/Elan456/fastquadtree/main/assets/quadtree_bench_throughput.png)
66
+
67
+ ### Summary (largest dataset, PyQtree baseline)
68
+ - Points: **250,000**, Queries: **500**
69
+ --------------------
70
+ - Fastest total: **fastquadtree** at **0.120 s**
71
+
72
+ | Library | Build (s) | Query (s) | Total (s) | Speed vs PyQtree |
73
+ |---|---:|---:|---:|---:|
74
+ | fastquadtree | 0.031 | 0.089 | 0.120 | 14.64× |
75
+ | Shapely STRtree | 0.179 | 0.100 | 0.279 | 6.29× |
76
+ | nontree-QuadTree | 0.595 | 0.605 | 1.200 | 1.46× |
77
+ | Rtree | 0.961 | 0.300 | 1.261 | 1.39× |
78
+ | e-pyquadtree | 1.005 | 0.660 | 1.665 | 1.05× |
79
+ | PyQtree | 1.492 | 0.263 | 1.755 | 1.00× |
80
+ | quads | 1.407 | 0.484 | 1.890 | 0.93× |
81
+
82
+ #### Benchmark Configuration
83
+ | Parameter | Value |
84
+ |---|---:|
85
+ | Bounds | (0, 0, 1000, 1000) |
86
+ | Max points per node | 128 |
87
+ | Max depth | 16 |
88
+ | Queries per experiment | 500 |
89
+
90
+ ## Install
91
+
92
+ ```bash
93
+ pip install fastquadtree
94
+ ````
95
+
96
+ If you are developing locally:
97
+
98
+ ```bash
99
+ # optimized dev install
100
+ maturin develop --release
101
+ ```
102
+
103
+ ## Quickstart
104
+
105
+ ```python
106
+ from fastquadtree import QuadTree
107
+
108
+ # Bounds are (min_x, min_y, max_x, max_y)
109
+ qt = QuadTree(bounds=(0, 0, 1000, 1000), capacity=20) # max_depth is optional
110
+
111
+ # Insert points with auto ids
112
+ id1 = qt.insert((10, 10))
113
+ id2 = qt.insert((200, 300))
114
+ id3 = qt.insert((999, 500), id=42) # you can supply your own id
115
+
116
+ # Axis-aligned rectangle query
117
+ hits = qt.query((0, 0, 250, 350)) # returns [(id, x, y), ...] by default
118
+ print(hits) # e.g. [(1, 10.0, 10.0), (2, 200.0, 300.0)]
119
+
120
+ # Nearest neighbor
121
+ best = qt.nearest_neighbor((210, 310)) # -> (id, x, y) or None
122
+ print(best)
123
+
124
+ # k-nearest neighbors
125
+ top3 = qt.nearest_neighbors((210, 310), 3)
126
+ print(top3) # list of up to 3 (id, x, y) tuples
127
+
128
+ # Delete items by ID and location
129
+ deleted = qt.delete(id2, (200, 300)) # True if found and deleted
130
+ print(f"Deleted: {deleted}")
131
+ print(f"Remaining items: {qt.count_items()}")
132
+
133
+ # For object tracking with track_objects=True
134
+ qt_tracked = QuadTree((0, 0, 1000, 1000), capacity=4, track_objects=True)
135
+ player1 = {"name": "Alice", "score": 100}
136
+ player2 = {"name": "Bob", "score": 200}
137
+
138
+ id1 = qt_tracked.insert((50, 50), obj=player1)
139
+ id2 = qt_tracked.insert((150, 150), obj=player2)
140
+
141
+ # Delete by object reference (O(1) lookup!)
142
+ deleted = qt_tracked.delete_by_object(player1)
143
+ print(f"Deleted player: {deleted}") # True
144
+ ```
145
+
146
+ ### Working with Python objects
147
+
148
+ You can keep the tree pure and manage your own id → object map, or let the wrapper manage it.
149
+
150
+ **Wrapper Managed Objects**
151
+
152
+ ```python
153
+ from fastquadtree import QuadTree
154
+
155
+ qt = QuadTree((0, 0, 1000, 1000), capacity=16, track_objects=True)
156
+
157
+ # Store the object alongside the point
158
+ qt.insert((25, 40), obj={"name": "apple"})
159
+
160
+ # Ask for Item objects within a bounding box
161
+ items = qt.query((0, 0, 100, 100), as_items=True)
162
+ for it in items:
163
+ print(it.id, it.x, it.y, it.obj)
164
+ ```
165
+
166
+ You can also attach or replace an object later:
167
+
168
+ ```python
169
+ qt.attach(123, my_object) # binds object to id 123
170
+ ```
171
+
172
+ ## API
173
+
174
+ ### `QuadTree(bounds, capacity, max_depth=None, track_objects=False, start_id=1)`
175
+
176
+ * `bounds` — tuple `(min_x, min_y, max_x, max_y)` defines the 2D area covered by the quadtree
177
+ * `capacity` — max number of points kept in a leaf before splitting
178
+ * `max_depth` — optional depth cap. If omitted, the tree can keep splitting as needed
179
+ * `track_objects` — if `True`, the wrapper maintains an id → object map for convenience.
180
+ * `start_id` — starting value for auto-assigned ids
181
+
182
+ ### Core Methods
183
+
184
+ Full docs are in the docstrings of the [Python Shim](pysrc/fastquadtree/__init__.py)
185
+
186
+ - `insert(xy, *, id=None, obj=None) -> int`
187
+
188
+ - `insert_many_points(points) -> int`
189
+
190
+ - `query(rect, *, as_items=False) -> list`
191
+
192
+ - `nearest_neighbor(xy, *, as_item=False) -> (id, x, y) | Item | None`
193
+
194
+ - `nearest_neighbors(xy, k, *, as_items=False) -> list`
195
+
196
+ - `delete(id, xy) -> bool`
197
+
198
+ - `delete_by_object(obj) -> bool (requires track_objects=True)`
199
+
200
+ - `attach(id, obj) -> None (requires track_objects=True)`
201
+
202
+ - `count_items() -> int`
203
+
204
+ - `get(id) -> object | None`
205
+
206
+ - `get_all_rectangles() -> list[tuple] (for visualization)`
207
+
208
+ ### `Item` (returned when `as_items=True`)
209
+
210
+ * Attributes: `id`, `x`, `y`, and a lazy `obj` property
211
+ * Accessing `obj` performs a dictionary lookup only if tracking is enabled
212
+
213
+ ### Geometric conventions
214
+
215
+ * Rectangles are `(min_x, min_y, max_x, max_y)`.
216
+ * Containment rule is closed on the min edge and open on the max edge
217
+ `(x >= min_x and x < max_x and y >= min_y and y < max_y)`.
218
+ This only matters for points exactly on edges.
219
+
220
+ ## Performance tips
221
+
222
+ * Choose `capacity` so that leaves keep a small batch of points. Typical values are 8 to 64.
223
+ * If your data is very skewed, set a `max_depth` to prevent long chains.
224
+ * For fastest local runs, use `maturin develop --release`.
225
+ * The wrapper keeps Python overhead low: raw tuple results by default, `Item` wrappers only when requested.
226
+
227
+
228
+ ### Native vs Shim Benchmark
229
+
230
+ **Setup**
231
+ - Points: 500,000
232
+ - Queries: 500
233
+ - Repeats: 5
234
+
235
+ **Timing (seconds)**
236
+
237
+ | Variant | Build | Query | Total |
238
+ |---|---:|---:|---:|
239
+ | Native | 0.483 | 4.380 | 4.863 |
240
+ | Shim (no map) | 0.668 | 4.167 | 4.835 |
241
+ | Shim (track+objs) | 1.153 | 4.458 | 5.610 |
242
+
243
+ **Overhead vs Native**
244
+
245
+ - No map: build 1.38x, query 0.95x, total 0.99x
246
+ - Track + objs: build 2.39x, query 1.02x, total 1.15x
247
+
248
+ ### Run benchmarks
249
+ To run the benchmarks yourself, first install the dependencies:
250
+
251
+ ```bash
252
+ pip install -r benchmarks/requirements.txt
253
+ ```
254
+
255
+ Then run:
256
+
257
+ ```bash
258
+ python benchmarks/cross_library_bench.py
259
+ python benchmarks/benchmark_native_vs_shim.py
260
+ ```
261
+
262
+ ## Run Visualizer
263
+ A visualizer is included to help you understand how the quadtree subdivides space.
264
+
265
+ ```bash
266
+ pip install -r interactive/requirements.txt
267
+ python interactive/interactive_v2.py
268
+ ```
269
+
270
+ Check the CLI arguments for the cross-library benchmark in `benchmarks/quadtree_bench/main.py`.
271
+
272
+ ## FAQ
273
+
274
+ **What happens if I insert the same id more than once?**
275
+ Allowed. For k-nearest, duplicates are de-duplicated by id. For range queries you will see every inserted point.
276
+
277
+ **Can I delete items from the quadtree?**
278
+ Yes! Use `delete(id, xy)` to remove specific items. You must provide both the ID and exact location for precise deletion. This handles cases where multiple items exist at the same location. If you're using `track_objects=True`, you can also use `delete_by_object(obj)` for convenient object-based deletion with O(1) lookup. The tree automatically merges nodes when item counts drop below capacity.
279
+
280
+ **Can I store rectangles or circles?**
281
+ The core stores points. To index objects with extent, insert whatever representative point you choose. For rectangles you can insert centers or build an AABB tree separately.
282
+
283
+ **Threading**
284
+ Use one tree per thread if you need heavy parallel inserts from Python.
285
+
286
+ ## License
287
+
288
+ MIT. See `LICENSE`.
289
+
290
+ ## Acknowledgments
291
+
292
+ * Python libraries compared: [PyQtree], [e-pyquadtree], [Rtree], [nontree], [quads], [Shapely]
293
+ * Built with [PyO3] and [maturin]
294
+
295
+ [PyQtree]: https://pypi.org/project/pyqtree/
296
+ [e-pyquadtree]: https://pypi.org/project/e-pyquadtree/
297
+ [PyO3]: https://pyo3.rs/
298
+ [maturin]: https://www.maturin.rs/
299
+ [Rtree]: https://pypi.org/project/Rtree/
300
+ [nontree]: https://pypi.org/project/nontree/
301
+ [quads]: https://pypi.org/project/quads/
302
+ [Shapely]: https://pypi.org/project/Shapely/
303
+
@@ -0,0 +1,10 @@
1
+ fastquadtree-0.6.1.dist-info/METADATA,sha256=ttnNs9M-mAPdUlOLj23Bq1Ewa_FwxB_pZDQevu3k1Ho,10474
2
+ fastquadtree-0.6.1.dist-info/WHEEL,sha256=lf0-jsOnQyGyURv7tfSV1sL9iSHJ1fDLj4bvJhUjCEA,129
3
+ fastquadtree-0.6.1.dist-info/licenses/LICENSE,sha256=pRuvcuqIMtEUBMgvP1Bc4fOHydzeuA61c6DQoQ1pb1w,1071
4
+ fastquadtree/__init__.py,sha256=1hCXNpz85X_UC4JqKj4AseFe0HNMM6yFWH3r8rNgX9U,12054
5
+ fastquadtree/__init__.pyi,sha256=NkLrhMy9HkcqdeEfmK6D9c01w-chiNope7DA4PyTeNQ,1992
6
+ fastquadtree/_bimap.py,sha256=pWY4O4BmOa78r4MnBMfpb-hYZ222U8mEMy4pnosgXLQ,3290
7
+ fastquadtree/_item.py,sha256=9Tk4wUZLFsjIhWALJ9xGW4bknvU_xfy2Na3ixWxh_xI,509
8
+ fastquadtree/_native.abi3.so,sha256=Mcpf2l9wI-8H5JuAMq2fVnfDl_Il6AS0jexV32tOnjk,447880
9
+ fastquadtree/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ fastquadtree-0.6.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.9.5)
3
+ Root-Is-Purelib: false
4
+ Tag: cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ethan Anderson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.