Add exact-geometry (orthogonal, DRC-clean) routing track

Adds route/exact_router.py: routes with the grid track's rip-up/multi-layer/
via topology search in orthogonal mode (axis-aligned segments -> exact IntBox
copper), then verifies clearance exactly against a ShapeSearchTree. A net
whose copper would come closer than the clearance to any accepted item is
dropped, so the emitted geometry is DRC-clean by construction; endpoints stay
exactly on pads and vias at real layer transitions.

GridRouter gains a non-breaking orthogonal option (4-connected). The pipeline
gains an engine= selector: 'grid' (default, unchanged, highest coverage) or
'exact' (DRC-verified). The grid MVP stays the fallback.

On the designed boards the exact track routes every net DRC-clean; on the
real KiCad board it matches the grid's connectivity (4/4 multi-pin nets) and
is DRC-clean where the grid MVP's widened centrelines are not. Deferred:
shove, and 45-degree/IntOctagon trace caps.

Tests: the exact router's output has no clearance violation (exact IntBox
check) on every fixture; endpoints on pads; traces orthogonal; crossing uses
a via; and a direct comparison showing the exact track is clean where the
grid MVP violates on the real board. Oracle-gated parity for engine='exact'.
This commit is contained in:
2026-07-13 02:16:23 -06:00
parent b200ecf6f8
commit 0bee385e71
5 changed files with 356 additions and 11 deletions
+7 -1
View File
@@ -128,6 +128,7 @@ class GridRouter:
max_passes: int = 10,
rip_cap: int = 4,
rip_penalty: float = 30.0,
orthogonal: bool = False,
) -> None:
self.board = board
self.trace_width = max(trace_width, 1)
@@ -138,6 +139,9 @@ class GridRouter:
self.max_passes = max(max_passes, 1)
self.rip_cap = max(rip_cap, 0)
self.rip_penalty = rip_penalty
# orthogonal routing (axis-aligned segments only) yields exact IntBox
# trace copper; 8-connected also produces 45-degree segments.
self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS
# cell size must fit a trace plus its clearance to a neighbouring trace
self.step = max(self.trace_width + self.clearance, 1)
@@ -272,7 +276,7 @@ class GridRouter:
return _reconstruct(came_from, current)
cx, cy, cl = current
base = g_score[current]
for dx, dy in _NEIGHBOURS:
for dx, dy in self._neighbours:
cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue
@@ -485,6 +489,7 @@ def route_board(
via_cost: float = 10.0,
rip_up: bool = True,
max_passes: int = 10,
orthogonal: bool = False,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -495,5 +500,6 @@ def route_board(
via_cost=via_cost,
rip_up=rip_up,
max_passes=max_passes,
orthogonal=orthogonal,
)
return router.route()