From c1df28414757617cff2e652ff4692bc114944a8d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Sun, 27 Sep 2026 07:26:18 -0600 Subject: [PATCH] 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. --- src/freeroute/board/board.py | 4 +- src/freeroute/board/build.py | 13 +++ src/freeroute/board/outline.py | 122 ++++++++++++++++++++++++++++ src/freeroute/cli.py | 18 +++- src/freeroute/route/exact_router.py | 29 +++++-- src/freeroute/route/grid_router.py | 76 +++++++++++++++-- src/freeroute/route/pipeline.py | 51 ++++++++++-- src/freeroute/route/room_router.py | 22 +++-- 8 files changed, 303 insertions(+), 32 deletions(-) create mode 100644 src/freeroute/board/outline.py diff --git a/src/freeroute/board/board.py b/src/freeroute/board/board.py index 784a302..cbfe0ce 100644 --- a/src/freeroute/board/board.py +++ b/src/freeroute/board/board.py @@ -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 ------------------------------------------------------------- diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index 8b1dc5b..640be84 100644 --- a/src/freeroute/board/build.py +++ b/src/freeroute/board/build.py @@ -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) diff --git a/src/freeroute/board/outline.py b/src/freeroute/board/outline.py new file mode 100644 index 0000000..025978c --- /dev/null +++ b/src/freeroute/board/outline.py @@ -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 diff --git a/src/freeroute/cli.py b/src/freeroute/cli.py index 539ee34..e5e10fb 100644 --- a/src/freeroute/cli.py +++ b/src/freeroute/cli.py @@ -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) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 6053a8a..522cf90 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -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 diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 6225bdc..1dd5bf9 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -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() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 482a64f..60e92f0 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -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) diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py index 91a1e57..2156148 100644 --- a/src/freeroute/route/room_router.py +++ b/src/freeroute/route/room_router.py @@ -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.