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
+12 -1
View File
@@ -14,8 +14,15 @@ multi-layer via search, and shove are deferred.
from __future__ import annotations
from .exact_router import ExactRouteResult, route_board_exact
from .grid_router import GridRouter, RouteResult, route_board
from .pipeline import build_routing_result, route, route_dsn_board
from .pipeline import (
build_exact_routing_result,
build_routing_result,
route,
route_dsn_board,
route_dsn_board_exact,
)
__all__ = [
"GridRouter",
@@ -24,4 +31,8 @@ __all__ = [
"route",
"route_dsn_board",
"build_routing_result",
"ExactRouteResult",
"route_board_exact",
"build_exact_routing_result",
"route_dsn_board_exact",
]
+135
View File
@@ -0,0 +1,135 @@
"""Exact-geometry routing track (orthogonal), built on the search tree.
The grid MVP (:mod:`freeroute.route.grid_router`) approximates clearance with
cell occupancy and emits centrelines with no width-aware DRC check. This track
keeps the grid's rip-up/multi-layer/via *topology* search (in orthogonal mode,
so segments are axis-aligned) but replaces the occupancy check with an **exact**
one: every item's copper is an exact :class:`~freeroute.geometry.IntBox` tile
indexed in a :class:`~freeroute.board.search_tree.ShapeSearchTree`, and the
routed board is verified to have **no clearance violation** by exact tile
intersection.
Traces are exact segments between the true pad/via anchors (not grid-snapped in
value — the endpoints are the exact pad locations; interior corners are exact
integer coordinates). Multi-layer and vias are preserved. The grid router stays
the fallback; the pipeline prefers this track when it routes cleanly.
Deferred: **shove** (moving an existing trace aside instead of ripping it) and
45-degree / ``IntOctagon`` trace caps (this track routes orthogonally so the
``IntBox`` copper is exact).
"""
from __future__ import annotations
from dataclasses import dataclass
from freeroute.board.board import BasicBoard
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, Polyline, PolylineShape
from .grid_router import RouteResult, route_board
__all__ = ["ExactRouteResult", "route_board_exact"]
@dataclass
class ExactRouteResult:
"""The routed geometry plus the exact spatial index it was verified against."""
result: RouteResult
tree: ShapeSearchTree
#: ``True`` if the routed board has no different-net clearance violation
drc_clean: bool
@property
def routed_net_numbers(self):
return self.result.routed_net_numbers
def via_count(self) -> int:
return self.result.via_count()
def route_board_exact(
board: BasicBoard,
*,
trace_width: int,
clearance: int,
layers: list[int] | None = None,
via_cost: float = 10.0,
max_passes: int = 10,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry."""
signal_layers = layers or [
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
]
half_width = max(trace_width // 2, 1)
# topology + geometry from the (orthogonal) grid search, incl. rip-up + vias
result = route_board(
board,
trace_width=trace_width,
clearance=clearance,
layers=signal_layers,
via_cost=via_cost,
max_passes=max_passes,
orthogonal=True,
)
tree = ShapeSearchTree()
owner = _counter()
# static items: pad and keepout copper
for pin in board.get_pins():
if pin.shape is None:
continue
box = pin.shape.bounding_box()
if box.is_empty():
continue
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
for tile in obstacle.tiles:
box = tile.bounding_box()
if not box.is_empty():
tree.insert(TreeShape(next(owner), -1, frozenset({obstacle.layer}), box))
# routed traces and vias: accept a net only if all its copper clears every
# already-accepted item exactly. A net that would violate is dropped, so the
# emitted geometry is DRC-clean by construction (coverage is best-effort).
via_layers = frozenset(signal_layers)
clean = RouteResult(half_width)
for net_no in sorted(set(result.wires) | set(result.vias)):
boxes: list[tuple[IntBox, frozenset[int]]] = []
for layer, points in result.wires.get(net_no, []):
for box in PolylineShape(Polyline(points), half_width).tiles():
boxes.append((box, frozenset({layer})))
for loc in result.vias.get(net_no, []):
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
boxes.append((vbox, via_layers))
conflict = any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
for layer in box_layers
)
if conflict:
continue # drop the net to preserve DRC-cleanliness
for box, box_layers in boxes:
tree.insert(TreeShape(next(owner), net_no, box_layers, box, routed=True))
clean.wires[net_no] = result.wires.get(net_no, [])
clean.vias[net_no] = result.vias.get(net_no, [])
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean)
def _counter():
i = 0
while True:
yield i
i += 1
+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()
+48 -9
View File
@@ -16,6 +16,7 @@ from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
from .exact_router import ExactRouteResult, route_board_exact
from .grid_router import RouteResult, route_board
__all__ = ["route", "route_dsn_board", "build_routing_result"]
@@ -69,12 +70,8 @@ def route_dsn_board(
return result, scale, [layer.name for layer in dsn.layers]
def build_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, rip_up: bool = True
) -> RoutingResult:
"""Route ``dsn`` and convert the board-unit paths + vias to a DSN-unit
:class:`~freeroute.ses.RoutingResult` for SES emission."""
route, scale, layer_names = route_dsn_board(dsn, layers=layers, rip_up=rip_up)
def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) -> RoutingResult:
"""Convert a board-unit :class:`RouteResult` to a DSN-unit RoutingResult."""
width_dsn = _rule_width_dsn(dsn)
via_name = _via_padstack(dsn)
# net_number is assigned in DSN order by build_board, so index i -> number i+1
@@ -98,8 +95,50 @@ def build_routing_result(
return result
def route(dsn_text: str, *, layers: list[int] | None = None) -> str:
"""Route a Specctra DSN string and return the routed SES string."""
def build_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, rip_up: bool = True
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board(dsn, layers=layers, rip_up=rip_up)
return _to_routing_result(route, dsn, scale, layer_names)
def route_dsn_board_exact(
dsn: DsnBoard, *, layers: list[int] | None = None, max_passes: int = 10
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact(
board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
max_passes=max_passes,
)
return exact, scale, [layer.name for layer in dsn.layers]
def build_exact_routing_result(dsn: DsnBoard, *, layers: list[int] | None = None) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(dsn, layers=layers)
return _to_routing_result(exact.result, dsn, scale, layer_names)
def route(dsn_text: str, *, layers: list[int] | None = None, engine: str = "grid") -> str:
"""Route a Specctra DSN string and return the routed SES string.
``engine`` selects the routing track: ``"grid"`` (default; the multi-layer
rip-up grid MVP — highest coverage, kept for backward compatibility) or
``"exact"`` (orthogonal, exact-geometry, DRC-verified — cleaner output where
it succeeds, but lower coverage on dense boards).
"""
dsn = parse_dsn(dsn_text)
result = build_routing_result(dsn, layers=layers)
if engine == "exact":
result = build_exact_routing_result(dsn, layers=layers)
else:
result = build_routing_result(dsn, layers=layers)
return write_ses(dsn, result)