diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 1dd5bf9..921a444 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -148,6 +148,7 @@ class GridRouter: # orthogonal routing (axis-aligned segments only) yields exact IntBox # trace copper; 8-connected also produces 45-degree segments. self._neighbours = _NEIGHBOURS[:4] if orthogonal else _NEIGHBOURS + self._orthogonal = orthogonal # cell size must fit a trace plus its clearance to a neighbouring trace self.step = max(self.trace_width + self.clearance, 1) # A via's copper is wider than a trace. Another net's trace needs its @@ -332,14 +333,11 @@ class GridRouter: def _search( self, conn: _Connection, allow_ripup: bool, conns: list[_Connection] ) -> list[Node] | None: - start_cell = self._cell_of(conn.a.location) goal_cell = self._cell_of(conn.b.location) - a_layers = [layer for layer in conn.a.layers if layer in self.layers] - b_layers = [layer for layer in conn.b.layers if layer in self.layers] - if not a_layers or not b_layers: + starts = self._end_nodes(conn.a, conn.net_no, conns) + goals = self._end_nodes(conn.b, conn.net_no, conns) + if not starts or not goals: return None - starts = {(*start_cell, layer) for layer in a_layers} - goals = {(*goal_cell, layer) for layer in b_layers} if starts & goals: return [min(starts & goals)] net_no = conn.net_no @@ -347,9 +345,11 @@ class GridRouter: open_heap: list[tuple[float, Node]] = [] g_score: dict[Node, float] = {} came_from: dict[Node, Node] = {} + # a stub costs its length, so the nearest cell wins unless it is blocked + goal_stub = {n: self._stub_cost(conn.b, n) for n in goals} for s in sorted(starts): - g_score[s] = 0.0 - heapq.heappush(open_heap, (self._h(s, goal_cell), s)) + g_score[s] = self._stub_cost(conn.a, s) + heapq.heappush(open_heap, (g_score[s] + self._h(s, goal_cell), s)) while open_heap: _, current = heapq.heappop(open_heap) @@ -363,7 +363,7 @@ class GridRouter: continue nxt = (*cell, cl) if nxt in goals: - enter = 0.0 + enter = goal_stub[nxt] else: enter = self._enter_cost(cell, cl, net_no, allow_ripup, conns) if enter is None: @@ -382,6 +382,65 @@ class GridRouter: ) return None + # --- pin stubs ---------------------------------------------------------- + # A pin rarely sits on a cell centre, so every route starts and ends with a + # short stub from the pin to its first cell. The grid has to see that + # copper too, or a stub ends up closer than clearance to a trace in the + # cell beside it. + + def _stub_cells(self, pin_location: IntPoint, cell: Cell) -> list[Cell]: + """Cells where another net's trace would come too close to the stub.""" + from freeroute.board.outline import _point_segment + + end = self._cell_center(cell) + reach = 2 * self.half_width + self.clearance + box = IntBox( + min(pin_location.x, end.x), min(pin_location.y, end.y), + max(pin_location.x, end.x), max(pin_location.y, end.y), + ) + return [ + c + for c in self._cells_in_box(box, 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: + for c in self._stub_cells(pin.location, cell): + owner = self._cell_owner.get((*c, layer)) + if owner is not None and conns[owner].net_no != net_no: + return False + via_owner = self._via_owner.get(c) + if via_owner is not None and conns[via_owner].net_no != net_no: + return False + return True + + def _stub_cost(self, pin: Pin, node: Node) -> float: + c = self._cell_center(node[:2]) + dx, dy = abs(c.x - pin.location.x), abs(c.y - pin.location.y) + if self._orthogonal: # measured the way the route itself is allowed to move + return (dx + dy) / self.step + return math.hypot(dx, dy) / self.step + + def _end_nodes(self, pin: Pin, net_no: int, conns) -> set[Node]: + """Nodes a route may start or end on for ``pin``: the nearest cell and + its neighbours, where the cell is enterable and the stub to it is clear. + A crowded fine-pitch pin gets more than one way out.""" + layers = [layer for layer in pin.layers if layer in self.layers] + cx, cy = self._cell_of(pin.location) + nodes: set[Node] = set() + for dx, dy in ((0, 0), *self._neighbours): + cell = (cx + dx, cy + dy) + if not (0 <= cell[0] < self.cols and 0 <= cell[1] < self.rows): + continue + for layer in layers: + if (dx or dy) and self._enter_cost(cell, layer, net_no, False, conns) is None: + continue + if self._stub_clear(pin, cell, layer, net_no, conns): + nodes.add((*cell, layer)) + if not nodes: # nothing clear: fall back to the nearest cell, as before + nodes = {(cx, cy, layer) for layer in layers} + return nodes + def _relax(self, current, nxt, tentative, goal_cell, g_score, came_from, open_heap): if tentative < g_score.get(nxt, math.inf): came_from[nxt] = current @@ -405,6 +464,13 @@ class GridRouter: if c not in self._via_owner: self._via_owner[c] = conn.index conn.via_halo.append(c) + for pin, (layer, cells) in ((conn.a, wire_segs[0]), (conn.b, wire_segs[-1])) if wire_segs else (): + cell = cells[0] if pin is conn.a else cells[-1] + for c in self._stub_cells(pin.location, cell): + node = (*c, layer) + if node not in self._cell_owner: + self._cell_owner[node] = conn.index + conn.trace_cells.append((c, layer)) conn.wires = [] for idx, (layer, cells) in enumerate(wire_segs): points = [self._cell_center(c) for c in cells] diff --git a/tests/route/test_real_board_geometry.py b/tests/route/test_real_board_geometry.py new file mode 100644 index 0000000..617ec0c --- /dev/null +++ b/tests/route/test_real_board_geometry.py @@ -0,0 +1,66 @@ +"""Routing behaviours found on the TerraHawk rc4 boards (2026-09-27).""" + +from __future__ import annotations + +import math + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.route.pipeline import route_dsn_board_exact + +# 20 x 20 mm board whose corners are rounded with a 3 mm radius, as polylines +_R = 3000.0 +_arc = [(_R - _R * math.cos(math.radians(a)), _R - _R * math.sin(math.radians(a))) for a in range(0, 91, 15)] + + +def _outline_coords(): + pts = [] + for cx, cy, sx, sy in ((0, 0, 1, 1), (20000, 0, -1, 1), (20000, -20000, -1, -1), (0, -20000, 1, -1)): + corner = [(cx + sx * x, cy - sy * y) for x, y in _arc] + pts.extend(corner if sx * sy > 0 else corner[::-1]) + return " ".join(f"{x:.1f} {y:.1f}" for x, y in pts) + + +def _dsn(placement, network, extra=""): + return f"""(pcb t (parser) (resolution um 10) (unit um) + (structure (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 {_outline_coords()})) + (via "Via[0-1]_600:300_um") (rule (width 250) (clearance 150)) {extra}) + (placement {placement}) + (library (image P (pin pad 1 0 0)) + (padstack pad (shape (rect F.Cu -150 -500 150 500)) (attach off)) + (padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off))) + (network {network}))""" + + +def test_routes_keep_edge_clearance_at_a_rounded_corner(): + # two pads near the top-left corner: the direct path cuts the corner arc + text = _dsn("(component P (place A 1800 -6000 front 0) (place B 6000 -1800 front 90))", + "(net N (pins A-1 B-1))") + dsn = parse_dsn(text) + result, scale, _ = route_dsn_board_exact(dsn, edge_clearance_mm=0.5) + assert result.routed_net_numbers + outline = build_board(dsn).outline + half = 125 * scale + for segs in result.result.wires.values(): + for _layer, pts in segs: + for p in pts: + # every trace point, plus half the trace, keeps 0.5 mm to the edge + assert outline.edge_distance(p) >= half + 500 * scale - 1 + + +@pytest.mark.xfail( + strict=True, + reason="known gap: the grid step (trace + clearance, 0.4 mm here) does not line up " + "with a 0.5 mm pin row, so only the outer pins get a column; the FreeRouting JAR, " + "being gridless, routes all five. This is most of the gap on an LQFP.", +) +def test_every_pin_of_a_half_millimetre_pitch_row_escapes(): + # five pads at 0.5 mm pitch, each wired to its own pad 8 mm away + places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5)) + places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5)) + nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5)) + result, _, _ = route_dsn_board_exact(parse_dsn(_dsn(f"(component P {places})", nets))) + assert len(result.routed_net_numbers) == 5