Add ShapeSearchTree: exact spatial index over item shapes

A bounding-box spatial hash (broad phase) with exact IntBox-intersection
narrow phase, replacing the exact router's occupancy grid. Supports
insert/remove-owner and overlap/region queries, a clearance_conflict test
(strict 2-D overlap of the clearance-expanded box, so tiles exactly a
clearance apart are allowed), and has_violation for the whole-board DRC
check. Tiles are tagged routed vs static so pad-vs-pad spacing in the
source design is not counted as a routing violation.
This commit is contained in:
Ryan Malloy 2026-07-13 02:16:11 -06:00
parent 2d394872e3
commit b200ecf6f8
3 changed files with 203 additions and 0 deletions

View File

@ -17,6 +17,7 @@ from .clearance import ClearanceMatrix
from .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure
from .net import Net, Nets
from .search_tree import ShapeSearchTree, TreeShape
from .transform import CoordinateTransform
from .unit import Unit
@ -35,5 +36,7 @@ __all__ = [
"Via",
"Trace",
"BasicBoard",
"ShapeSearchTree",
"TreeShape",
"build_board",
]

View File

@ -0,0 +1,136 @@
"""``ShapeSearchTree`` — a spatial index over item shapes with exact overlap.
Ports the query role of ``board/ShapeSearchTree*.java``. It replaces the exact
router's occupancy grid: instead of rasterizing shapes into cells, it indexes
each item's convex tiles (:class:`~freeroute.geometry.IntBox`) and answers
overlap / region queries with a broad phase (a bounding-box spatial hash) and an
**exact** narrow phase (exact ``IntBox`` intersection). Insert and remove keep
the index in step as traces are added.
**Arithmetic model: exact.** The narrow-phase overlap and clearance tests are
exact integer ``IntBox`` intersections; no grid quantization. (The tiles happen
to be axis-aligned boxes because the exact router routes orthogonally; the index
itself is shape-agnostic any tile exposing ``bounding_box()`` and an exact
``intersection`` works.)
"""
from __future__ import annotations
from dataclasses import dataclass
from freeroute.geometry import IntBox
__all__ = ["TreeShape", "ShapeSearchTree"]
@dataclass(frozen=True)
class TreeShape:
"""One convex tile of an item, tagged with its net, layers and owner id.
``routed`` distinguishes router-produced copper (traces, vias) from static
board items (pads, keepouts). The routing DRC only counts violations that
involve at least one routed tile two source-design pads that are already
closer than the clearance are a property of the input, not of the router.
"""
owner: int # a caller-chosen id grouping the tiles of one item/connection
net_no: int
layers: frozenset[int]
tile: IntBox
routed: bool = False
def on_layer(self, layer: int) -> bool:
return layer in self.layers
class ShapeSearchTree:
"""Bounding-box spatial hash of :class:`TreeShape` with exact queries."""
__slots__ = ("_bucket", "_buckets", "_by_owner")
def __init__(self, bucket_size: int = 200_000) -> None:
self._bucket = max(bucket_size, 1)
self._buckets: dict[tuple[int, int], list[TreeShape]] = {}
self._by_owner: dict[int, list[TreeShape]] = {}
# --- broad-phase bucketing ---------------------------------------------
def _cells(self, box: IntBox):
lo_x = box.ll.x // self._bucket
hi_x = box.ur.x // self._bucket
lo_y = box.ll.y // self._bucket
hi_y = box.ur.y // self._bucket
for bx in range(lo_x, hi_x + 1):
for by in range(lo_y, hi_y + 1):
yield bx, by
# --- mutation ----------------------------------------------------------
def insert(self, shape: TreeShape) -> None:
for cell in self._cells(shape.tile):
self._buckets.setdefault(cell, []).append(shape)
self._by_owner.setdefault(shape.owner, []).append(shape)
def remove_owner(self, owner: int) -> None:
"""Remove every tile belonging to ``owner`` (a ripped connection)."""
shapes = self._by_owner.pop(owner, [])
for shape in shapes:
for cell in self._cells(shape.tile):
bucket = self._buckets.get(cell)
if bucket:
self._buckets[cell] = [s for s in bucket if s is not shape]
def all_shapes(self) -> list[TreeShape]:
return [s for shapes in self._by_owner.values() for s in shapes]
# --- queries (exact narrow phase) --------------------------------------
def overlapping(self, box: IntBox) -> list[TreeShape]:
"""All indexed tiles whose exact intersection with ``box`` is non-empty."""
seen: set[int] = set()
result: list[TreeShape] = []
for cell in self._cells(box):
for shape in self._buckets.get(cell, ()):
key = id(shape)
if key in seen:
continue
seen.add(key)
if not shape.tile.intersection(box).is_empty():
result.append(shape)
return result
def clearance_conflict(self, box: IntBox, net_no: int, layer: int, clearance: int) -> bool:
"""True if placing ``box`` (of ``net_no`` on ``layer``) would come *closer
than* ``clearance`` to a different net's tile on that layer (exact).
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed.
"""
expanded = box.offset(clearance)
for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer):
continue
if shape.tile.overlaps(expanded):
return True
return False
def has_violation(self, clearance: int) -> TreeShape | None:
"""Return a routed tile involved in a clearance violation, or ``None``.
The routing DRC invariant: for every pair of different-net tiles sharing
a layer where **at least one is routed** (a trace or via), the exact
intersection of their ``clearance``-expanded coppers is empty (no 2-D
overlap). Static pad-vs-pad spacing in the source design is not counted.
"""
for shape in self.all_shapes():
expanded = shape.tile.offset(clearance)
for other in self.overlapping(expanded):
if other is shape or other.net_no == shape.net_no:
continue
if not (shape.routed or other.routed):
continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers):
continue
if other.tile.overlaps(expanded):
return shape
return None

View File

@ -0,0 +1,64 @@
"""Tests for the exact ShapeSearchTree spatial index."""
from __future__ import annotations
from freeroute.board import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox
def shape(owner, net, layer, box, routed=False):
return TreeShape(owner, net, frozenset({layer}), box, routed=routed)
def test_insert_and_overlapping_exact():
tree = ShapeSearchTree(bucket_size=100)
tree.insert(shape(0, 1, 0, IntBox(0, 0, 50, 50)))
tree.insert(shape(1, 2, 0, IntBox(200, 200, 250, 250)))
# a query box overlapping only the first shape
hit = tree.overlapping(IntBox(40, 40, 60, 60))
assert [s.owner for s in hit] == [0]
# a query far from everything
assert tree.overlapping(IntBox(1000, 1000, 1010, 1010)) == []
def test_touching_boxes_do_not_overlap_but_do_intersect():
tree = ShapeSearchTree(bucket_size=100)
tree.insert(shape(0, 1, 0, IntBox(0, 0, 10, 10)))
# shares the edge x=10 -> intersection is 1-D (found by broad phase)
assert len(tree.overlapping(IntBox(10, 0, 20, 10))) == 1
def test_remove_owner():
tree = ShapeSearchTree(bucket_size=100)
tree.insert(shape(0, 1, 0, IntBox(0, 0, 50, 50)))
tree.insert(shape(0, 1, 0, IntBox(60, 0, 90, 50))) # same owner, 2 tiles
tree.insert(shape(1, 2, 0, IntBox(0, 0, 50, 50)))
tree.remove_owner(0)
assert all(s.owner == 1 for s in tree.all_shapes())
assert len(tree.all_shapes()) == 1
def test_clearance_conflict_respects_the_clearance_gap():
tree = ShapeSearchTree(bucket_size=1000)
tree.insert(shape(0, 1, 0, IntBox(0, 0, 100, 100), routed=True))
# a net-2 box exactly `clearance` (20) to the right -> allowed (touching)
ok = IntBox(120, 0, 200, 100)
assert not tree.clearance_conflict(ok, net_no=2, layer=0, clearance=20)
# one unit closer -> conflict
bad = IntBox(119, 0, 200, 100)
assert tree.clearance_conflict(bad, net_no=2, layer=0, clearance=20)
# same net never conflicts
assert not tree.clearance_conflict(bad, net_no=1, layer=0, clearance=20)
# different layer never conflicts
assert not tree.clearance_conflict(bad, net_no=2, layer=1, clearance=20)
def test_has_violation_ignores_static_pad_pairs():
tree = ShapeSearchTree(bucket_size=1000)
# two different-net *static* pads closer than clearance -> not a routing DRC
tree.insert(shape(0, 1, 0, IntBox(0, 0, 10, 10), routed=False))
tree.insert(shape(1, 2, 0, IntBox(11, 0, 20, 10), routed=False))
assert tree.has_violation(clearance=20) is None
# a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None