Support exact octagon tiles in the search tree clearance checks

Add optional exact/seg fields to TreeShape for a diagonal trace tile: tile holds
the bounding box for broad-phase bucketing, exact holds the tight octagon copper,
and seg=(a,b,half_width) rebuilds the clearance region on demand. Every
orthogonal tile leaves both None and takes the identical box code path, so the
box-only tree stays byte-for-byte unchanged (whole suite green).

clearance_conflict now tests a stored diagonal tile with an exact 2-D overlap;
clearance_conflict_shape checks a diagonal trace's clearance octagon against
stored copper; has_violation runs the exact octagon test whenever either tile is
diagonal and the plain box overlap otherwise. All exact-integer via Simplex
intersection.
This commit is contained in:
Ryan Malloy 2026-07-13 12:47:06 -06:00
parent 8380318d3c
commit 20dc5fd96a

View File

@ -18,7 +18,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from freeroute.geometry import IntBox from freeroute.geometry import IntBox, Simplex, overlaps_2d, segment_octagon
__all__ = ["TreeShape", "ShapeSearchTree"] __all__ = ["TreeShape", "ShapeSearchTree"]
@ -31,6 +31,13 @@ class TreeShape:
board items (pads, keepouts). The routing DRC only counts violations that board items (pads, keepouts). The routing DRC only counts violations that
involve at least one routed tile two source-design pads that are already 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. 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 owner: int # a caller-chosen id grouping the tiles of one item/connection
@ -38,10 +45,22 @@ class TreeShape:
layers: frozenset[int] layers: frozenset[int]
tile: IntBox tile: IntBox
routed: bool = False routed: bool = False
exact: Simplex | None = None
seg: tuple | None = None # (a: IntPoint, b: IntPoint, half_width: int)
def on_layer(self, layer: int) -> bool: def on_layer(self, layer: int) -> bool:
return layer in self.layers 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: class ShapeSearchTree:
"""Bounding-box spatial hash of :class:`TreeShape` with exact queries.""" """Bounding-box spatial hash of :class:`TreeShape` with exact queries."""
@ -110,7 +129,27 @@ class ShapeSearchTree:
for shape in self.overlapping(expanded): for shape in self.overlapping(expanded):
if shape.net_no == net_no or not shape.on_layer(layer): if shape.net_no == net_no or not shape.on_layer(layer):
continue continue
if shape.tile.overlaps(expanded): 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 True
return False return False
@ -124,6 +163,7 @@ class ShapeSearchTree:
""" """
for shape in self.all_shapes(): for shape in self.all_shapes():
expanded = shape.tile.offset(clearance) expanded = shape.tile.offset(clearance)
grown = None # exact clearance region, built lazily only for diagonals
for other in self.overlapping(expanded): for other in self.overlapping(expanded):
if other is shape or other.net_no == shape.net_no: if other is shape or other.net_no == shape.net_no:
continue continue
@ -131,6 +171,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers): if not (shape.layers & other.layers):
continue continue
if other.tile.overlaps(expanded): 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 shape
return None return None