From 4042f80b81a632ac3d52a57b90cf19df4411cd73 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 13 Jul 2026 10:27:10 -0600 Subject: [PATCH] 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=...). --- src/freeroute/route/pipeline.py | 18 +- src/freeroute/route/room_router.py | 410 +++++++++++++++++++++++++++-- 2 files changed, 395 insertions(+), 33 deletions(-) diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 65a76a1..bed9c76 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -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) diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py index 79731bc..882adcd 100644 --- a/src/freeroute/route/room_router.py +++ b/src/freeroute/route/room_router.py @@ -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": - gate = _edge_gate(door, pts[-1], half_width) + 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 boxes + + +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 - for box, box_layers in boxes: - tree.insert(TreeShape(next(owner), net_no, box_layers, box, routed=True)) + 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]: