Add shove-and-retry to the exact router

Before a dropped net is abandoned, the exact router (with shove=True)
tries to make room by moving an existing trace aside instead: it forms a
straight orthogonal candidate for the dropped net, finds the axis-aligned
trace segments that cross it, and shoves each one perpendicular (via
shove_segment) far enough to restore clearance. A shove is accepted only
if the moved trace still connects its pads, clears every other item
exactly against the ShapeSearchTree, and stays inside the board outline,
so DRC-cleanliness is preserved by construction. Shoves are bounded
(max_shove_depth, per-trace shove_cap) and deterministic. The dropped set
now includes nets the grid failed to route, not only exact-clearance
rejections, so shove can recover them.

The pipeline exposes shove (and rip_up) through route_dsn_board_exact /
build_exact_routing_result / route(engine='exact', shove=True); default
off, so the grid track and the no-shove exact track are unchanged.
This commit is contained in:
Ryan Malloy 2026-07-13 02:43:19 -06:00
parent dffa54a43a
commit f581f5b1e2
2 changed files with 345 additions and 40 deletions

View File

@ -7,31 +7,48 @@ 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.
intersection. A net whose copper would violate is dropped, so the output is
DRC-clean by construction.
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.
**Shove (opt-in, ``shove=True``).** Before a dropped net is abandoned, the
router tries to make room by *moving an existing trace aside* geometrically
(:func:`~freeroute.geometry.shove_segment`) rather than dropping the incoming
net. A shove is accepted only if the moved trace still connects its pads, clears
every other item exactly (via the tree), and stays in the board outline so
cleanliness is preserved. Shoves are bounded (``max_shove_depth`` and a
per-trace cap) and deterministic.
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).
This is an MVP shove for orthogonal traces: it recovers a straight-line net by
shoving the traces that cross it perpendicular. On this grid-based track its
extra reach over rip-up is limited (rip-up already reroutes); the larger density
win needs a continuous expansion-room router (the next gap). Also deferred:
45-degree / ``IntOctagon`` trace caps.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import IntBox, Polyline, PolylineShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment
from .grid_router import RouteResult, route_board
__all__ = ["ExactRouteResult", "route_board_exact"]
@dataclass
class _Accepted:
"""A committed net's per-net geometry and its tile owners in the tree."""
wires: list[tuple[int, list[IntPoint]]] = field(default_factory=list)
vias: list[IntPoint] = field(default_factory=list)
owners: list[int] = field(default_factory=list)
shove_count: int = 0
@dataclass
class ExactRouteResult:
"""The routed geometry plus the exact spatial index it was verified against."""
@ -40,6 +57,8 @@ class ExactRouteResult:
tree: ShapeSearchTree
#: ``True`` if the routed board has no different-net clearance violation
drc_clean: bool
#: number of successful shoves performed
shoves: int = 0
@property
def routed_net_numbers(self):
@ -57,14 +76,18 @@ def route_board_exact(
layers: list[int] | None = None,
via_cost: float = 10.0,
max_passes: int = 10,
rip_up: bool = True,
shove: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
) -> 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)
step = trace_width + clearance
# topology + geometry from the (orthogonal) grid search, incl. rip-up + vias
result = route_board(
board,
trace_width=trace_width,
@ -72,13 +95,14 @@ def route_board_exact(
layers=signal_layers,
via_cost=via_cost,
max_passes=max_passes,
rip_up=rip_up,
orthogonal=True,
)
tree = ShapeSearchTree()
owner = _counter()
# static items: pad and keepout copper
# static items: pad and keepout copper (routed=False)
for pin in board.get_pins():
if pin.shape is None:
continue
@ -97,35 +121,302 @@ def route_board_exact(
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)
accepted: dict[int, _Accepted] = {}
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(
boxes = _net_boxes(result, net_no, half_width, via_layers)
if 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
):
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = _Accepted(wires=result.wires.get(net_no, []), vias=result.vias.get(net_no, []))
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, [])
oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
acc.owners.append(oid)
accepted[net_no] = acc
# a net is dropped if it has a real ratsnest (>= 2 pins on a signal layer)
# but was not accepted — whether the grid failed to route it or its exact
# copper conflicted.
dropped = sorted(_routable_nets(board, signal_layers) - set(accepted))
shoves = 0
if shove and dropped:
outline = board.bounding_box
for net_no in dropped:
done = _shove_recover(
net_no,
board,
tree,
accepted,
owner,
signal_layers,
half_width,
clearance,
step,
outline,
max_shove_depth,
shove_cap,
)
if done:
shoves += done
clean = RouteResult(half_width)
for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean)
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves)
# --- shove recovery ----------------------------------------------------------
def _shove_recover(
net_no,
board,
tree,
accepted,
owner,
signal_layers,
half_width,
clearance,
step,
outline,
max_shove_depth,
shove_cap,
) -> int:
"""Try to route dropped net ``net_no`` by shoving crossing traces aside.
MVP: a straight orthogonal candidate for a 2-pin net, shoving each single-
segment trace that crosses it perpendicular until it clears. Returns the
number of successful shoves (0 if the net stays dropped).
"""
pins = _net_pins(board, net_no, signal_layers)
if len(pins) != 2:
return 0
a, b = pins
if a.location.x != b.location.x and a.location.y != b.location.y:
return 0 # MVP only recovers straight (axis-aligned) nets
layer = next(iter(set(a.layers) & set(b.layers) & set(signal_layers)), None)
if layer is None:
return 0
corners = [a.location, b.location]
d_boxes = PolylineShape(Polyline(corners), half_width).tiles()
# which accepted traces cross this net's straight copper?
blockers: dict[int, list[int]] = {}
for box in d_boxes:
for shape in tree.overlapping(box.offset(clearance)):
if not shape.routed or shape.net_no == net_no or layer not in shape.layers:
continue
if shape.tile.overlaps(box.offset(clearance)):
blockers.setdefault(shape.net_no, []).append(shape.owner)
if len(blockers) > max_shove_depth:
return 0
shoved: list[int] = []
for blocker_net in sorted(blockers):
acc = accepted.get(blocker_net)
if acc is None or acc.shove_count >= shove_cap:
return _rollback(shoved, accepted, tree, owner)
if not _try_shove_trace(
blocker_net,
corners,
layer,
board,
tree,
accepted,
owner,
signal_layers,
half_width,
clearance,
step,
outline,
):
return _rollback(shoved, accepted, tree, owner)
shoved.append(blocker_net)
# re-verify the dropped net is now clean, then accept it
if any(tree.clearance_conflict(box, net_no, layer, clearance) for box in d_boxes):
return _rollback(shoved, accepted, tree, owner)
acc = _Accepted(wires=[(layer, corners)], vias=[])
for box in d_boxes:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, frozenset({layer}), box, routed=True))
acc.owners.append(oid)
accepted[net_no] = acc
return len(shoved) + 1
def _try_shove_trace(
blocker_net,
d_corners,
layer,
board,
tree,
accepted,
owner,
signal_layers,
half_width,
clearance,
step,
outline,
) -> bool:
"""Shove ``blocker_net``'s trace clear of the straight net at ``d_corners``.
Only the single-segment straight-crossing case is handled (MVP). Tries
increasing perpendicular displacements away from the crossing net until the
moved trace clears everything exactly.
"""
acc = accepted[blocker_net]
if len(acc.wires) != 1:
return False
seg_layer, corners = acc.wires[0]
if seg_layer != layer:
return False
# find the blocker's axis-aligned segment (run) that conflicts with the
# crossing net, and the perpendicular displacements to clear it
d_boxes = PolylineShape(Polyline(d_corners), half_width).tiles()
seg_index, horizontal = _conflicting_segment(corners, d_boxes, half_width, clearance)
if seg_index is None:
return False
d0, d1 = d_corners[0], d_corners[1]
margin = 2 * half_width + clearance
fine = half_width + clearance
ref = corners[seg_index]
displacements: list[tuple[int, int]] = []
if horizontal: # displace in y; clear the crossing net's y-span
lo, hi = min(d0.y, d1.y), max(d0.y, d1.y)
for extra in range(6):
displacements.append((0, (hi + margin) - ref.y + extra * fine))
displacements.append((0, (lo - margin) - ref.y - extra * fine))
else: # displace in x
lo, hi = min(d0.x, d1.x), max(d0.x, d1.x)
for extra in range(6):
displacements.append(((hi + margin) - ref.x + extra * fine, 0))
displacements.append(((lo - margin) - ref.x - extra * fine, 0))
# remove the blocker's current tiles so its shoved copy can be verified alone
for oid in acc.owners:
tree.remove_owner(oid)
for dx, dy in displacements:
if dx == 0 and dy == 0:
continue
new_corners = shove_segment(list(corners), seg_index, dx, dy)
if _place_trace(
blocker_net,
seg_layer,
new_corners,
tree,
acc,
owner,
half_width,
clearance,
outline,
):
acc.wires = [(seg_layer, new_corners)]
acc.shove_count += 1
return True
# could not shove cleanly: restore the original tiles
_place_trace(
blocker_net,
seg_layer,
corners,
tree,
acc,
owner,
half_width,
clearance,
outline,
force=True,
)
return False
def _conflicting_segment(corners, d_boxes, half_width, clearance):
"""Index + orientation of the axis-aligned segment of ``corners`` that
conflicts with the crossing net's copper ``d_boxes``, or ``(None, False)``."""
for k in range(len(corners) - 1):
a, b = corners[k], corners[k + 1]
horizontal = a.y == b.y
if not horizontal and a.x != b.x:
continue # skip diagonal grid stubs
from freeroute.geometry import segment_box
seg = segment_box(a, b, half_width).offset(clearance)
if any(seg.overlaps(box) for box in d_boxes):
return k, horizontal
return None, False
def _place_trace(
net_no, layer, corners, tree, acc, owner, half_width, clearance, outline, force=False
) -> bool:
"""Verify + insert a trace's copper; ``force`` skips the DRC/outline check."""
boxes = PolylineShape(Polyline(corners), half_width).tiles()
if not force:
for box in boxes:
if not outline.contains_box(box):
return False
if tree.clearance_conflict(box, net_no, layer, clearance):
return False
acc.owners = []
for box in boxes:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, frozenset({layer}), box, routed=True))
acc.owners.append(oid)
return True
def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001
# shoved traces were verified individually; nothing else to undo for the MVP
return 0
# --- helpers -----------------------------------------------------------------
def _net_boxes(result, net_no, half_width, via_layers):
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))
return boxes
def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]:
return [
pin
for pin in board.get_pins()
if net_no in pin.net_nos and any(layer in signal_layers for layer in pin.layers)
]
def _routable_nets(board: BasicBoard, signal_layers) -> set[int]:
"""Net numbers with at least two pins on a signal layer (a real ratsnest)."""
counts: dict[int, int] = {}
for pin in board.get_pins():
if not any(layer in signal_layers for layer in pin.layers):
continue
for net_no in pin.net_nos:
counts[net_no] = counts.get(net_no, 0) + 1
return {net_no for net_no, c in counts.items() if c >= 2}
def _counter():

