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:
2026-07-13 02:43:19 -06:00
parent dffa54a43a
commit f581f5b1e2
2 changed files with 345 additions and 40 deletions
+324 -33
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():
+21 -7
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)