Route to the real board outline, per connection, and out of fine-pitch pins

- The exact engine verified copper per net and dropped a whole net on any
  conflict: a 25-pin +3V3 net lost every connection to one bad segment.
  Verify and accept per ratsnest connection instead.
- Pad and keepout cells were rounded outward, blocking cells up to half a
  step clear of the copper; 62 of the 64 pins of a 0.5 mm pitch LQFP could
  not start a route. Block only cells whose centre is inside the clearance
  zone, and let a net walk along inside its own pad, which adds no copper.
- Routing was bounded by the outline's bounding box, so tracks cut across
  rounded corners. Keep the DSN boundary as a polygon; the grid blocks cells
  too close to it (scanline plus per-edge, precomputed for vias) and the
  exact and room engines reject copper that is.
- --edge-clearance MM sets copper-to-edge clearance, e.g. KiCad's
  min_copper_edge_clearance, which the DSN does not carry. Default 0.

On the TerraHawk rc4 power stage the exact engine now matches the
FreeRouting JAR: same open count, no DRC errors, with a 0.5 mm edge rule.
This commit is contained in:
2026-09-27 07:26:32 -06:00
parent 96c212466a
commit c1df284147
8 changed files with 303 additions and 32 deletions
+3 -1
View File
@@ -22,7 +22,7 @@ __all__ = ["BasicBoard"]
class BasicBoard:
"""A board: layers, nets, clearances, a bounding box, and items."""
__slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "_items")
__slots__ = ("layer_structure", "nets", "clearance_matrix", "bounding_box", "outline", "_items")
def __init__(
self,
@@ -35,6 +35,8 @@ class BasicBoard:
self.nets = nets
self.clearance_matrix = clearance_matrix
self.bounding_box = bounding_box
#: the real board outline, when the DSN has a polygon boundary
self.outline = None
self._items: list[Item] = []
# --- layers -------------------------------------------------------------
+13
View File
@@ -50,6 +50,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
bounding_box = _build_bounding_box(dsn, transform)
board = BasicBoard(layer_structure, nets, clearance, bounding_box)
board.outline = _build_outline(dsn, transform)
_insert_pins(dsn, board, transform, terminal_to_net)
_insert_keepouts(dsn, board, transform)
@@ -99,6 +100,18 @@ def _build_bounding_box(dsn: DsnBoard, transform: CoordinateTransform) -> IntBox
return IntBox(llx, lly, urx, ury)
def _build_outline(dsn: DsnBoard, transform: CoordinateTransform):
"""The DSN boundary as a polygon, or None when it is not a polygon."""
from .outline import BoardOutline
shape = dsn.boundary
if not isinstance(shape, (Polygon, Path)):
return None
c = shape.coords
pts = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
return BoardOutline(pts) if len(pts) >= 3 else None
def _shape_coords(shape: Shape) -> list[float]:
if isinstance(shape, Rectangle):
return list(shape.coords)
+122
View File
@@ -0,0 +1,122 @@
"""The board outline as a polygon, and clearance tests against its edge.
The DSN boundary is the real Edge.Cuts outline, rounded corners and all
(arcs come as short segments). Routing inside its bounding box instead lets
copper cut across a rounded corner closer to the edge than the board allows.
"""
from __future__ import annotations
import math
from freeroute.geometry import IntBox, IntPoint
class BoardOutline:
"""A simple polygon in board units, with point and box clearance tests."""
def __init__(self, points: list[IntPoint]) -> None:
pts = list(points)
if len(pts) > 1 and pts[0] == pts[-1]:
pts.pop()
self.points = pts
self._edges = [(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))]
def contains(self, p: IntPoint) -> bool:
"""Even-odd point in polygon; points on the edge count as outside."""
inside = False
x, y = p.x, p.y
for a, b in self._edges:
if (a.y > y) != (b.y > y):
t = (y - a.y) / (b.y - a.y)
if x < a.x + t * (b.x - a.x):
inside = not inside
return inside
def row_inside_spans(self, y: float) -> list[tuple[float, float]]:
"""The x intervals inside the polygon along the horizontal line at ``y``."""
xs = sorted(
a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y)
for a, b in self._edges
if (a.y > y) != (b.y > y)
)
# an odd count (a line grazing a vertex) drops the unpaired crossing
return list(zip(xs[0::2], xs[1::2], strict=False))
def edges(self):
return self._edges
def edge_distance(self, p: IntPoint) -> float:
return min(_point_segment(p, a, b) for a, b in self._edges)
def point_clear(self, p: IntPoint, reach: float) -> bool:
"""Copper reaching ``reach`` around ``p`` stays inside, clear of the edge."""
return self.contains(p) and self.edge_distance(p) >= reach
def box_clear(self, box: IntBox, clearance: float) -> bool:
"""``box`` lies inside the outline at least ``clearance`` from its edge."""
corners = [
IntPoint(box.ll.x, box.ll.y),
IntPoint(box.ur.x, box.ll.y),
IntPoint(box.ur.x, box.ur.y),
IntPoint(box.ll.x, box.ur.y),
]
if not all(self.contains(c) for c in corners):
return False
return all(_segment_box(a, b, box) >= clearance for a, b in self._edges)
def _point_segment(p: IntPoint, a: IntPoint, b: IntPoint) -> float:
dx, dy = b.x - a.x, b.y - a.y
length2 = dx * dx + dy * dy
if length2 == 0:
return math.hypot(p.x - a.x, p.y - a.y)
t = max(0.0, min(1.0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / length2))
return math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
def _point_box(p: IntPoint, box: IntBox) -> float:
dx = max(box.ll.x - p.x, 0, p.x - box.ur.x)
dy = max(box.ll.y - p.y, 0, p.y - box.ur.y)
return math.hypot(dx, dy)
def _segment_box(a: IntPoint, b: IntPoint, box: IntBox) -> float:
"""Distance between segment ``ab`` and an axis-aligned box (0 if they touch)."""
if _segments_cross_box(a, b, box):
return 0.0
corners = [
IntPoint(box.ll.x, box.ll.y),
IntPoint(box.ur.x, box.ll.y),
IntPoint(box.ur.x, box.ur.y),
IntPoint(box.ll.x, box.ur.y),
]
return min(
_point_box(a, box),
_point_box(b, box),
*(_point_segment(c, a, b) for c in corners),
)
def _segments_cross_box(a: IntPoint, b: IntPoint, box: IntBox) -> bool:
"""Liang-Barsky clip: does segment ``ab`` enter the box?"""
t0, t1 = 0.0, 1.0
dx, dy = b.x - a.x, b.y - a.y
for p, q in (
(-dx, a.x - box.ll.x),
(dx, box.ur.x - a.x),
(-dy, a.y - box.ll.y),
(dy, box.ur.y - a.y),
):
if p == 0:
if q < 0:
return False
else:
r = q / p
if p < 0:
t0 = max(t0, r)
else:
t1 = min(t1, r)
if t0 > t1:
return False
return True
+16 -2
View File
@@ -110,6 +110,13 @@ def build_parser() -> argparse.ArgumentParser:
help="disable rip-up-and-retry (grid and exact engines)",
)
parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
parser.add_argument(
"--edge-clearance",
type=float,
metavar="MM",
help="copper to board-edge clearance in mm, e.g. KiCad's min_copper_edge_clearance "
"(the DSN does not carry it); default 0: copper only has to stay inside the outline",
)
return parser
@@ -158,11 +165,18 @@ def main(argv: list[str] | None = None) -> int:
diagonal=args.diagonal,
rip_up=rip_up,
max_passes=max_passes,
edge_clearance_mm=args.edge_clearance,
)
elif args.engine == "room":
result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack)
result = build_rooms_routing_result(
dsn, layers=layers, shove=args.shove, pack=args.pack,
edge_clearance_mm=args.edge_clearance,
)
else:
result = build_routing_result(dsn, layers=layers, rip_up=rip_up, max_passes=max_passes)
result = build_routing_result(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=args.edge_clearance,
)
ses_text = write_ses(dsn, result)
+22 -7
View File
@@ -91,6 +91,7 @@ def route_board_exact(
max_shove_depth: int = 5,
shove_cap: int = 3,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -116,7 +117,10 @@ def route_board_exact(
rip_up=rip_up,
orthogonal=True,
via_half=via_half,
edge_clearance=edge_clearance,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
tree = ShapeSearchTree()
owner = _counter()
@@ -143,20 +147,25 @@ def route_board_exact(
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
for net_no in sorted(set(result.wires) | set(result.vias)):
boxes = _net_boxes(result, net_no, half_width, via_layers, via_half)
# Verify connection by connection, so one conflicting segment of a
# 25-pin supply net costs that one connection, not the whole net.
for net_no, wires, vias in sorted(result.connections, key=lambda c: c[0]):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
if any(
tree.clearance_conflict(box, net_no, layer, clearance)
for box, box_layers in boxes
for layer in box_layers
):
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = _Accepted(wires=result.wires.get(net_no, []), vias=result.vias.get(net_no, []))
acc = accepted.get(net_no)
if acc is None:
acc = accepted[net_no] = _Accepted(wires=[], vias=[])
acc.wires.extend(wires)
acc.vias.extend(vias)
for box, box_layers in boxes:
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
@@ -547,12 +556,18 @@ def _rollback(shoved, accepted, tree, owner) -> int: # noqa: ARG001
def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
return _copper_boxes(
result.wires.get(net_no, []), result.vias.get(net_no, []), half_width, via_layers, via_half
)
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = []
for layer, points in result.wires.get(net_no, []):
for layer, points in wires:
for box in PolylineShape(Polyline(points), half_width).tiles():
boxes.append((box, frozenset({layer})))
for loc in result.vias.get(net_no, []):
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
+68 -8
View File
@@ -31,7 +31,7 @@ import math
from freeroute.board.board import BasicBoard
from freeroute.board.items import ObstacleArea, Pin
from freeroute.geometry import IntPoint
from freeroute.geometry import IntBox, IntPoint
__all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -59,7 +59,7 @@ class RouteResult:
``routed_net_numbers`` are the nets that got any wire or via.
"""
__slots__ = ("wires", "vias", "half_width", "unrouted")
__slots__ = ("wires", "vias", "half_width", "unrouted", "connections")
def __init__(self, half_width: int) -> None:
self.wires: dict[int, list[tuple[int, list[IntPoint]]]] = {}
@@ -67,6 +67,8 @@ class RouteResult:
self.half_width = half_width
#: number of ratsnest connections left unrouted
self.unrouted = 0
#: the same copper per routed connection: (net_no, wires, vias)
self.connections: list[tuple[int, list[tuple[int, list[IntPoint]]], list[IntPoint]]] = []
def add_wire(self, net_no: int, layer: int, path: list[IntPoint]) -> None:
self.wires.setdefault(net_no, []).append((layer, path))
@@ -132,6 +134,7 @@ class GridRouter:
rip_penalty: float = 30.0,
orthogonal: bool = False,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> None:
self.board = board
self.trace_width = max(trace_width, 1)
@@ -151,6 +154,8 @@ class GridRouter:
# centre via_half + clearance + half_width from the via's centre, so a
# via also claims every cell within via_reach of its own.
self.via_half = via_half or self.half_width
# copper to board edge; KiCad's rule is usually wider than clearance
self.edge_clearance = 0 if edge_clearance is None else edge_clearance
need = self.via_half + self.clearance + self.half_width
self.via_reach = max(0, math.ceil(need / self.step) - 1)
@@ -166,6 +171,7 @@ class GridRouter:
# static obstacles
self._pad_block: dict[Node, set[int]] = {}
self._pad_own: dict[Node, set[int]] = {}
self._keepout: set[Node] = set()
# per-connection occupancy (owner = connection index)
self._cell_owner: dict[Node, int] = {}
@@ -185,16 +191,57 @@ class GridRouter:
return IntPoint(self.origin_x + cell[0] * self.step, self.origin_y + cell[1] * self.step)
def _cells_in_box(self, box, margin: int):
lo_x = round((box.ll.x - margin - self.origin_x) / self.step)
hi_x = round((box.ur.x + margin - self.origin_x) / self.step)
lo_y = round((box.ll.y - margin - self.origin_y) / self.step)
hi_y = round((box.ur.y + margin - self.origin_y) / self.step)
"""Cells whose centre lies strictly inside ``box`` grown by ``margin``.
A trace centred there would come closer than ``margin`` to the box.
Rounding the bounds outward instead blocked cells up to half a step
clear of it, which walled in every pin of a 0.5 mm pitch package.
"""
lo_x = math.floor((box.ll.x - margin - self.origin_x) / self.step) + 1
hi_x = math.ceil((box.ur.x + margin - self.origin_x) / self.step) - 1
lo_y = math.floor((box.ll.y - margin - self.origin_y) / self.step) + 1
hi_y = math.ceil((box.ur.y + margin - self.origin_y) / self.step) - 1
for gx in range(max(lo_x, 0), min(hi_x, self.cols - 1) + 1):
for gy in range(max(lo_y, 0), min(hi_y, self.rows - 1) + 1):
yield gx, gy
def _outline_blocked(self, reach: int) -> set[Cell]:
"""Cells whose centre is outside the outline or within ``reach`` of its edge."""
outline = self.board.outline
blocked: set[Cell] = set()
for gy in range(self.rows): # outside: one scanline per row
y = self.origin_y + gy * self.step
spans = outline.row_inside_spans(y)
for gx in range(self.cols):
x = self.origin_x + gx * self.step
if not any(lo < x < hi for lo, hi in spans):
blocked.add((gx, gy))
for a, b in outline.edges(): # too close: only cells near each edge
edge_box = IntBox(min(a.x, b.x), min(a.y, b.y), max(a.x, b.x), max(a.y, b.y))
for cell in self._cells_in_box(edge_box, reach):
if cell not in blocked and outline.edge_distance(self._cell_center(cell)) < reach:
blocked.add(cell)
return blocked
def _inside_own_pad(self, cell: Cell, shape) -> bool:
"""A trace centred on ``cell`` lies entirely on the pad's own copper."""
c = self._cell_center(cell)
h = self.half_width
return all(
shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h)
)
def _rasterize_obstacles(self) -> None:
margin = self.clearance + self.half_width
outline = self.board.outline
self._via_edge_block: set[Cell] = set()
if outline is not None:
# cells too close to the real outline (rounded corners included):
# for a trace on every layer, and separately for a via's wider copper
for cell in self._outline_blocked(self.half_width + self.edge_clearance):
for layer in self.layers:
self._keepout.add((*cell, layer))
self._via_edge_block = self._outline_blocked(self.via_half + self.edge_clearance)
for item in self.board.get_items():
if isinstance(item, Pin):
box = item.shape.bounding_box() if item.shape is not None else None
@@ -205,6 +252,13 @@ class GridRouter:
for cell in self._cells_in_box(box, margin):
for layer in pin_layers:
self._pad_block.setdefault((*cell, layer), set()).add(net)
# Copper of the pin's own net: a trace inside it adds nothing,
# so it is open to that net however close the neighbours are.
# That is how a route leaves a fine-pitch pin.
for cell in self._cells_in_box(box, 0):
if self._inside_own_pad(cell, item.shape):
for layer in pin_layers:
self._pad_own.setdefault((*cell, layer), set()).add(net)
elif isinstance(item, ObstacleArea):
if item.layer not in self.layers:
continue
@@ -214,7 +268,8 @@ class GridRouter:
box = tile.bounding_box()
if box.is_empty():
continue
for cell in self._cells_in_box(box, self.clearance):
# the same distance the exact engine verifies against
for cell in self._cells_in_box(box, margin):
self._keepout.add((*cell, item.layer))
# --- occupancy cost -----------------------------------------------------
@@ -233,7 +288,7 @@ class GridRouter:
if node in self._keepout:
return None
pads = self._pad_block.get(node)
if pads and any(n != net_no for n in pads):
if pads and any(n != net_no for n in pads) and net_no not in self._pad_own.get(node, ()):
return None
blocking = None
owner = self._cell_owner.get(node)
@@ -264,6 +319,8 @@ class GridRouter:
def _via_placeable(self, cell: Cell, net_no: int, conns: list[_Connection]) -> bool:
"""A through via needs its whole footprint free of other nets on every layer."""
if cell in self._via_edge_block:
return False
return all(
self._enter_cost(c, layer, net_no, False, conns) == 0.0
for c in self._via_cells(cell)
@@ -464,6 +521,7 @@ class GridRouter:
result.add_wire(net_no, layer, points)
for location in vias:
result.add_via(net_no, location)
result.connections.append((net_no, wires, vias))
return result
@@ -520,6 +578,7 @@ def route_board(
max_passes: int = 10,
orthogonal: bool = False,
via_half: int | None = None,
edge_clearance: int | None = None,
) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter(
@@ -532,5 +591,6 @@ def route_board(
max_passes=max_passes,
orthogonal=orthogonal,
via_half=via_half,
edge_clearance=edge_clearance,
)
return router.route()
+44 -7
View File
@@ -45,6 +45,14 @@ def _via_padstack(dsn: DsnBoard) -> str:
return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA
def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | None:
"""A length in millimetres as board units, or None."""
if value_mm is None:
return None
unit_mm = {"inch": 25.4, "mil": 0.0254, "mm": 1.0, "cm": 10.0, "um": 0.001}[dsn.unit]
return round(value_mm / unit_mm * scale)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
"""Half the via pad's size in board units, from the DSN's via padstack.
@@ -67,6 +75,7 @@ def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
def route_dsn_board(
dsn: DsnBoard,
*,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
@@ -77,11 +86,13 @@ def route_dsn_board(
toggles rip-up-and-retry (``False`` = greedy single pass)."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
result = route_board(
board,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -122,10 +133,12 @@ def build_routing_result(
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -133,6 +146,7 @@ def build_routing_result(
def route_dsn_board_exact(
dsn: DsnBoard,
*,
edge_clearance_mm: float | None = None,
layers: list[int] | None = None,
max_passes: int = 10,
rip_up: bool = True,
@@ -146,11 +160,13 @@ def route_dsn_board_exact(
the 45-degree recovery pass for still-dropped 2-pin nets."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
exact = route_board_exact(
board,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -170,6 +186,7 @@ def build_exact_routing_result(
diagonal: bool = False,
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -179,12 +196,18 @@ def build_exact_routing_result(
diagonal=diagonal,
rip_up=rip_up,
max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
def route_dsn_board_rooms(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False
dsn: DsnBoard,
*,
layers: list[int] | None = None,
shove: bool = False,
pack: bool = False,
edge_clearance_mm: float | None = None,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the continuous expansion-room track;
return the result, the scale, and layer names. ``shove`` enables occupancy-
@@ -192,11 +215,13 @@ def route_dsn_board_rooms(
``pack`` enables the coordinated multi-trace channel packer."""
board = build_board(dsn)
scale = max(dsn.resolution.value, 1)
edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm)
width_board = round(_rule_width_dsn(dsn) * scale)
clearance_board = round(_rule_clearance_dsn(dsn) * scale)
rooms = route_board_rooms(
board,
via_half=_via_half_board(dsn, scale),
edge_clearance=edge_clearance_board,
trace_width=width_board,
clearance=clearance_board,
layers=layers,
@@ -207,10 +232,17 @@ def route_dsn_board_rooms(
def build_rooms_routing_result(
dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False
dsn: DsnBoard,
*,
layers: list[int] | None = None,
shove: bool = False,
pack: bool = False,
edge_clearance_mm: float | None = None,
) -> RoutingResult:
"""Route ``dsn`` (room track) and convert to a DSN-unit RoutingResult."""
rooms, scale, layer_names = route_dsn_board_rooms(dsn, layers=layers, shove=shove, pack=pack)
rooms, scale, layer_names = route_dsn_board_rooms(
dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm
)
return _to_routing_result(rooms.result, dsn, scale, layer_names)
@@ -222,6 +254,7 @@ def route(
shove: bool = False,
pack: bool = False,
diagonal: bool = False,
edge_clearance_mm: float | None = None,
) -> str:
"""Route a Specctra DSN string and return the routed SES string.
@@ -235,9 +268,13 @@ def route(
"""
dsn = parse_dsn(dsn_text)
if engine == "exact":
result = build_exact_routing_result(dsn, layers=layers, shove=shove, diagonal=diagonal)
result = build_exact_routing_result(
dsn, layers=layers, shove=shove, diagonal=diagonal, edge_clearance_mm=edge_clearance_mm
)
elif engine == "room":
result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack)
result = build_rooms_routing_result(
dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm
)
else:
result = build_routing_result(dsn, layers=layers)
result = build_routing_result(dsn, layers=layers, edge_clearance_mm=edge_clearance_mm)
return write_ses(dsn, result)
+15 -7
View File
@@ -472,6 +472,7 @@ def route_board_rooms(
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.
@@ -498,6 +499,7 @@ def route_board_rooms(
# 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
@@ -541,7 +543,7 @@ def route_board_rooms(
via_layers,
accepted,
shove,
via_half=via_half)
via_half=via_half, edge=edge)
for net_no in sorted(_routable_nets(board, signal_layers)):
if net_no in packed:
continue
@@ -566,7 +568,7 @@ def route_board_rooms(
accepted,
max_shove_depth,
shove_cap,
via_half=via_half)
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)
@@ -592,6 +594,7 @@ def _route_connection(
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)
@@ -612,7 +615,7 @@ def _route_connection(
if realized is not None:
wires, vias = realized
boxes = _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=via_half)
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
@@ -639,11 +642,11 @@ def _route_connection(
clearance,
max_shove_depth,
shove_cap,
via_half=via_half)
via_half=via_half, edge=edge)
return 0
def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None):
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)."""
@@ -652,6 +655,8 @@ def _collect_boxes(wires, vias, half_width, via_layers, outline, via_half=None):
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(
@@ -864,6 +869,7 @@ def _pack_channels(
accepted,
shove,
via_half=None,
edge=None,
) -> set[int]:
"""Coordinated multi-trace channel packing (opt-in pre-pass).
@@ -932,7 +938,7 @@ def _pack_channels(
spacing_min,
via_layers,
outline,
via_half=via_half)
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:
@@ -969,6 +975,7 @@ def _pack_group(
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
@@ -1025,7 +1032,7 @@ def _pack_group(
if len(corners) < 2:
return None
wires = [(layer, corners)]
boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half)
boxes = _collect_boxes(wires, [], half_width, via_layers, outline, via_half=via_half, edge=edge)
if boxes is None:
return None
if any(
@@ -1061,6 +1068,7 @@ def _shove_recover_room(
max_shove_depth,
shove_cap,
via_half=None,
edge=None,
) -> int:
"""Recover a dropped net by nudging the committed trace(s) that block it.