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 .items import ConductionArea, Item, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure from .layer import Layer, LayerStructure
from .net import Net, Nets from .net import Net, Nets
from .search_tree import ShapeSearchTree, TreeShape
from .transform import CoordinateTransform from .transform import CoordinateTransform
from .unit import Unit from .unit import Unit
@ -35,5 +36,7 @@ __all__ = [
"Via", "Via",
"Trace", "Trace",
"BasicBoard", "BasicBoard",
"ShapeSearchTree",
"TreeShape",
"build_board", "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