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:
parent
2d394872e3
commit
b200ecf6f8
@ -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",
|
||||
]
|
||||
|
||||
136
src/freeroute/board/search_tree.py
Normal file
136
src/freeroute/board/search_tree.py
Normal 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
|
||||
64
tests/board/test_search_tree.py
Normal file
64
tests/board/test_search_tree.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user