View File

@ -104,10 +104,17 @@ def build_routing_result(
def route_dsn_board_exact(
dsn: DsnBoard, *, layers: list[int] | None = None, max_passes: int = 10
dsn: DsnBoard,
*,
layers: list[int] | None = None,
max_passes: int = 10,
rip_up: bool = True,
shove: bool = False,
) -> 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."""
DRC-verified) track; return the exact result, the scale, and layer names.
``shove`` enables moving existing traces aside to recover dropped nets;
``rip_up`` toggles the underlying grid's rip-up-and-retry."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
width_board = round(_rule_width_dsn(dsn) * scale)
@ -118,27 +125,34 @@ def route_dsn_board_exact(
clearance=clearance_board,
layers=layers,
max_passes=max_passes,
rip_up=rip_up,
shove=shove,
)
return exact, scale, [layer.name for layer in dsn.layers]
def build_exact_routing_result(dsn: DsnBoard, *, layers: list[int] | None = None) -> RoutingResult:
def build_exact_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(dsn, layers=layers)
exact, scale, layer_names = route_dsn_board_exact(dsn, layers=layers, shove=shove)
return _to_routing_result(exact.result, dsn, scale, layer_names)
def route(dsn_text: str, *, layers: list[int] | None = None, engine: str = "grid") -> str:
def route(
dsn_text: str, *, layers: list[int] | None = None, engine: str = "grid", shove: bool = False
) -> 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).
it succeeds). ``shove`` (exact engine only) tries moving existing traces
aside to recover dropped nets.
"""
dsn = parse_dsn(dsn_text)
if engine == "exact":
result = build_exact_routing_result(dsn, layers=layers)
result = build_exact_routing_result(dsn, layers=layers, shove=shove)
else:
result = build_routing_result(dsn, layers=layers)
return write_ses(dsn, result)