Compare commits

...
Author SHA1 Message Date
rsp2k a2e108bc51 Rollback on by default; --no-rollback keeps every rip-up
Over 8 net orders it left 25 to 30 fewer connections unrouted on the
dense sense boards and 16 to 18 fewer on two PCBench boards, and was
never clearly worse. KiCad DRC on the sense harness: 41 opens to 28.
2026-09-28 16:17:28 -06:00
rsp2k 3462089e09 --rollback: undo a rip-up that leaves fewer connections routed
The connections a rip-up route crosses are rerouted at once in free
space. If that leaves fewer connections routed than before, the new
route comes out and every displaced connection goes back on its old
path, with its rip count as it was. Even trades are kept.
2026-09-28 14:54:29 -06:00
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00
rsp2k bb75dc5067 DSN reader: only the declared string_quote character opens a string
KiCad declares " and writes a keycap part number as a bare ', which the
tokenizer took for an unterminated string (PCBench f.60_keyboard).
2026-09-28 09:41:27 -06:00
rsp2k 9de8329027 Merge feat/pin-access: vias in pads inside keepouts, exact stub checks, --access-cost 2026-09-28 09:38:18 -06:00
14 changed files with 340 additions and 22 deletions
+21 -8
View File
@@ -26,6 +26,7 @@ import sys
from freeroute.board import build_board from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router from freeroute.route import grid_router
from freeroute.route.exact_router import route_board_exact from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of from freeroute.route.grid_router import GridRouter, _ends_of
@@ -80,13 +81,24 @@ def _capture_conflicts() -> list:
def conflict(self, box, net_no, layer, clearance, kind="wire"): def conflict(self, box, net_no, layer, clearance, kind="wire"):
hit = original(self, box, net_no, layer, clearance, kind) hit = original(self, box, net_no, layer, clearance, kind)
if hit: if hit:
# the shape that tripped the check, found the way the check does
other = None
expanded = box.offset(clearance) expanded = box.offset(clearance)
other = next( for s in self.overlapping(expanded):
(s for s in self.overlapping(expanded) if s.net_no == net_no or not s.on_layer(layer):
if s.net_no != net_no and s.on_layer(layer) continue
and (s.blocks == "all" or s.blocks == kind + "s")), if s.blocks != "all" and s.blocks != kind + "s":
None, continue
) if not s.clearance:
if s.tile.overlaps(box):
other = s
break
continue
if (s.exact is not None and overlaps_2d(s.exact, expanded)) or (
s.exact is None and s.tile.overlaps(expanded)
):
other = s
break
log.append((net_no, kind, layer, box, clearance, other)) log.append((net_no, kind, layer, box, clearance, other))
return hit return hit
@@ -177,10 +189,11 @@ def diagnose(
if other is None: if other is None:
what = "?" what = "?"
elif other.net_no == -1: elif other.net_no == -1:
what = "keepout" what = "keepout" if other.clearance else "rule area"
else: else:
what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?") what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?")
short = (clearance - _gap(box, other.tile)) / scale / 1000 if other is not None else 0 need = clearance if other is None or other.clearance else 0
short = (need - _gap(box, other.tile)) / scale / 1000 if other is not None else 0
per_net.setdefault(net_no, []).append( per_net.setdefault(net_no, []).append(
{"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)} {"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)}
) )
+10
View File
@@ -333,10 +333,20 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
tiles=tiles, tiles=tiles,
blocks_wires=keepout.kind != "via_keepout", blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout", blocks_vias=keepout.kind != "wire_keepout",
clearance=not is_rule_area(keepout.area.name),
) )
) )
#: a structure keepout named with this prefix is a KiCad rule area (mckicad
#: names them on export); it is checked for overlap only
RULE_AREA_PREFIX = "rule_area"
def is_rule_area(name: str | None) -> bool:
return bool(name) and name.startswith(RULE_AREA_PREFIX)
def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list: def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list:
"""Convex board tiles covering a keepout border shape. """Convex board tiles covering a keepout border shape.
+5
View File
@@ -76,6 +76,11 @@ class ObstacleArea(Item):
#: tracks" rule area, where vias are allowed), via_keepout only vias #: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True blocks_wires: bool = True
blocks_vias: bool = True blocks_vias: bool = True
#: False for a KiCad rule area: copper may not overlap it but may touch
#: it, since KiCad's DRC applies no clearance to a rule area. Every other
#: keepout may stand for copper (a net tie, a track kept as it is), so it
#: keeps the clearance unless named as a rule area.
clearance: bool = True
def is_obstacle(self) -> bool: def is_obstacle(self) -> bool:
return True return True
+26
View File
@@ -50,6 +50,8 @@ class TreeShape:
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout, #: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout) #: vias allowed) or "vias" (a via_keepout)
blocks: str = "all" blocks: str = "all"
#: False for a rule area: copper may touch it, only overlap counts
clearance: bool = True
def on_layer(self, layer: int) -> bool: def on_layer(self, layer: int) -> bool:
return layer in self.layers return layer in self.layers
@@ -129,6 +131,8 @@ class ShapeSearchTree:
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed. exactly ``clearance`` apart (touching after expansion) are allowed.
A rule area (``clearance=False``) only has to be clear of the copper
itself.
""" """
expanded = box.offset(clearance) expanded = box.offset(clearance)
for shape in self.overlapping(expanded): for shape in self.overlapping(expanded):
@@ -136,6 +140,10 @@ class ShapeSearchTree:
continue continue
if shape.blocks != "all" and shape.blocks != kind + "s": if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on continue # a wire-only keepout does not stop a via, and so on
if not shape.clearance:
if shape.tile.overlaps(box):
return True
continue
if shape.exact is not None: if shape.exact is not None:
if overlaps_2d(shape.exact, expanded): if overlaps_2d(shape.exact, expanded):
return True return True
@@ -143,6 +151,17 @@ class ShapeSearchTree:
return True return True
return False return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
if shape.clearance or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
if shape.tile.overlaps(box):
return True
return False
def clearance_conflict_shape( def clearance_conflict_shape(
self, region, net_no: int, layer: int self, region, net_no: int, layer: int
) -> bool: ) -> bool:
@@ -178,6 +197,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers): if not (shape.layers & other.layers):
continue continue
if not (shape.clearance and other.clearance):
# a rule area: only an overlap is a violation
mine = shape.exact if shape.exact is not None else shape.tile
theirs = other.exact if other.exact is not None else other.tile
if overlaps_2d(mine, theirs):
return shape
continue
if shape.exact is None and other.exact is None: if shape.exact is None and other.exact is None:
if other.tile.overlaps(expanded): # byte-identical box path if other.tile.overlaps(expanded): # byte-identical box path
return shape return shape
+14 -2
View File
@@ -30,8 +30,8 @@ from freeroute.ses import write_ses
#: which optional flags each engine actually implements. Anything not listed for #: which optional flags each engine actually implements. Anything not listed for
#: an engine is rejected by :func:`_validate` rather than quietly ignored. #: an engine is rejected by :func:`_validate` rather than quietly ignored.
ENGINE_OPTIONS: dict[str, frozenset[str]] = { ENGINE_OPTIONS: dict[str, frozenset[str]] = {
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access"}), "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access"}), "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"room": frozenset({"shove", "pack"}), "room": frozenset({"shove", "pack"}),
} }
@@ -46,6 +46,7 @@ _FLAG_NAMES = {
"orders": "--orders", "orders": "--orders",
"promote": "--promote-failures", "promote": "--promote-failures",
"access": "--access-cost", "access": "--access-cost",
"rollback": "--no-rollback",
} }
@@ -166,6 +167,14 @@ def build_parser() -> argparse.ArgumentParser:
help="grid and exact engines: extra cost for a route to pass where it would close " help="grid and exact engines: extra cost for a route to pass where it would close "
"another pin's way out while that pin still has connections to route (default 0: off)", "another pin's way out while that pin still has connections to route (default 0: off)",
) )
parser.add_argument(
"--no-rollback",
dest="rollback",
action="store_false",
help="grid and exact engines: keep every rip-up, as before. By default the "
"connections a rip-up displaces are rerouted at once and the rip-up is undone "
"if fewer connections end up routed",
)
parser.add_argument( parser.add_argument(
"--jobs", "--jobs",
type=int, type=int,
@@ -195,6 +204,7 @@ def main(argv: list[str] | None = None) -> int:
("orders", args.orders is not None), ("orders", args.orders is not None),
("promote", args.promote_failures is not None), ("promote", args.promote_failures is not None),
("access", args.access_cost is not None), ("access", args.access_cost is not None),
("rollback", not args.rollback),
) )
if asked if asked
] ]
@@ -229,6 +239,7 @@ def main(argv: list[str] | None = None) -> int:
"resolution": 2 if args.grid_resolution is None else args.grid_resolution, "resolution": 2 if args.grid_resolution is None else args.grid_resolution,
"heuristic_weight": args.heuristic_weight, "heuristic_weight": args.heuristic_weight,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
} }
elif args.engine == "grid": elif args.engine == "grid":
options = { options = {
@@ -238,6 +249,7 @@ def main(argv: list[str] | None = None) -> int:
"edge_clearance_mm": args.edge_clearance, "edge_clearance_mm": args.edge_clearance,
"resolution": 1 if args.grid_resolution is None else args.grid_resolution, "resolution": 1 if args.grid_resolution is None else args.grid_resolution,
"access_cost": args.access_cost or 0.0, "access_cost": args.access_cost or 0.0,
"rollback": args.rollback,
} }
if args.engine == "room": if args.engine == "room":
result = build_rooms_routing_result( result = build_rooms_routing_result(
+17 -1
View File
@@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour.
from __future__ import annotations from __future__ import annotations
import re
from .model import ( from .model import (
ClearanceRule, ClearanceRule,
ComponentPlace, ComponentPlace,
@@ -481,12 +483,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None:
# --- top level --------------------------------------------------------------- # --- top level ---------------------------------------------------------------
_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)")
def _declared_quote(text: str) -> str:
"""The quote character the file declares in ``(string_quote X)``.
Only that character opens a string. Without a declaration both kinds are
accepted, as before: KiCad declares ``"`` and then writes a bare ``'``
where a value is a single quote mark, e.g. a keycap part number.
"""
found = _STRING_QUOTE.search(text[:4096])
return found.group(1) if found else "\"'"
def parse_dsn(text: str) -> DsnBoard: def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`. """Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``. Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
""" """
top = parse(text) top = parse(text, _declared_quote(text))
if top.head != "pcb": if top.head != "pcb":
raise DsnParseError( raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'" f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
+21 -2
View File
@@ -42,6 +42,7 @@ from freeroute.geometry import (
segment_octagon, segment_octagon,
shove_segment, shove_segment,
) )
from freeroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board from .grid_router import RouteResult, route_board
@@ -97,6 +98,7 @@ def route_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> ExactRouteResult: ) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry. """Route ``board`` orthogonally and verify the result with exact geometry.
@@ -128,6 +130,7 @@ def route_board_exact(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
edge = 0 if edge_clearance is None else edge_clearance edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline outline = board.outline
@@ -157,7 +160,10 @@ def route_board_exact(
box = tile.bounding_box() box = tile.bounding_box()
if not box.is_empty(): if not box.is_empty():
tree.insert( tree.insert(
TreeShape(next(owner), -1, frozenset({obstacle.layer}), box, blocks=blocks) TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
) )
via_layers = frozenset(signal_layers) via_layers = frozenset(signal_layers)
@@ -178,7 +184,9 @@ def route_board_exact(
) )
for i, (box, box_layers) in enumerate(boxes) for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers for layer in box_layers
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)): ) or _end_in_rule_area(tree, wires, half_width) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
):
rejected.append((net_no, *ends)) rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove) continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no) acc = accepted.get(net_no)
@@ -592,6 +600,17 @@ def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
) )
def _end_in_rule_area(tree, wires, half_width) -> bool:
"""A wire's tiles stop at its end points, but a track ends in a round cap
half its width beyond them. Against other copper the pad or via there
covers it; a rule area has no clearance to absorb it, so check it."""
for layer, points in wires:
for end in (points[0], points[-1]):
if tree.rule_area_overlap(corner_box(end, half_width), layer):
return True
return False
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None): def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = [] boxes: list[tuple[IntBox, frozenset[int]]] = []
+83 -9
View File
@@ -33,6 +33,7 @@ import random
from freeroute.board.board import BasicBoard from freeroute.board.board import BasicBoard
from freeroute.board.items import ConductionArea, ObstacleArea, Pin from freeroute.board.items import ConductionArea, ObstacleArea, Pin
from freeroute.geometry import IntBox, IntPoint from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry.polyline import corner_box
__all__ = ["GridRouter", "RouteResult", "route_board"] __all__ = ["GridRouter", "RouteResult", "route_board"]
@@ -112,6 +113,7 @@ class _Connection:
"b_group", "b_group",
"a_end", "a_end",
"b_end", "b_end",
"path",
) )
def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None: def __init__(self, index: int, net_no: int, a: Pin, b: Pin) -> None:
@@ -133,6 +135,8 @@ class _Connection:
self.b_group: tuple[list[Pin], list] = ([b], []) self.b_group: tuple[list[Pin], list] = ([b], [])
self.a_end: Pin | None = a self.a_end: Pin | None = a
self.b_end: Pin | None = b self.b_end: Pin | None = b
# the node path last committed, so a rip-up can be undone
self.path: list[Node] | None = None
class GridRouter: class GridRouter:
@@ -158,6 +162,7 @@ class GridRouter:
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> None: ) -> None:
self.board = board self.board = board
# Pin access: while a pin still has connections to route, the cells # Pin access: while a pin still has connections to route, the cells
@@ -167,6 +172,12 @@ class GridRouter:
self.access_cost = max(float(access_cost), 0.0) self.access_cost = max(float(access_cost), 0.0)
self._doorways: dict[Node, list[tuple[int, int]]] = {} self._doorways: dict[Node, list[tuple[int, int]]] = {}
self._pending: dict[int, int] = {} self._pending: dict[int, int] = {}
# Rip-up as a transaction: the connections a rip-up route crosses are
# rerouted at once, and if fewer connections are routed afterwards
# than before, everything goes back as it was. On by default: over
# 8 net orders it left 25 to 30 fewer connections unrouted on dense
# boards and was never clearly worse.
self.rollback = bool(rollback)
# None keeps the deterministic order; a seed shuffles which connection # None keeps the deterministic order; a seed shuffles which connection
# routes first, to measure how much a result owes to the order # routes first, to measure how much a result owes to the order
self.seed = seed self.seed = seed
@@ -298,7 +309,9 @@ class GridRouter:
outline = self.board.outline outline = self.board.outline
self._via_edge_block: set[Cell] = set() self._via_edge_block: set[Cell] = set()
# wire keepouts as boxes, to check a pin stub the way the exact engine will # wire keepouts as boxes, to check a pin stub the way the exact engine will
self._keepout_boxes: dict[int, list[IntBox]] = {} self._keepout_boxes: dict[int, list[tuple[IntBox, bool]]] = {}
# pin end nodes no wire can reach without entering a keepout
self._via_only: set[Node] = set()
if outline is not None: if outline is not None:
# cells too close to the real outline (rounded corners included): # 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 a trace on every layer, and separately for a via's wider copper
@@ -336,8 +349,12 @@ class GridRouter:
continue continue
# the same distance the exact engine verifies against # the same distance the exact engine verifies against
if item.blocks_wires: if item.blocks_wires:
self._keepout_boxes.setdefault(item.layer, []).append(box) self._keepout_boxes.setdefault(item.layer, []).append(
for cell in self._cells_in_box(box, margin): (box, item.clearance)
)
# a rule area takes no clearance: a trace may touch it
reach = margin if item.clearance else self.half_width
for cell in self._cells_in_box(box, reach):
if item.blocks_wires: if item.blocks_wires:
self._keepout.add((*cell, item.layer)) self._keepout.add((*cell, item.layer))
if item.blocks_vias: if item.blocks_vias:
@@ -475,12 +492,19 @@ class GridRouter:
return path return path
cx, cy, cl = current cx, cy, cl = current
base = g_score[current] base = g_score[current]
for dx, dy in self._neighbours: # A cell inside a wire keepout (a pin there, or where a via
# landed) may only be left by another via: a wire from it would
# start inside the keepout.
blocked = current in self._keepout or current in self._via_only
neighbours = () if blocked else self._neighbours
for dx, dy in neighbours:
cell = (cx + dx, cy + dy) cell = (cx + dx, cy + dy)
if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows):
continue continue
nxt = (*cell, cl) nxt = (*cell, cl)
if nxt in goal_stub: # a pin end; a zone end is entered normally if nxt in goal_stub: # a pin end; a zone end is entered normally
if nxt in self._keepout or nxt in self._via_only:
continue # a pin inside a keepout is reached by a via only
enter = goal_stub[nxt] enter = goal_stub[nxt]
else: else:
enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns) enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns)
@@ -528,7 +552,9 @@ class GridRouter:
if _point_segment(self._cell_center(c), pin_location, end) < reach if _point_segment(self._cell_center(c), pin_location, end) < reach
] ]
def _stub_clear(self, pin: Pin, cell: Cell, layer: int, net_no: int, conns) -> bool: def _stub_clear(
self, pin: Pin, cell: Cell, layer: int, net_no: int, conns, keepouts_only: bool = False
) -> bool:
keepouts = self._keepout_boxes.get(layer) keepouts = self._keepout_boxes.get(layer)
if keepouts: if keepouts:
# the stub is off the grid, so check it exactly, as the exact # the stub is off the grid, so check it exactly, as the exact
@@ -539,8 +565,14 @@ class GridRouter:
if end != pin.location: if end != pin.location:
for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles(): for tile in PolylineShape(Polyline([pin.location, end]), self.half_width).tiles():
grown = tile.offset(self.clearance) grown = tile.offset(self.clearance)
if any(k.overlaps(grown) for k in keepouts): if any(k.overlaps(grown if needs else tile) for k, needs in keepouts):
return False return False
# the track's round end at the pin, for a rule area
cap = corner_box(pin.location, self.half_width)
if any(not needs and k.overlaps(cap) for k, needs in keepouts):
return False
if keepouts_only:
return True
for c in self._stub_cells(pin.location, cell): for c in self._stub_cells(pin.location, cell):
owner = self._cell_owner.get((*c, layer)) owner = self._cell_owner.get((*c, layer))
if owner is not None and conns[owner].net_no != net_no: if owner is not None and conns[owner].net_no != net_no:
@@ -646,6 +678,13 @@ class GridRouter:
nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy)) nodes |= self._escape_nodes(pin, net_no, conns, layers, (cx, cy))
if not nodes: # nothing clear: fall back to the nearest cell, as before if not nodes: # nothing clear: fall back to the nearest cell, as before
nodes = {(cx, cy, layer) for layer in layers} nodes = {(cx, cy, layer) for layer in layers}
if self._keepout_boxes and not any(
self._stub_clear(pin, (cx, cy), layer, net_no, conns, keepouts_only=True)
for layer in layers
):
# no wire can reach the pin without entering a keepout: the
# route has to arrive by a via, which goes in the pad
self._via_only.update(nodes)
return nodes return nodes
def _crowded(self, pin: Pin, net_no: int) -> bool: def _crowded(self, pin: Pin, net_no: int) -> bool:
@@ -759,6 +798,7 @@ class GridRouter:
# --- commit / rip ------------------------------------------------------- # --- commit / rip -------------------------------------------------------
def _commit(self, conn: _Connection, path: list[Node]) -> None: def _commit(self, conn: _Connection, path: list[Node]) -> None:
conn.path = list(path)
wire_segs, via_cells = _split_path(path) wire_segs, via_cells = _split_path(path)
via_cells = list(via_cells) via_cells = list(via_cells)
if isinstance(conn.a_end, ConductionArea): if isinstance(conn.a_end, ConductionArea):
@@ -800,7 +840,9 @@ class GridRouter:
if len(wire_segs) > 1: if len(wire_segs) > 1:
for idx, pin in ((0, conn.a_end), (len(wire_segs) - 1, conn.b_end)): for idx, pin in ((0, conn.a_end), (len(wire_segs) - 1, conn.b_end)):
layer, cells = wire_segs[idx] layer, cells = wire_segs[idx]
if isinstance(pin, Pin) and len(cells) == 1 and (*cells[0], layer) in self._keepout: node = (*cells[0], layer)
walled = node in self._keepout or node in self._via_only
if isinstance(pin, Pin) and len(cells) == 1 and walled:
in_pad[idx] = pin.location in_pad[idx] = pin.location
conn.wires = [] conn.wires = []
for idx, (layer, cells) in enumerate(wire_segs): for idx, (layer, cells) in enumerate(wire_segs):
@@ -950,11 +992,41 @@ class GridRouter:
path = self._search(conn, allow_ripup=True, conns=conns) path = self._search(conn, allow_ripup=True, conns=conns)
if path is None: if path is None:
return False return False
for idx in sorted(self._crossed_owners(path, conn.net_no, conns)): victims = [conns[i] for i in sorted(self._crossed_owners(path, conn.net_no, conns))]
self._rip(conns[idx]) if self.rollback:
return self._ripup_transaction(conn, path, victims, conns)
for victim in victims:
self._rip(victim)
self._commit(conn, path) self._commit(conn, path)
return True return True
def _ripup_transaction(self, conn, path, victims, conns) -> bool:
"""Route ``conn`` through ``victims``, reroute them at once in free
space, and undo it all if fewer connections end up routed."""
saved = [(v, v.path, v.a_end, v.b_end, v.rip_count) for v in victims]
own_rips = conn.rip_count
for victim in victims:
self._rip(victim)
self._commit(conn, path)
rerouted = 0
for victim in victims:
again = self._search(victim, allow_ripup=False, conns=conns)
if again is not None:
self._commit(victim, again)
rerouted += 1
if 1 + rerouted >= len(victims):
return True # a gain, or an even trade that may pay off later
for victim in victims:
if victim.routed:
self._rip(victim)
self._rip(conn)
conn.rip_count = own_rips
for victim, old_path, a_end, b_end, rips in saved:
victim.a_end, victim.b_end = a_end, b_end
self._commit(victim, old_path)
victim.rip_count = rips
return False
def _build_connections(self) -> list[_Connection]: def _build_connections(self) -> list[_Connection]:
pins_by_net = self._pins_by_net() pins_by_net = self._pins_by_net()
zones_by_net: dict[int, list] = {} zones_by_net: dict[int, list] = {}
@@ -1158,6 +1230,7 @@ def route_board(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> RouteResult: ) -> RouteResult:
"""Convenience wrapper: build a :class:`GridRouter` and route the board.""" """Convenience wrapper: build a :class:`GridRouter` and route the board."""
router = GridRouter( router = GridRouter(
@@ -1176,5 +1249,6 @@ def route_board(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
return router.route() return router.route()
+8
View File
@@ -83,6 +83,7 @@ def route_dsn_board(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]: ) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer and the layer names. ``layers`` restricts routing to those signal-layer
@@ -106,6 +107,7 @@ def route_dsn_board(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
return result, scale, [layer.name for layer in dsn.layers] return result, scale, [layer.name for layer in dsn.layers]
@@ -149,6 +151,7 @@ def build_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board( route, scale, layer_names = route_dsn_board(
@@ -156,6 +159,7 @@ def build_routing_result(
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
return _to_routing_result(route, dsn, scale, layer_names) return _to_routing_result(route, dsn, scale, layer_names)
@@ -174,6 +178,7 @@ def route_dsn_board_exact(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names. DRC-verified) track; return the exact result, the scale, and layer names.
@@ -201,6 +206,7 @@ def route_dsn_board_exact(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
@@ -219,6 +225,7 @@ def build_exact_routing_result(
seed: int | None = None, seed: int | None = None,
route_first: frozenset | None = None, route_first: frozenset | None = None,
access_cost: float = 0.0, access_cost: float = 0.0,
rollback: bool = True,
) -> RoutingResult: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact( exact, scale, layer_names = route_dsn_board_exact(
@@ -234,6 +241,7 @@ def build_exact_routing_result(
seed=seed, seed=seed,
route_first=route_first, route_first=route_first,
access_cost=access_cost, access_cost=access_cost,
rollback=rollback,
) )
return _to_routing_result(exact.result, dsn, scale, layer_names) return _to_routing_result(exact.result, dsn, scale, layer_names)
+34
View File
@@ -254,3 +254,37 @@ def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []
+34
View File
@@ -62,3 +62,37 @@ def test_has_violation_ignores_static_pad_pairs():
# a routed trace of net 3 within clearance of pad net 1 -> violation # a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True)) tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None assert tree.has_violation(clearance=20) is not None
def test_a_rule_area_conflicts_only_on_overlap():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
near = IntBox(0, 0, 95, 100) # 5 short of the area, well inside clearance 50
over = IntBox(0, 0, 105, 100)
assert not tree.clearance_conflict(near, 7, 0, 50)
assert tree.clearance_conflict(over, 7, 0, 50)
def test_an_ordinary_keepout_still_needs_clearance():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100)))
assert tree.clearance_conflict(IntBox(0, 0, 95, 100), 7, 0, 50)
def test_the_routed_board_check_treats_a_rule_area_the_same_way():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, 7, frozenset({0}), IntBox(0, 0, 95, 100), routed=True))
assert tree.has_violation(50) is None
tree.insert(TreeShape(3, 7, frozenset({0}), IntBox(150, 0, 160, 100), routed=True))
assert tree.has_violation(50) is not None
def test_rule_area_overlap_sees_only_rule_areas():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, -1, frozenset({0}), IntBox(300, 0, 400, 100)))
assert tree.rule_area_overlap(IntBox(90, 40, 110, 60), 0)
assert not tree.rule_area_overlap(IntBox(290, 40, 310, 60), 0)
assert not tree.rule_area_overlap(IntBox(90, 40, 110, 60), 1)
+23
View File
@@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords():
place = board.placements[0].places[0] place = board.placements[0].places[0]
assert place.name == "U9" assert place.name == "U9"
assert place.x is None and place.y is None assert place.x is None and place.y is None
# --- string_quote ----------------------------------------------------------
_QUOTED = """(pcb q.dsn
(parser (string_quote {q}) (space_in_quoted_tokens on))
(resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
(library)
(network)
)"""
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
# KiCad declares " and writes a keycap's part number as a bare '
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
assert board.parser.string_quote == '"'
def test_the_declared_quote_character_is_the_one_that_opens_strings():
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
assert board.parser.string_quote == "'"
+43
View File
@@ -0,0 +1,43 @@
"""--rollback: a rip-up that loses connections is undone, leaving no trace."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _routed_router(name):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
rollback=True,
)
return router, router.route()
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_rollback_never_routes_fewer_than_plain_rip_up(name):
text = (FIXTURES / name).read_text()
plain, _, _ = route_dsn_board(parse_dsn(text), rollback=False)
undo, _, _ = route_dsn_board(parse_dsn(text))
assert undo.unrouted <= plain.unrouted
+1
View File
@@ -158,6 +158,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"orders": ["--orders", "2"], "orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"], "promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"], "access": ["--access-cost", "5"],
"rollback": ["--no-rollback"],
} }
for engine, options in ENGINE_OPTIONS.items(): for engine, options in ENGINE_OPTIONS.items():
for option in options: for option in options: