From b200ecf6f8c0e1bde519af5113728bd353a2719f Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 13 Jul 2026 02:16:11 -0600 Subject: [PATCH] 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. --- src/freeroute/board/__init__.py | 3 + src/freeroute/board/search_tree.py | 136 +++++++++++++++++++++++++++++ tests/board/test_search_tree.py | 64 ++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 src/freeroute/board/search_tree.py create mode 100644 tests/board/test_search_tree.py diff --git a/src/freeroute/board/__init__.py b/src/freeroute/board/__init__.py index a79839e..e1a06a0 100644 --- a/src/freeroute/board/__init__.py +++ b/src/freeroute/board/__init__.py @@ -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", ] diff --git a/src/freeroute/board/search_tree.py b/src/freeroute/board/search_tree.py new file mode 100644 index 0000000..9706aa3 --- /dev/null +++ b/src/freeroute/board/search_tree.py @@ -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 diff --git a/tests/board/test_search_tree.py b/tests/board/test_search_tree.py new file mode 100644 index 0000000..17f7881 --- /dev/null +++ b/tests/board/test_search_tree.py @@ -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