Files
rpmroute/src/freeroute/route/room_router.py
T
rsp2k 8a50bde531 Read the DSN's wiring as fixed copper of its nets
Tracks and vias already on the board were ignored, so other nets could
be routed straight through them (the flight controller's 82 GND
stitching vias and its guard tracks). They are now copper of their net:
other nets keep a pad's distance from them in the grid, the exact check
and the room engine, and they add no connections to route.
2026-09-28 21:06:35 -06:00

1221 lines
45 KiB
Python

"""Continuous expansion-room routing track (foundation).
The grid and exact tracks quantize space into fixed ``width + clearance`` cells.
This track replaces that grid with FreeRouting's idea of **free-space rooms**:
per layer, the space *not* occupied by items (inflated by the routing clearance)
is decomposed into exact convex tiles (here axis-aligned :class:`IntBox` rooms
via coordinate compression, the orthogonal analogue of
``CompleteFreeSpaceExpansionRoom``). Adjacent rooms share a **door** on their
common edge (and a **via door** joins the same footprint on two layers); the
maze search is an A* over rooms-through-doors, so a path may cross a door at any
point along its width — continuous, not cell-quantized. The found room sequence
is realized into an exact orthogonal :class:`Polyline` and verified DRC-clean
against the :class:`ShapeSearchTree`, exactly like the exact track.
**Foundation scope (this is large — the tail is deferred):** rooms are
axis-aligned rectangles (not general ``Simplex`` tiles); path realization routes
through door mid-gates with an L-connector and drops a net if the realized trace
cannot be made exactly clean. Any-angle rooms, optimal gate placement (true
sub-cell shove), and optimization passes are follow-ups. The grid and exact
tracks are unchanged; this is opt-in via ``engine="room"``.
Maps to FreeRouting: rooms ← ``ExpansionRoom`` /
``CompleteFreeSpaceExpansionRoom``; doors ← ``ExpansionDoor`` /
``TargetItemExpansionDoor``; search ← ``MazeSearchAlgo`` / ``AutorouteEngine``;
realization ← ``LocateFoundConnectionAlgo``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
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, 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."""
id: int
layer: int
box: IntBox
def center(self) -> IntPoint:
return IntPoint((self.box.ll.x + self.box.ur.x) // 2, (self.box.ll.y + self.box.ur.y) // 2)
@dataclass
class _Door:
"""A passage between two rooms: a shared edge, or a via between layers."""
other: int # neighbouring room id
kind: str # "edge" | "via"
# gate geometry: for an edge door the shared segment, for a via the overlap box
lo: IntPoint
hi: IntPoint
@dataclass
class _Graph:
rooms: dict[int, _Room] = field(default_factory=dict)
doors: dict[int, list[_Door]] = field(default_factory=dict)
by_layer: dict[int, list[int]] = field(default_factory=dict)
# --- free-space decomposition ------------------------------------------------
def _free_rooms(
tree: ShapeSearchTree, outline: IntBox, layer: int, net_no: int, margin: int, half_width: int
) -> list[IntBox]:
"""Decompose the centreline-free space on ``layer`` into rectangles.
Obstacles are every other-net tile on this layer, inflated by ``margin``
(clearance + half-width) so a centreline inside a room keeps the trace clear.
The routing region is the outline shrunk by ``half_width``.
"""
region = outline.offset(-half_width)
if region.is_empty():
return []
obstacles: list[IntBox] = []
for shape in tree.all_shapes():
if net_no in (shape.net_no,) or layer not in shape.layers:
continue
infl = shape.tile.offset(margin)
clipped = infl.intersection(region)
if not clipped.is_empty() and clipped.dimension() == 2:
obstacles.append(clipped)
xs = sorted(
{region.ll.x, region.ur.x} | {o.ll.x for o in obstacles} | {o.ur.x for o in obstacles}
)
ys = sorted(
{region.ll.y, region.ur.y} | {o.ll.y for o in obstacles} | {o.ur.y for o in obstacles}
)
rooms: list[IntBox] = []
for j in range(len(ys) - 1):
y0, y1 = ys[j], ys[j + 1]
if y1 <= y0:
continue
run: IntBox | None = None
for i in range(len(xs) - 1):
x0, x1 = xs[i], xs[i + 1]
if x1 <= x0:
continue
cx, cy = (x0 + x1) // 2, (y0 + y1) // 2
blocked = any(o.contains_inside(IntPoint(cx, cy)) for o in obstacles)
if blocked:
if run is not None:
rooms.append(run)
run = None
continue
cell = IntBox(x0, y0, x1, y1)
run = cell if run is None else run.union(cell) # merge along x
if run is not None:
rooms.append(run)
return rooms
# --- doors -------------------------------------------------------------------
def _build_graph(rooms_by_layer: dict[int, list[IntBox]], trace_width: int) -> _Graph:
graph = _Graph()
next_id = 0
layer_rooms: dict[int, list[_Room]] = {}
for layer, boxes in rooms_by_layer.items():
layer_rooms[layer] = []
for box in boxes:
room = _Room(next_id, layer, box)
graph.rooms[room.id] = room
graph.doors[room.id] = []
graph.by_layer.setdefault(layer, []).append(room.id)
layer_rooms[layer].append(room)
next_id += 1
# same-layer edge doors (shared edge with overlap >= trace_width)
for rooms in layer_rooms.values():
for i in range(len(rooms)):
a = rooms[i]
for j in range(i + 1, len(rooms)):
b = rooms[j]
door = _edge_door(a.box, b.box, trace_width)
if door is not None:
lo, hi = door
graph.doors[a.id].append(_Door(b.id, "edge", lo, hi))
graph.doors[b.id].append(_Door(a.id, "edge", lo, hi))
# via doors: overlapping rooms on different layers
layers = sorted(layer_rooms)
for li in range(len(layers)):
for lj in range(li + 1, len(layers)):
for a in layer_rooms[layers[li]]:
for b in layer_rooms[layers[lj]]:
inter = a.box.intersection(b.box)
if not inter.is_empty() and inter.min_width() >= trace_width:
lo = IntPoint(inter.ll.x, inter.ll.y)
hi = IntPoint(inter.ur.x, inter.ur.y)
graph.doors[a.id].append(_Door(b.id, "via", lo, hi))
graph.doors[b.id].append(_Door(a.id, "via", lo, hi))
return graph
def _edge_door(a: IntBox, b: IntBox, trace_width: int):
"""Shared edge segment of two boxes if they abut with enough overlap."""
if a.ur.x == b.ll.x or b.ur.x == a.ll.x: # vertical shared edge
x = a.ur.x if a.ur.x == b.ll.x else a.ll.x
lo_y = max(a.ll.y, b.ll.y)
hi_y = min(a.ur.y, b.ur.y)
if hi_y - lo_y >= trace_width:
return IntPoint(x, lo_y), IntPoint(x, hi_y)
if a.ur.y == b.ll.y or b.ur.y == a.ll.y: # horizontal shared edge
y = a.ur.y if a.ur.y == b.ll.y else a.ll.y
lo_x = max(a.ll.x, b.ll.x)
hi_x = min(a.ur.x, b.ur.x)
if hi_x - lo_x >= trace_width:
return IntPoint(lo_x, y), IntPoint(hi_x, y)
return None
# --- room A* -----------------------------------------------------------------
def _rooms_containing(graph: _Graph, point: IntPoint, layers) -> list[int]:
return [
rid
for layer in layers
for rid in graph.by_layer.get(layer, [])
if graph.rooms[rid].box.contains(point)
]
def _search_rooms(graph: _Graph, starts, goals, goal_pt, via_cost):
goal_set = set(goals)
if not goal_set:
return None
open_heap: list[tuple[float, int]] = []
g_score: dict[int, float] = {}
came: dict[int, tuple[int, _Door]] = {}
for s in sorted(starts):
g_score[s] = 0.0
heapq.heappush(open_heap, (_dist(graph.rooms[s].center(), goal_pt), s))
while open_heap:
_, cur = heapq.heappop(open_heap)
if cur in goal_set:
return _reconstruct_rooms(came, cur)
base = g_score[cur]
c_center = graph.rooms[cur].center()
for door in graph.doors[cur]:
step = _dist(c_center, graph.rooms[door.other].center())
if door.kind == "via":
step += via_cost
tentative = base + step
if tentative < g_score.get(door.other, float("inf")):
came[door.other] = (cur, door)
g_score[door.other] = tentative
h = _dist(graph.rooms[door.other].center(), goal_pt)
heapq.heappush(open_heap, (tentative + h, door.other))
return None
def _reconstruct_rooms(came, cur):
seq = [(cur, None)]
while cur in came:
prev, door = came[cur]
seq.append((prev, door))
cur = prev
seq.reverse()
return seq # list of (room_id, door_used_to_enter_or_None_for_start)
def _dist(a: IntPoint, b: IntPoint) -> float:
return abs(a.x - b.x) + abs(a.y - b.y)
# --- path realization --------------------------------------------------------
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 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] = []
layer = graph.rooms[seq[0][0]].layer
pts = [start]
# seq[i] carries the door from room i to room i+1 (seq[-1] has None)
for i in range(len(seq) - 1):
door = seq[i][1]
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)
pts.append(gate)
wires.append((layer, list(pts)))
vias.append(gate)
layer = graph.rooms[door.other].layer
pts = [gate]
pts.append(goal)
wires.append((layer, pts))
# expand each polyline of gate points into orthogonal L-connected corners
ortho: list[tuple[int, list[IntPoint]]] = []
for lyr, poly in wires:
corners = _orthogonalize(poly)
if len(corners) >= 2:
ortho.append((lyr, corners))
return ortho, vias
def _edge_gate(door: _Door, from_pt: IntPoint, half_width: int) -> IntPoint:
"""A point on the door edge, kept >= half_width from the door ends."""
if door.lo.x == door.hi.x: # vertical edge -> pick y
y = _clamp(from_pt.y, door.lo.y + half_width, door.hi.y - half_width)
return IntPoint(door.lo.x, y)
x = _clamp(from_pt.x, door.lo.x + half_width, door.hi.x - half_width)
return IntPoint(x, door.lo.y)
def _via_gate(door: _Door, from_pt: IntPoint, half_width: int) -> IntPoint:
x = _clamp(from_pt.x, door.lo.x + half_width, door.hi.x - half_width)
y = _clamp(from_pt.y, door.lo.y + half_width, door.hi.y - half_width)
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]]
for p in points[1:]:
last = out[-1]
if last.x != p.x and last.y != p.y:
out.append(IntPoint(p.x, last.y)) # horizontal then vertical
if not (out[-1].x == p.x and out[-1].y == p.y):
out.append(p)
return _dedupe(out)
def _dedupe(points: list[IntPoint]) -> list[IntPoint]:
out: list[IntPoint] = []
for p in points:
if out and out[-1].x == p.x and out[-1].y == p.y:
continue
if len(out) >= 2:
a, b = out[-2], out[-1]
if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0:
out[-1] = p
continue
out.append(p)
return out
def _clamp(v: int, lo: int, hi: int) -> int:
if lo > hi:
return (lo + hi) // 2
return max(lo, min(hi, v))
# --- orchestration -----------------------------------------------------------
def route_board_rooms(
board: BasicBoard,
*,
trace_width: int,
clearance: int,
layers: list[int] | None = None,
via_cost: float = 50000.0,
shove: bool = False,
pack: bool = False,
max_shove_depth: int = 5,
shove_cap: int = 3,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> ExactRouteResult:
"""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.
``pack`` (opt-in, independent of ``shove``) runs the coordinated channel
packer first: groups of 2-pin nets that must cross a common obstacle gap are
assigned consistent parallel lanes across the gap and routed together, so a
channel that is physically wide enough for all of them does not drop nets to
greedy per-net gate collisions. With ``pack=False`` the pre-pass is skipped
and the output is unchanged.
"""
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)
# half the via pad's size: a via is not a trace, and sizing it like one
# let routes pass within a via's real copper
via_half = via_half or half_width
edge = (board.outline, 0 if edge_clearance is None else edge_clearance)
margin = clearance + half_width
outline = board.bounding_box
tree = ShapeSearchTree()
owner = _counter()
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 net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
for tile in obstacle.tiles:
b = tile.bounding_box()
if not b.is_empty():
tree.insert(TreeShape(next(owner), -1, frozenset({obstacle.layer}), b))
result = RouteResult(half_width)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
shoves = 0
packed: set[int] = set()
if pack:
packed = _pack_channels(
board,
tree,
result,
owner,
outline,
signal_layers,
half_width,
clearance,
margin,
trace_width,
via_layers,
accepted,
shove,
via_half=via_half, edge=edge)
for net_no in sorted(_routable_nets(board, signal_layers)):
if net_no in packed:
continue
pins = _net_pins(board, net_no, signal_layers)
for a, b in zip(pins, pins[1:], strict=False):
shoves += _route_connection(
net_no,
a,
b,
tree,
result,
owner,
outline,
signal_layers,
half_width,
clearance,
margin,
trace_width,
via_cost,
via_layers,
shove,
accepted,
max_shove_depth,
shove_cap,
via_half=via_half, edge=edge)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean, shoves=shoves)
def _route_connection(
net_no,
a,
b,
tree,
result,
owner,
outline,
signal_layers,
half_width,
clearance,
margin,
trace_width,
via_cost,
via_layers,
shove=False,
accepted=None,
max_shove_depth=5,
shove_cap=3,
via_half=None,
edge=None,
):
rooms_by_layer = {
layer: _free_rooms(tree, outline, layer, net_no, margin, half_width)
for layer in signal_layers
}
graph = _build_graph(rooms_by_layer, trace_width)
a_layers = [layer for layer in a.layers if layer in signal_layers]
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)
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, via_half=via_half, edge=edge)
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
, via_half=via_half)
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,
via_half=via_half, edge=edge)
return 0
def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None, edge=None):
via_half = via_half or half_width
"""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 None
if edge is not None and edge[0] is not None and not edge[0].box_clear(box, edge[1]):
return None
boxes.append((box, frozenset({layer})))
for loc in vias:
vbox = IntBox(
loc.x - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
boxes.append((vbox, via_layers))
return boxes
def _commit(
net_no, wires, vias, tree, result, owner, half_width, via_layers, shove, accepted, via_half=None
):
via_half = via_half or half_width
"""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 - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
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 - via_half, loc.y - via_half, loc.x + via_half, loc.y + via_half
)
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)
# --- coordinated channel packing ---------------------------------------------
def _long(p: IntPoint, axis: str) -> int:
"""Coordinate along a channel's length (the axis nets travel)."""
return p.y if axis == "v" else p.x
def _trans(p: IntPoint, axis: str) -> int:
"""Coordinate across a channel (the axis lanes spread over)."""
return p.x if axis == "v" else p.y
def _box_long(b: IntBox, axis: str) -> tuple[int, int]:
return (b.ll.y, b.ur.y) if axis == "v" else (b.ll.x, b.ur.x)
def _box_trans(b: IntBox, axis: str) -> tuple[int, int]:
return (b.ll.x, b.ur.x) if axis == "v" else (b.ll.y, b.ur.y)
def _outline_trans(outline: IntBox, axis: str) -> tuple[int, int]:
return (outline.min_x, outline.max_x) if axis == "v" else (outline.min_y, outline.max_y)
def _axis_point(long_val: int, trans_val: int, axis: str) -> IntPoint:
"""Build a point from (length-coord, transverse-coord) for the given axis."""
return IntPoint(trans_val, long_val) if axis == "v" else IntPoint(long_val, trans_val)
def _lane_polyline(
top: IntPoint, bot: IntPoint, lane: int, trunk_top: int, trunk_bot: int, axis: str
) -> list[IntPoint]:
"""A pad -> trunk -> lane -> trunk -> pad orthogonal trace.
Each net converges to its lane on a *staggered* trunk line (``trunk_top`` /
``trunk_bot``, a distinct length-coordinate per net just outside the gap), runs
straight through the channel in its lane, then diverges to the far pad on the
other trunk. Distinct trunk lines keep the fan-in horizontals from overlapping
when pads spread wider than the gap. ``top`` is the pin with the larger
length-coordinate. Collinear/duplicate points collapse."""
tt = _trans(top, axis)
tb = _trans(bot, axis)
pts = [
top,
_axis_point(trunk_top, tt, axis),
_axis_point(trunk_top, lane, axis),
_axis_point(trunk_bot, lane, axis),
_axis_point(trunk_bot, tb, axis),
bot,
]
return _dedupe(pts)
def _wall_rows(obstacles: list[IntBox], axis: str) -> list[list[IntBox]]:
"""Cluster obstacle boxes into walls whose blocked length-ranges overlap; each
cluster is one barrier that nets cross transversely through its gaps."""
boxes = sorted(obstacles, key=lambda b: (_box_long(b, axis), _box_trans(b, axis)))
used = [False] * len(boxes)
rows: list[list[IntBox]] = []
for i, b in enumerate(boxes):
if used[i]:
continue
used[i] = True
cluster = [b]
lo, hi = _box_long(b, axis)
changed = True
while changed:
changed = False
for j, c in enumerate(boxes):
if used[j]:
continue
clo, chi = _box_long(c, axis)
if clo < hi and chi > lo:
used[j] = True
cluster.append(c)
lo, hi = min(lo, clo), max(hi, chi)
changed = True
rows.append(cluster)
return rows
def _channel_groups(obstacles, cands, axis, trace_width):
"""Find (gap_lo, gap_hi, [net_no, ...]) channels on one axis: a gap between two
obstacles in a wall row, and the >= 2 candidate nets that must cross that wall
and are nearest this gap. Only gaps bounded by an obstacle on both sides count
(an open board edge is not a channel)."""
groups = []
for cluster in _wall_rows(obstacles, axis):
blo = max(_box_long(b, axis)[0] for b in cluster)
bhi = min(_box_long(b, axis)[1] for b in cluster)
if bhi <= blo:
continue # no length-band the whole cluster blocks in common
occ = sorted(_box_trans(b, axis) for b in cluster)
merged: list[tuple[int, int]] = []
for lo, hi in occ:
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], hi))
else:
merged.append((lo, hi))
gaps = [
(merged[k][1], merged[k + 1][0])
for k in range(len(merged) - 1)
if merged[k + 1][0] - merged[k][1] >= trace_width
]
if not gaps:
continue
crossing = []
for net_no, (pa, pb) in cands.items():
la, lb = _long(pa, axis), _long(pb, axis)
if (la <= blo and lb >= bhi) or (lb <= blo and la >= bhi):
crossing.append(net_no)
for gap_lo, gap_hi in gaps:
members = []
for net_no in crossing:
pa, pb = cands[net_no]
center = (_trans(pa, axis) + _trans(pb, axis)) / 2
nearest = min(gaps, key=lambda g: abs((g[0] + g[1]) / 2 - center))
if nearest == (gap_lo, gap_hi):
members.append(net_no)
if len(members) >= 2:
groups.append((gap_lo, gap_hi, blo, bhi, sorted(members)))
return groups
def _boxes_clear(boxes, net_no, placed, clearance) -> bool:
"""True if none of ``boxes`` come within ``clearance`` of a different-net box in
``placed`` (list of ``(net_no, layer, box)``) -- the in-group lane-vs-lane check
that the search tree cannot do until copper is committed."""
for box, box_layers in boxes:
expanded = box.offset(clearance)
for other_net, other_layer, other_box in placed:
if other_net == net_no or other_layer not in box_layers:
continue
if other_box.overlaps(expanded):
return False
return True
def _pack_channels(
board,
tree,
result,
owner,
outline,
signal_layers,
half_width,
clearance,
margin,
trace_width,
via_layers,
accepted,
shove,
via_half=None,
edge=None,
) -> set[int]:
"""Coordinated multi-trace channel packing (opt-in pre-pass).
Greedy per-net routing sends each net through a shared constriction on its own
independently chosen path; the paths collide and some nets drop even when the
gap is physically wide enough for all of them. This pass groups 2-pin nets that
must cross a common obstacle gap, assigns each a parallel lane across the gap's
usable width -- sorted by pad position, spacing >= trace_width + clearance so
lanes never conflict and, being pad-ordered, never cross -- and realizes each as
an orthogonal converge/straight/diverge trace verified exactly DRC-clean against
the search tree. A net whose lane trace is not clean (e.g. it clips a pad) is
left for the normal loop. Returns the fully-routed net numbers so the caller
skips them. Scope: single-layer 2-pin nets through one axis-aligned gap."""
routed: set[int] = set()
spacing_min = trace_width + clearance
cands: dict[int, tuple[int, IntPoint, IntPoint]] = {}
for net_no in sorted(_routable_nets(board, signal_layers)):
pins = _net_pins(board, net_no, signal_layers)
if len(pins) != 2:
continue
shared = [
layer for layer in pins[0].layers if layer in pins[1].layers and layer in signal_layers
]
if not shared:
continue
cands[net_no] = (min(shared), pins[0].location, pins[1].location)
if len(cands) < 2:
return routed
obstacles_by_layer: dict[int, list[IntBox]] = {layer: [] for layer in signal_layers}
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():
obstacles_by_layer[obstacle.layer].append(box)
for layer in sorted(signal_layers):
obstacles = obstacles_by_layer.get(layer, [])
if not obstacles:
continue
layer_cands = {net_no: (pa, pb) for net_no, (lyr, pa, pb) in cands.items() if lyr == layer}
if len(layer_cands) < 2:
continue
for axis in ("v", "h"):
for gap_lo, gap_hi, blo, bhi, members in _channel_groups(
obstacles, layer_cands, axis, trace_width
):
members = [n for n in members if n not in routed]
if len(members) < 2:
continue
placed = _pack_group(
members,
layer_cands,
axis,
layer,
gap_lo,
gap_hi,
blo,
bhi,
tree,
half_width,
clearance,
margin,
spacing_min,
via_layers,
outline,
via_half=via_half, edge=edge)
if placed is None:
continue # could not pack every member cleanly -> leave to greedy
for net_no, wires in placed:
_commit(
net_no,
wires,
[],
tree,
result,
owner,
half_width,
via_layers,
shove,
accepted,
via_half=via_half)
routed.add(net_no)
return routed
def _pack_group(
members,
cands,
axis,
layer,
gap_lo,
gap_hi,
blo,
bhi,
tree,
half_width,
clearance,
margin,
spacing_min,
via_layers,
outline,
via_half=None,
edge=None,
):
"""Assign parallel lanes + staggered fan-in trunks to every net crossing one
gap, and return ``[(net_no, wires), ...]`` if ALL of them realize exactly
DRC-clean, else ``None``. All-or-nothing: a partial pack could leave the
channel worse than greedy, so the group is committed only if it packs whole."""
count = len(members)
lo = gap_lo + margin
hi = gap_hi - margin
band = hi - lo
if band < (count - 1) * spacing_min:
return None # gap not physically wide enough for this many lanes
# per net: (top pin, bottom pin) by length-coordinate; top has the larger value
ends = {}
for net_no in members:
pa, pb = cands[net_no]
ends[net_no] = (pa, pb) if _long(pa, axis) >= _long(pb, axis) else (pb, pa)
# lanes in gap order, preserving pad order so lanes never cross
lane_order = sorted(members, key=lambda n: (_trans(ends[n][0], axis), n))
lane_of = {
net_no: (lo if count == 1 else lo + round(band * i / (count - 1)))
for i, net_no in enumerate(lane_order)
}
# staggered trunks: the widest fan-in horizontal sits nearest the gap mouth so
# it passes under the shorter nets' descents (a monotone, crossing-free fan)
top_rank = {
n: r
for r, n in enumerate(
sorted(members, key=lambda n: (-abs(_trans(ends[n][0], axis) - lane_of[n]), n))
)
}
bot_rank = {
n: r
for r, n in enumerate(
sorted(members, key=lambda n: (-abs(_trans(ends[n][1], axis) - lane_of[n]), n))
)
}
top_pad = min(_long(ends[n][0], axis) for n in members)
bot_pad = max(_long(ends[n][1], axis) for n in members)
if bhi + margin + (count - 1) * spacing_min > top_pad - margin:
return None # not enough room above the gap to stack the fan-in trunks
if blo - margin - (count - 1) * spacing_min < bot_pad + margin:
return None # not enough room below the gap
placed_boxes: list[tuple[int, int, IntBox]] = []
to_commit = []
for net_no in sorted(members):
top_pt, bot_pt = ends[net_no]
trunk_top = bhi + margin + top_rank[net_no] * spacing_min
trunk_bot = blo - margin - bot_rank[net_no] * spacing_min
corners = _lane_polyline(top_pt, bot_pt, lane_of[net_no], trunk_top, trunk_bot, axis)
if len(corners) < 2:
return None
wires = [(layer, corners)]
boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half, edge=edge)
if boxes is None:
return None
if any(
tree.clearance_conflict(box, net_no, box_layer, clearance)
for box, box_layers in boxes
for box_layer in box_layers
):
return None
if not _boxes_clear(boxes, net_no, placed_boxes, clearance):
return None
for box, box_layers in boxes:
for box_layer in box_layers:
placed_boxes.append((net_no, box_layer, box))
to_commit.append((net_no, wires))
return to_commit
# --- 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,
via_half=None,
edge=None,
) -> 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)
# Reuse this net's existing registry if it already committed connections, so
# ``wire_owners`` stays parallel to ``wires``. Clobbering with a fresh
# ``_Accepted`` here would leave ``wire_owners`` shorter than ``wires`` (its
# ``result.wires[net_no]`` list already holds the earlier connections),
# desyncing the two lists and making a later shove remove the wrong wire's
# copper from the tree while it stays in the emitted route.
acc = accepted.get(net_no) if accepted is not None else None
if acc is None:
acc = _Accepted(
wires=result.wires.setdefault(net_no, []), vias=result.vias.setdefault(net_no, [])
)
accepted[net_no] = acc
result.add_wire(net_no, layer, corners)
acc.wire_owners.append(owners)
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]:
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 _counter():
i = 0
while True:
yield i
i += 1