Add gate-optimal placement and shove-in-rooms to the room router

Both upgrades sit behind an opt-in shove=False flag, so the default code
path is byte-for-byte the foundation router (existing room tests are
unchanged by construction).

With shove=True:

- Gate placement becomes occupancy-aware: each edge gate is projected
  into the largest other-net-free sub-span of the door, with the reserved
  intervals read straight from the search tree. With margin = clearance +
  half_width the room decomposition already keeps doors clearance-clear,
  so this reduces exactly to the old midpoint clamp where no other-net
  copper crosses the door.

- When a net would be dropped, a transactional shove-in-rooms recovery
  nudges the committed trace(s) blocking it aside with the exact shove
  primitive and re-verifies against the clearance oracle, so both nets fit
  through one channel. Every moved trace is journaled and rolled back to
  its exact original tiles and wires on any failure -- the room engine
  mutates already-committed nets, so the rollback is real rather than the
  exact track's no-op.

The board-wide has_violation(clearance) gate stays the final assertion.
Thread the flag through pipeline.route_dsn_board_rooms /
build_rooms_routing_result / route(engine='room', shove=...).
This commit is contained in:
Ryan Malloy 2026-07-13 10:27:10 -06:00
parent e67a11c833
commit 4042f80b81
2 changed files with 395 additions and 33 deletions

View File

@ -141,23 +141,26 @@ def build_exact_routing_result(
def route_dsn_board_rooms(
dsn: DsnBoard, *, layers: list[int] | None = None
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the continuous expansion-room track;
return the result, the scale, and layer names."""
return the result, the scale, and layer names. ``shove`` enables occupancy-
aware gate placement and shove-in-rooms recovery of otherwise-dropped nets."""
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)
rooms = route_board_rooms(
board, trace_width=width_board, clearance=clearance_board, layers=layers
board, trace_width=width_board, clearance=clearance_board, layers=layers, shove=shove
)
return rooms, scale, [layer.name for layer in dsn.layers]
def build_rooms_routing_result(dsn: DsnBoard, *, layers: list[int] | None = None) -> RoutingResult:
def build_rooms_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False
) -> RoutingResult:
"""Route ``dsn`` (room track) and convert to a DSN-unit RoutingResult."""
rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers)
rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers, shove=shove)
return _to_routing_result(rooms.result, dsn, scale, layer_names)
@ -171,13 +174,14 @@ def route(
``"exact"`` (orthogonal, exact-geometry, DRC-verified cleaner output where
it succeeds), or ``"room"`` (continuous expansion-room exact free-space
decomposition, routes off-grid channels the grid/exact tracks cannot).
``shove`` (exact engine only) tries moving existing traces aside.
``shove`` (exact and room engines) tries moving existing traces aside; on the
room engine it also turns on occupancy-aware gate placement.
"""
dsn = parse_dsn(dsn_text)
if engine == "exact":
result = build_exact_routing_result(dsn, layers=layers, shove=shove)
elif engine == "room":
result = build_rooms_routing_result(dsn, layers=layers)
result = build_rooms_routing_result(dsn, layers=layers, shove=shove)
else:
result = build_routing_result(dsn, layers=layers)
return write_ses(dsn, result)

View File

@ -33,14 +33,51 @@ import heapq
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, IntPoint, Polyline, PolylineShape
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape, shove_segment
from .exact_router import ExactRouteResult, _routable_nets
from .grid_router import RouteResult
from .shove import (
conflicting_segment,
force_place_owners,
place_trace_owners,
shove_displacements,
trace_tiles,
)
__all__ = ["route_board_rooms"]
@dataclass
class _Accepted:
"""A committed net's per-wire tile owners, for occupancy-aware placement and
the journaled shove rollback.
``wires`` / ``vias`` are the *same* list objects as ``RouteResult.wires`` /
``.vias`` for this net, so rewriting a shoved wire in place also updates the
routed output. ``wire_owners`` is parallel to ``wires``: each entry is the
list of :class:`~freeroute.board.search_tree.TreeShape` owner ids for that
wire's copper tiles, so a later shove can locate, remove, and roll back a
specific wire without disturbing a multi-wire net's other connections.
"""
wires: list[tuple[int, list[IntPoint]]]
vias: list[IntPoint]
wire_owners: list[list[int]] = field(default_factory=list)
via_owners: list[int] = field(default_factory=list)
shove_count: int = 0
@dataclass
class _ShoveEntry:
"""One moved blocker wire, enough to restore it exactly on rollback."""
blocker_net: int
acc: _Accepted
wire_index: int
old_wire: tuple[int, list[IntPoint]]
@dataclass(frozen=True)
class _Room:
"""A convex free-space rectangle on one layer."""
@ -245,11 +282,17 @@ def _dist(a: IntPoint, b: IntPoint) -> float:
# --- path realization --------------------------------------------------------
def _realize(graph, seq, start: IntPoint, goal: IntPoint, half_width):
def _realize(graph, seq, start: IntPoint, goal: IntPoint, half_width, occupancy=None):
"""Turn a room sequence into per-layer orthogonal wire segments + vias.
Routes through each door's mid-gate with an L-connector, splitting the path
at via doors. Returns ``(wires, vias)`` in board units or ``None``.
Routes through each door's gate with an L-connector, splitting the path at
via doors. Returns ``(wires, vias)`` in board units or ``None``.
``occupancy`` (``None`` by default) enables occupancy-aware gate placement:
when it is ``(tree, net_no, clearance)`` each edge gate is projected into the
largest other-net-free sub-span of the door instead of the raw midpoint. With
no other-net routed copper on the door this reduces exactly to the midpoint
clamp, so the ``occupancy is None`` path stays byte-for-byte the old router.
"""
wires: list[tuple[int, list[IntPoint]]] = []
vias: list[IntPoint] = []
@ -261,7 +304,11 @@ def _realize(graph, seq, start: IntPoint, goal: IntPoint, half_width):
if door is None:
return None
if door.kind == "edge":
if occupancy is None:
gate = _edge_gate(door, pts[-1], half_width)
else:
tree, net_no, clearance = occupancy
gate = _project_gate(door, pts[-1], half_width, clearance, tree, net_no, layer)
pts.append(gate)
else: # via: close the current-layer wire, drop a via, start next layer
gate = _via_gate(door, pts[-1], half_width)
@ -296,6 +343,88 @@ def _via_gate(door: _Door, from_pt: IntPoint, half_width: int) -> IntPoint:
return IntPoint(x, y)
def _project_gate(
door: _Door, from_pt: IntPoint, half_width: int, clearance: int, tree, net_no: int, layer: int
) -> IntPoint:
"""An occupancy-aware gate point on an edge door.
Ports ``LocateFoundConnectionAlgo``'s projection of the current from-point
onto the door shrunk by half the trace width, but subtracts the intervals
already reserved by other-net routed copper crossing the door (the
``ExpansionDoor`` section reservations, read straight from the search tree).
The trace is seated in the largest remaining free sub-span, at the point
nearest the ideal projection (tie-break: lowest coordinate). With no other-net
routed tile on the door this returns exactly :func:`_edge_gate`'s clamp.
"""
if door.lo.x == door.hi.x: # vertical edge -> choose the y coordinate
axis_lo, axis_hi, vertical = door.lo.y, door.hi.y, True
ideal = from_pt.y
else: # horizontal edge -> choose the x coordinate
axis_lo, axis_hi, vertical = door.lo.x, door.hi.x, False
ideal = from_pt.x
lo = axis_lo + half_width
hi = axis_hi - half_width
if lo > hi: # door too narrow for the trace: same midpoint fallback as _clamp
value = (axis_lo + axis_hi) // 2
else:
ideal = max(lo, min(hi, ideal))
forbidden = _door_forbidden_intervals(
door, half_width, clearance, tree, net_no, layer, vertical
)
value = _best_free_point(lo, hi, forbidden, ideal)
return IntPoint(door.lo.x, value) if vertical else IntPoint(value, door.lo.y)
def _door_forbidden_intervals(door, half_width, clearance, tree, net_no, layer, vertical):
"""Axis intervals a trace centre must avoid: one per other-net routed tile
crossing the door edge, widened by ``clearance + half_width`` on each side."""
edge_box = IntBox(door.lo.x, door.lo.y, door.hi.x, door.hi.y)
query = edge_box.offset(clearance + half_width)
reach = clearance + half_width
forbidden: list[tuple[int, int]] = []
for shape in tree.overlapping(query):
if not shape.routed or shape.net_no == net_no or layer not in shape.layers:
continue
tile = shape.tile
if vertical:
forbidden.append((tile.ll.y - reach, tile.ur.y + reach))
else:
forbidden.append((tile.ll.x - reach, tile.ur.x + reach))
return forbidden
def _best_free_point(lo: int, hi: int, forbidden, ideal: int) -> int:
"""The point in the largest free sub-interval of ``[lo, hi]`` nearest ``ideal``.
Free sub-intervals are ``[lo, hi]`` minus the (open) ``forbidden`` intervals;
the largest is chosen (tie-break: lowest coordinate) so a trace leaves the
most room for the next one, then clamped toward ``ideal`` inside it.
"""
free = _free_intervals(lo, hi, forbidden)
if not free:
return max(lo, min(hi, ideal))
a, b = max(free, key=lambda iv: (iv[1] - iv[0], -iv[0]))
return max(a, min(b, ideal))
def _free_intervals(lo: int, hi: int, forbidden) -> list[tuple[int, int]]:
"""Closed integer sub-intervals of ``[lo, hi]`` outside every open forbidden
interval. Interval boundaries stay free: a centre exactly ``clearance`` away
from a tile is allowed by the exact clearance rule."""
free: list[tuple[int, int]] = []
cur = lo
for f_lo, f_hi in sorted(forbidden):
seg_hi = min(f_lo, hi)
if cur <= hi and seg_hi >= cur:
free.append((cur, seg_hi))
cur = max(cur, min(f_hi, hi))
if cur >= hi:
break
if cur <= hi:
free.append((cur, hi))
return free
def _orthogonalize(points: list[IntPoint]) -> list[IntPoint]:
"""Connect gate points with axis-aligned segments (L-shaped between each)."""
out = [points[0]]
@ -338,8 +467,21 @@ def route_board_rooms(
clearance: int,
layers: list[int] | None = None,
via_cost: float = 50000.0,
shove: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
) -> ExactRouteResult:
"""Route ``board`` with the continuous expansion-room track (foundation)."""
"""Route ``board`` with the continuous expansion-room track.
``shove`` (opt-in) turns on two continuous, sub-cell upgrades that the grid
could not do: occupancy-aware gate placement (seat each trace at the best
point in a door, not its midpoint) and shove-in-rooms (when a net would be
dropped, nudge the blocking committed trace(s) aside with the exact shove
primitive and re-route, rolling every moved trace back on any failure). With
``shove=False`` every new branch is skipped and the output is byte-for-byte
the foundation router. ``max_shove_depth`` bounds the distinct blocker nets
per recovery; ``shove_cap`` bounds how often one trace may be moved.
"""
signal_layers = layers or [
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
]
@ -369,10 +511,12 @@ def route_board_rooms(
result = RouteResult(half_width)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
shoves = 0
for net_no in sorted(_routable_nets(board, signal_layers)):
pins = _net_pins(board, net_no, signal_layers)
for a, b in zip(pins, pins[1:], strict=False):
_route_connection(
shoves += _route_connection(
net_no,
a,
b,
@ -387,10 +531,14 @@ def route_board_rooms(
trace_width,
via_cost,
via_layers,
shove,
accepted,
max_shove_depth,
shove_cap,
)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean)
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves)
def _route_connection(
@ -408,6 +556,10 @@ def _route_connection(
trace_width,
via_cost,
via_layers,
shove=False,
accepted=None,
max_shove_depth=5,
shove_cap=3,
):
rooms_by_layer = {
layer: _free_rooms(tree, outline, layer, net_no, margin, half_width)
@ -418,41 +570,247 @@ def _route_connection(
b_layers = [layer for layer in b.layers if layer in signal_layers]
starts = _rooms_containing(graph, a.location, a_layers)
goals = _rooms_containing(graph, b.location, b_layers)
if not starts or not goals:
return
seq = _search_rooms(graph, starts, set(goals), b.location, via_cost)
if seq is None:
return
realized = _realize(graph, seq, a.location, b.location, half_width)
if realized is None:
return
wires, vias = realized
# verify every piece is exactly DRC-clean before committing
realized = None
if starts and goals:
seq = _search_rooms(graph, starts, set(goals), b.location, via_cost)
if seq is not None:
occupancy = (tree, net_no, clearance) if shove else None
realized = _realize(graph, seq, a.location, b.location, half_width, occupancy)
if realized is not None:
wires, vias = realized
boxes = _collect_boxes(wires, vias, half_width, via_layers, outline)
if boxes is not None and not any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
for layer in box_layers
):
_commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted)
return 0
# the net would be dropped: try a transactional shove-in-rooms recovery
if shove:
return _shove_recover_room(
net_no,
a,
b,
tree,
result,
accepted,
owner,
outline,
signal_layers,
half_width,
clearance,
max_shove_depth,
shove_cap,
)
return 0
def _collect_boxes(wires, vias, half_width, via_layers, outline):
"""The ``(box, layers)`` copper tiles of a realized route, or ``None`` if any
tile leaves the board outline (the same early-out as the old inline check)."""
boxes: list[tuple[IntBox, frozenset[int]]] = []
for layer, corners in wires:
for box in PolylineShape(Polyline(corners), half_width).tiles():
if not outline.contains_box(box):
return
return None
boxes.append((box, frozenset({layer})))
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
boxes.append((vbox, via_layers))
if any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
for layer in box_layers
):
return
return boxes
for box, box_layers in boxes:
tree.insert(TreeShape(next(owner), net_no, box_layers, box, routed=True))
def _commit(net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted):
"""Insert a verified route's copper and record it in the routed output.
With ``shove=False`` this is the foundation router's exact commit sequence.
With ``shove=True`` the tiles are additionally grouped per wire into the
``accepted`` registry so a later recovery can move or roll back one wire.
"""
if not shove:
for layer, corners in wires:
for box in PolylineShape(Polyline(corners), half_width).tiles():
tree.insert(TreeShape(next(owner), net_no, frozenset({layer}), box, routed=True))
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
tree.insert(TreeShape(next(owner), net_no, via_layers, vbox, routed=True))
for layer, corners in wires:
result.add_wire(net_no, layer, corners)
for loc in vias:
result.add_via(net_no, loc)
return
acc = accepted.get(net_no)
if acc is None:
acc = _Accepted(
wires=result.wires.setdefault(net_no, []), vias=result.vias.setdefault(net_no, [])
)
accepted[net_no] = acc
for layer, corners in wires:
w_owners: list[int] = []
for box in PolylineShape(Polyline(corners), half_width).tiles():
oid = next(owner)
tree.insert(TreeShape(oid, net_no, frozenset({layer}), box, routed=True))
w_owners.append(oid)
result.add_wire(net_no, layer, corners)
acc.wire_owners.append(w_owners)
for loc in vias:
vbox = IntBox(
loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width
)
oid = next(owner)
tree.insert(TreeShape(oid, net_no, via_layers, vbox, routed=True))
result.add_via(net_no, loc)
acc.via_owners.append(oid)
# --- shove-in-rooms recovery -------------------------------------------------
def _shove_recover_room(
net_no,
a,
b,
tree,
result,
accepted,
owner,
outline,
signal_layers,
half_width,
clearance,
max_shove_depth,
shove_cap,
) -> int:
"""Recover a dropped net by nudging the committed trace(s) that block it.
MVP (mirrors the exact track): a straight, axis-aligned 2-pin net on one
signal layer. Its straight copper is the corridor; every routed different-net
trace crossing it is shoved perpendicular with the shared exact primitive and
re-verified against the tree, then the net is placed straight. A static tile
(pad/keepout) in the corridor, or a blocker that will not move cleanly, aborts
the whole attempt and rolls every moved trace back to its exact original
tiles and wires. Returns the number of successful moves (0 = still dropped).
"""
if a.location.x != b.location.x and a.location.y != b.location.y:
return 0 # only straight nets in this MVP
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 = trace_tiles(corners, half_width)
blockers: dict[int, set[int]] = {}
for box in d_boxes:
expanded = box.offset(clearance)
for shape in tree.overlapping(expanded):
if shape.net_no == net_no or layer not in shape.layers:
continue
if not shape.tile.overlaps(expanded):
continue
if not shape.routed:
return 0 # a pad or keepout sits in the corridor: cannot shove it
blockers.setdefault(shape.net_no, set()).add(shape.owner)
if not blockers or len(blockers) > max_shove_depth:
return 0
journal: list[_ShoveEntry] = []
for blocker_net in sorted(blockers):
acc = accepted.get(blocker_net) if accepted is not None else None
if acc is None or acc.shove_count >= shove_cap:
_rollback_room(journal, tree, owner, half_width)
return 0
if not _shove_blocker(
blocker_net, acc, corners, layer, tree, owner, half_width, clearance, outline, journal
):
_rollback_room(journal, tree, owner, half_width)
return 0
# the corridor must now be clean and inside the outline before we commit it
if any(not outline.contains_box(box) for box in d_boxes) or any(
tree.clearance_conflict(box, net_no, layer, clearance) for box in d_boxes
):
_rollback_room(journal, tree, owner, half_width)
return 0
owners = force_place_owners(net_no, layer, corners, tree, owner, half_width)
acc = _Accepted(
wires=result.wires.setdefault(net_no, []), vias=result.vias.setdefault(net_no, [])
)
result.add_wire(net_no, layer, corners)
acc.wire_owners.append(owners)
accepted[net_no] = acc
return len(journal) + 1
def _shove_blocker(
blocker_net, acc, d_corners, layer, tree, owner, half_width, clearance, outline, journal
) -> bool:
"""Move ``blocker_net``'s conflicting wire clear of the corridor ``d_corners``.
Finds the single wire of the blocker whose axis-aligned run crosses the
corridor, removes its tiles, and walks the perpendicular displacement ladder
until the reshaped wire places cleanly (outline + exact clearance). On success
the move is journaled for rollback; on failure the wire's original tiles are
restored and ``False`` is returned.
"""
d_boxes = trace_tiles(d_corners, half_width)
for wire_index, (seg_layer, corners) in enumerate(acc.wires):
if seg_layer != layer:
continue
seg_index, horizontal = conflicting_segment(corners, d_boxes, half_width, clearance)
if seg_index is None:
continue
old_owners = list(acc.wire_owners[wire_index])
old_wire = (seg_layer, list(corners))
for oid in old_owners:
tree.remove_owner(oid)
for dx, dy in shove_displacements(
corners, seg_index, horizontal, d_corners, half_width, clearance
):
if dx == 0 and dy == 0:
continue
new_corners = shove_segment(list(corners), seg_index, dx, dy)
new_owners = place_trace_owners(
blocker_net, seg_layer, new_corners, tree, owner, half_width, clearance, outline
)
if new_owners is not None:
journal.append(_ShoveEntry(blocker_net, acc, wire_index, old_wire))
acc.wires[wire_index] = (seg_layer, new_corners)
acc.wire_owners[wire_index] = new_owners
acc.shove_count += 1
return True
# no clean displacement: restore the wire's original tiles and give up
acc.wire_owners[wire_index] = force_place_owners(
blocker_net, seg_layer, list(corners), tree, owner, half_width
)
return False
return False
def _rollback_room(journal, tree, owner, half_width) -> int:
"""Undo every journaled shove, restoring exact original tiles and wires."""
for entry in reversed(journal):
for oid in entry.acc.wire_owners[entry.wire_index]:
tree.remove_owner(oid)
layer, old_corners = entry.old_wire
entry.acc.wire_owners[entry.wire_index] = force_place_owners(
entry.blocker_net, layer, old_corners, tree, owner, half_width
)
entry.acc.wires[entry.wire_index] = (layer, old_corners)
entry.acc.shove_count -= 1
return 0
def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]: