From 2d686178af4643950905075cd3424d63c8164991 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 29 Sep 2026 09:52:35 -0600 Subject: [PATCH] --fanout: commit a way out for every fine-pitch pin before routing (work in progress) --- src/freeroute/board/items.py | 4 + src/freeroute/cli.py | 18 ++- src/freeroute/route/fanout.py | 206 +++++++++++++++++++++++++++++ src/freeroute/route/grid_router.py | 26 +++- src/freeroute/route/pipeline.py | 46 +++++++ tests/test_cli.py | 1 + 6 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 src/freeroute/route/fanout.py diff --git a/src/freeroute/board/items.py b/src/freeroute/board/items.py index 8a0319c..0c04e67 100644 --- a/src/freeroute/board/items.py +++ b/src/freeroute/board/items.py @@ -119,6 +119,8 @@ class Via(Item): shape: object = None #: drill diameter in board units, when the padstack name gives it drill: int = 0 + #: placed by the router's fan-out, not on the board yet: written out + generated: bool = False @dataclass @@ -132,6 +134,8 @@ class Trace(Item): layer: int = 0 half_width: int = 0 corners: list[IntPoint] = field(default_factory=list) + #: placed by the router's fan-out, not on the board yet: written out + generated: bool = False def is_empty(self) -> bool: return len(self.corners) < 2 diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 07163c6..f32d386 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -30,8 +30,11 @@ from freeroute.ses import write_ses #: which optional flags each engine actually implements. Anything not listed for #: an engine is rejected by :func:`_validate` rather than quietly ignored. ENGINE_OPTIONS: dict[str, frozenset[str]] = { - "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}), - "exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}), + "grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback", "fanout"}), + "exact": frozenset( + {"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", + "rollback", "fanout"} + ), "room": frozenset({"shove", "pack"}), } @@ -47,6 +50,7 @@ _FLAG_NAMES = { "promote": "--promote-failures", "access": "--access-cost", "rollback": "--no-rollback", + "fanout": "--fanout", } @@ -183,6 +187,13 @@ def build_parser() -> argparse.ArgumentParser: help="grid and exact engines: least distance between the edges of any two via " "holes, of one net or two (KiCad's min_hole_to_hole; default 0.25)", ) + parser.add_argument( + "--fanout", + action="store_true", + help="grid and exact engines: before routing, give every fine-pitch pin a stub " + "outward past its pad row (and a via to its plane, for a plane net), committed " + "as copper so later routes cannot wall it in", + ) parser.add_argument( "--jobs", type=int, @@ -213,6 +224,7 @@ def main(argv: list[str] | None = None) -> int: ("promote", args.promote_failures is not None), ("access", args.access_cost is not None), ("rollback", not args.rollback), + ("fanout", args.fanout), ) if asked ] @@ -248,6 +260,7 @@ def main(argv: list[str] | None = None) -> int: "heuristic_weight": args.heuristic_weight, "access_cost": args.access_cost or 0.0, "hole_to_hole_mm": args.hole_to_hole, + "fanout": args.fanout, "rollback": args.rollback, } elif args.engine == "grid": @@ -259,6 +272,7 @@ def main(argv: list[str] | None = None) -> int: "resolution": 1 if args.grid_resolution is None else args.grid_resolution, "access_cost": args.access_cost or 0.0, "hole_to_hole_mm": args.hole_to_hole, + "fanout": args.fanout, "rollback": args.rollback, } if args.engine == "room": diff --git a/src/freeroute/route/fanout.py b/src/freeroute/route/fanout.py new file mode 100644 index 0000000..aaaa41f --- /dev/null +++ b/src/freeroute/route/fanout.py @@ -0,0 +1,206 @@ +"""Fan out fine-pitch pins before routing: commit each pin's way out as copper. + +On a dense board the pins of a fine-pitch package get walled in: their own +neighbours' pads on either side, and other nets' copper that got there first +across the only way out. Reserving the way out did not help (the reserved +cells still had to be routed, and lost to congestion there); committing it +does, which is what a person does by hand. + +Each fine-pitch pin of a perimeter package (QFP, QFN, LGA: pads on the edge, +none inside) gets a straight stub outward, perpendicular to its side of the +package, just past its pad row. A pin whose net has an inner plane also gets +a via at the stub's end, staggered between neighbouring pins so their holes +keep their spacing, and so reaches the plane with nothing left to route. +Every stub and via is checked exactly against pads, keepouts, the board edge, +copper already on the board and earlier fan-out, and left out where it would +not fit. The result goes on the board as fixed copper of its net (marked +``generated``), so the router starts from the stub's end or the via, and the +session writes it out with the routes. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +from freeroute.board.board import BasicBoard +from freeroute.board.items import Pin, Trace, Via +from freeroute.geometry import IntBox, IntPoint +from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box + +# a package is fanned out when it has this many pins and they are this close +_MIN_PINS = 8 +_FINE_PITCH_FACTOR = 1.2 # neighbour centres within this many (trace + 2 clearance) + + +@dataclass +class FanoutRules: + trace_width: int + clearance: int + via_half: int + via_drill: int + hole_to_hole: int + edge_clearance: int + signal_layers: list[int] + via_padstack: str + + +def plan_fanout(board: BasicBoard, rules: FanoutRules) -> list: + """The stubs and vias to put on the board before routing.""" + half = max(rules.trace_width // 2, 1) + # a net may have several plane zones (a main plane and an island) + planes: dict[int, list] = {} + for z in board.get_conduction_areas(): + if z.net_nos and not board.layer_structure.arr[z.layer].is_signal: + planes.setdefault(z.net_nos[0], []).append(z) + placed = _Placed(board, rules) + items: list = [] + for pins in _packages(board, rules).values(): + centre = IntPoint( + sum(p.location.x for p in pins) // len(pins), sum(p.location.y for p in pins) // len(pins) + ) + for k, pin in enumerate(sorted(pins, key=lambda p: (p.location.x, p.location.y))): + net = pin.net_nos[0] if pin.net_nos else None + if net is None: + continue + layer = next(layer for layer in pin.layers if layer in rules.signal_layers) + direction = _outward(pin.location, centre) + reach = _pad_reach(pin, direction) + rules.clearance + half + rules.trace_width + end = _step(pin.location, direction, reach) + plane = planes.get(net) + if plane is not None: + # stagger the via of every other pin along the side outward + pitch = 2 * rules.via_half + rules.hole_to_hole + options = [reach + rules.via_half + rules.clearance + (k % 2) * pitch, + reach + rules.via_half + rules.clearance + ((k + 1) % 2) * pitch] + for d in options: + at = _step(pin.location, direction, d) + if not any(z.polygon is not None and z.polygon.contains(at) for z in plane): + continue + if placed.stub_fits(pin.location, at, layer, net, half) and placed.via_fits(at, net): + items.append(placed.add_stub(pin.location, at, layer, net, half)) + items.append(placed.add_via(at, net)) + break + continue + if placed.stub_fits(pin.location, end, layer, net, half): + items.append(placed.add_stub(pin.location, end, layer, net, half)) + return items + + +def _packages(board: BasicBoard, rules: FanoutRules) -> dict[str, list[Pin]]: + """Fine-pitch perimeter packages: many single-layer pins, close together.""" + by_ref: dict[str, list[Pin]] = {} + for pin in board.get_pins(): + if pin.shape is None or not pin.component_ref: + continue + on = [layer for layer in pin.layers if layer in rules.signal_layers] + if len(on) != 1: + continue # through-hole pins change layer for free + by_ref.setdefault(pin.component_ref, []).append(pin) + fine = _FINE_PITCH_FACTOR * (rules.trace_width + 2 * rules.clearance) + out = {} + for ref, pins in by_ref.items(): + if len(pins) < _MIN_PINS: + continue + close = sum( + 1 for p in pins + if min(math.hypot(p.location.x - q.location.x, p.location.y - q.location.y) + for q in pins if q is not p) < fine * 2 + ) + if close >= len(pins) // 2: + out[ref] = pins + return out + + +def _outward(point: IntPoint, centre: IntPoint) -> tuple[int, int]: + """Away from the package centre, along the nearer axis (its side's normal).""" + dx, dy = point.x - centre.x, point.y - centre.y + if abs(dx) >= abs(dy): + return (1 if dx >= 0 else -1, 0) + return (0, 1 if dy >= 0 else -1) + + +def _pad_reach(pin: Pin, direction: tuple[int, int]) -> int: + """How far the pad reaches from its centre in ``direction``.""" + box = pin.shape.bounding_box() + if direction[0] > 0: + return box.ur.x - pin.location.x + if direction[0] < 0: + return pin.location.x - box.ll.x + if direction[1] > 0: + return box.ur.y - pin.location.y + return pin.location.y - box.ll.y + + +def _step(point: IntPoint, direction: tuple[int, int], distance: float) -> IntPoint: + return IntPoint(point.x + round(direction[0] * distance), point.y + round(direction[1] * distance)) + + +class _Placed: + """Copper a fan-out stub or via has to clear, growing as fan-out is placed.""" + + def __init__(self, board: BasicBoard, rules: FanoutRules) -> None: + self.board, self.rules = board, rules + self.copper: list[tuple[IntBox, frozenset[int], int]] = [] # box, layers, net + for pin in board.get_pins(): + if pin.shape is not None: + net = pin.net_nos[0] if pin.net_nos else -1 + self.copper.append((pin.shape.bounding_box(), frozenset(pin.layers), net)) + for net, layers, box in board.get_fixed_copper(): + self.copper.append((box, layers, net)) + self.keepouts = [ + (tile.bounding_box(), o.layer, o.blocks_wires, o.blocks_vias, o.clearance) + for o in board.get_obstacle_areas() for tile in o.tiles + ] + self.holes: list[tuple[IntPoint, int]] = [] # centre, radius + for item in board.get_items(): + if isinstance(item, Via) and item.fixed: + self.holes.append((item.location, (item.drill or rules.via_drill) // 2)) + for pin in board.get_pins(): + if pin.shape is not None and sum(1 for layer in pin.layers if layer in rules.signal_layers) > 1: + b = pin.shape.bounding_box() + self.holes.append((pin.location, min(b.ur.x - b.ll.x, b.ur.y - b.ll.y) // 2)) + + def _clear(self, box: IntBox, layers: frozenset[int], net: int, kind: str) -> bool: + grown = box.offset(self.rules.clearance) + for other, other_layers, other_net in self.copper: + if other_net != net and layers & other_layers and other.overlaps(grown): + return False + for kbox, klayer, wires, vias, needs in self.keepouts: + if klayer not in layers or not (wires if kind == "wire" else vias): + continue + if kbox.overlaps(grown if needs else box): + return False + outline = self.board.outline + return outline is None or outline.box_clear(box, self.rules.edge_clearance) + + def stub_fits(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> bool: + boxes = list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)] + return all(self._clear(box, frozenset({layer}), net, "wire") for box in boxes) + + def via_fits(self, at: IntPoint, net: int) -> bool: + h = self.rules.via_half + box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h) + if not self._clear(box, frozenset(self.rules.signal_layers), net, "via"): + return False + mine = self.rules.via_drill // 2 + return all( + math.hypot(c.x - at.x, c.y - at.y) >= mine + r + self.rules.hole_to_hole + for c, r in self.holes + ) + + def add_stub(self, a: IntPoint, b: IntPoint, layer: int, net: int, half: int) -> Trace: + for box in list(PolylineShape(Polyline([a, b]), half).tiles()) + [corner_box(b, half)]: + self.copper.append((box, frozenset({layer}), net)) + return Trace(net_nos=[net], fixed=True, layer=layer, half_width=half, corners=[a, b], + generated=True) + + def add_via(self, at: IntPoint, net: int) -> Via: + h = self.rules.via_half + box = IntBox(at.x - h, at.y - h, at.x + h, at.y + h) + layers = list(self.rules.signal_layers) + self.copper.append((box, frozenset(layers), net)) + self.holes.append((at, self.rules.via_drill // 2)) + return Via(net_nos=[net], fixed=True, padstack_name=self.rules.via_padstack, + location=at, layers=layers, shape=box, drill=self.rules.via_drill, + generated=True) diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 96facb7..adc6d08 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -1360,7 +1360,7 @@ class GridRouter: elif isinstance(item, Trace) and not item.is_empty(): boxes = list(PolylineShape(Polyline(item.corners), item.half_width).tiles()) boxes += [corner_box(e, item.half_width) for e in (item.corners[0], item.corners[-1])] - piece = _FixedPiece([item.layer], boxes) + piece = _FixedPiece([item.layer], boxes, trace=item) else: continue cache.setdefault(item.net_nos[0], []).append(piece) @@ -1482,12 +1482,13 @@ def _fixed_joins_zone(piece, zone, plane: bool) -> bool: class _FixedPiece: """One track or via already on the board, as connectivity.""" - __slots__ = ("layers", "boxes", "via") + __slots__ = ("layers", "boxes", "via", "trace") - def __init__(self, layers, boxes, via=None) -> None: + def __init__(self, layers, boxes, via=None, trace=None) -> None: self.layers = frozenset(layers) self.boxes = boxes self.via = via # the Via item, for a via + self.trace = trace # the Trace item, for a track def _connected_groups( @@ -1548,11 +1549,28 @@ def _connected_groups( groups[root][1].append(zone) for k, piece in enumerate(fixed): root = find(base + k) - if piece.via is not None and root in groups: + if root not in groups: + continue + if piece.via is not None: groups[root][0].append(_via_end(piece.via)) + elif piece.trace is not None: + # a track's ends are places a route may start: a fan-out stub's + # far end is outside the pad row the pin sat walled in by + for end in (piece.trace.corners[0], piece.trace.corners[-1]): + groups[root][0].append(_trace_end(piece.trace, end)) return [groups[r] for r in order] +def _trace_end(trace, end: IntPoint) -> Pin: + """An end of a fixed track as a place a route may start or end.""" + from freeroute.geometry.polyline import corner_box + + return Pin( + net_nos=list(trace.net_nos), fixed=True, name="track", component_ref="", + location=end, layers=[trace.layer], shape=corner_box(end, trace.half_width), + ) + + def _via_end(via) -> Pin: """A fixed via as a place a route may start or end (not a pad to route).""" return Pin( diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index d5b770a..4ff9c6e 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -88,6 +88,38 @@ def _dsn_units_per_um(dsn: DsnBoard) -> float: return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0) +def _place_fanout(board, dsn, scale, layers, width, clearance, edge, hole_to_hole_mm) -> None: + """Put the fan-out's stubs and vias on the board, before routing.""" + from .fanout import FanoutRules, plan_fanout + + via_half = _via_half_board(dsn, scale) or max(width // 2, 1) + rules = FanoutRules( + trace_width=width, + clearance=clearance, + via_half=via_half, + via_drill=_via_drill_board(dsn, scale) or 2 * via_half, + hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, + edge_clearance=edge or 0, + signal_layers=layers or [i for i, layer in enumerate(dsn.layers) if layer.is_signal], + via_padstack=_via_padstack(dsn), + ) + for item in plan_fanout(board, rules): + board.insert(item) + + +def _add_generated(result, board) -> None: + """The fan-out's copper goes into the session with the routes.""" + from freeroute.board.items import Trace, Via + + for item in board.get_items(): + if not getattr(item, "generated", False) or not item.net_nos: + continue + if isinstance(item, Trace): + result.add_wire(item.net_nos[0], item.layer, list(item.corners)) + elif isinstance(item, Via): + result.add_via(item.net_nos[0], item.location) + + 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. @@ -119,6 +151,7 @@ def route_dsn_board( route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, + fanout: bool = False, rollback: bool = True, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, @@ -130,6 +163,9 @@ def route_dsn_board( 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) + if fanout: + _place_fanout(board, dsn, scale, layers, width_board, clearance_board, + edge_clearance_board, hole_to_hole_mm) result = route_board( board, resolution=resolution, @@ -148,6 +184,7 @@ def route_dsn_board( via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, ) + _add_generated(result, board) return result, scale, [layer.name for layer in dsn.layers] @@ -197,6 +234,7 @@ def build_routing_result( route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, + fanout: bool = False, rollback: bool = True, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" @@ -206,6 +244,7 @@ def build_routing_result( route_first=route_first, access_cost=access_cost, hole_to_hole_mm=hole_to_hole_mm, + fanout=fanout, rollback=rollback, ) return _to_routing_result(route, dsn, scale, layer_names) @@ -226,6 +265,7 @@ def route_dsn_board_exact( route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, + fanout: bool = False, rollback: bool = True, ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, @@ -238,6 +278,9 @@ def route_dsn_board_exact( 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) + if fanout: + _place_fanout(board, dsn, scale, layers, width_board, clearance_board, + edge_clearance_board, hole_to_hole_mm) exact = route_board_exact( board, resolution=resolution, @@ -259,6 +302,7 @@ def route_dsn_board_exact( via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, ) + _add_generated(exact.result, board) return exact, scale, [layer.name for layer in dsn.layers] @@ -277,6 +321,7 @@ def build_exact_routing_result( route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, + fanout: bool = False, rollback: bool = True, ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" @@ -294,6 +339,7 @@ def build_exact_routing_result( route_first=route_first, access_cost=access_cost, hole_to_hole_mm=hole_to_hole_mm, + fanout=fanout, rollback=rollback, ) return _to_routing_result(exact.result, dsn, scale, layer_names) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5e56191..72451b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -159,6 +159,7 @@ def test_cli_supported_combinations_are_accepted(tmp_path): "promote": ["--promote-failures", "2"], "access": ["--access-cost", "5"], "rollback": ["--no-rollback"], + "fanout": ["--fanout"], } for engine, options in ENGINE_OPTIONS.items(): for option in options: