A route may not leave a cell inside a wire keepout as a wire, nor reach a pin there by one: it changes layer instead, and the via goes in the pad. A pin whose every stub enters a keepout is reached by a via only. A structure keepout named rule_area... (mckicad names KiCad rule areas so) is checked for overlap only, as KiCad's DRC does, with the free ends of each wire tested for their round caps.
217 lines
9.5 KiB
Python
217 lines
9.5 KiB
Python
"""``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"
|
|
#: False for a rule area: copper may touch it, only overlap counts
|
|
clearance: bool = True
|
|
|
|
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.
|
|
A rule area (``clearance=False``) only has to be clear of the copper
|
|
itself.
|
|
"""
|
|
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 not shape.clearance:
|
|
if shape.tile.overlaps(box):
|
|
return True
|
|
continue
|
|
if shape.exact is not None:
|
|
if overlaps_2d(shape.exact, expanded):
|
|
return True
|
|
elif shape.tile.overlaps(expanded):
|
|
return True
|
|
return False
|
|
|
|
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
|
|
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
|
|
for shape in self.overlapping(box):
|
|
if shape.clearance or not shape.on_layer(layer):
|
|
continue
|
|
if shape.blocks != "all" and shape.blocks != kind + "s":
|
|
continue
|
|
if shape.tile.overlaps(box):
|
|
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 not (shape.clearance and other.clearance):
|
|
# a rule area: only an overlap is a violation
|
|
mine = shape.exact if shape.exact is not None else shape.tile
|
|
theirs = other.exact if other.exact is not None else other.tile
|
|
if overlaps_2d(mine, theirs):
|
|
return shape
|
|
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
|