"""``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, Simplex, overlaps_2d, segment_octagon __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. ``exact`` / ``seg`` are set only for a 45-degree (diagonal) trace tile: ``tile`` is then the bounding box (broad-phase only), ``exact`` is the tight octagon copper :class:`~freeroute.geometry.Simplex`, and ``seg`` is ``(a, b, half_width)`` so the clearance region can be rebuilt exactly on demand. For every orthogonal tile both are ``None`` and all code paths are byte-identical to the box-only tree. """ owner: int # a caller-chosen id grouping the tiles of one item/connection net_no: int layers: frozenset[int] tile: IntBox routed: bool = False exact: Simplex | None = None seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int) #: which copper a keepout tile stops: "all", "wires" (a wire_keepout, #: vias allowed) or "vias" (a via_keepout) blocks: str = "all" def on_layer(self, layer: int) -> bool: return layer in self.layers def clearance_region(self, clearance: int): """This tile's copper grown outward by ``clearance`` (a convex tile). Orthogonal: the box offset by ``clearance``. Diagonal: the octagon of the segment swept by ``half_width + clearance`` (exact integer superset).""" if self.seg is None: return self.tile.offset(clearance) a, b, half_width = self.seg return segment_octagon(a, b, half_width + clearance) 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, kind: str = "wire" ) -> 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.blocks != "all" and shape.blocks != kind + "s": continue # a wire-only keepout does not stop a via, and so on if shape.exact is not None: if overlaps_2d(shape.exact, expanded): return True elif shape.tile.overlaps(expanded): return True return False def clearance_conflict_shape( self, region, net_no: int, layer: int ) -> bool: """True if the convex ``region`` (a clearance-grown copper tile, e.g. a diagonal trace's octagon) overlaps a different net's copper on ``layer``. ``region`` already carries the clearance, so this tests a bare 2-D overlap against each candidate's exact tile (or its box). Used to check a diagonal trace whose growth is not an axis-aligned box.""" for shape in self.overlapping(region.bounding_box()): if shape.net_no == net_no or not shape.on_layer(layer): continue other = shape.exact if shape.exact is not None else shape.tile if overlaps_2d(region, other): 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) grown = None # exact clearance region, built lazily only for diagonals 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 shape.exact is None and other.exact is None: if other.tile.overlaps(expanded): # byte-identical box path return shape continue if grown is None: grown = shape.clearance_region(clearance) other_tile = other.exact if other.exact is not None else other.tile if overlaps_2d(grown, other_tile): return shape return None