diff --git a/bench/diagnose.py b/bench/diagnose.py index aa9cbcc..fb51a78 100644 --- a/bench/diagnose.py +++ b/bench/diagnose.py @@ -32,8 +32,10 @@ from freeroute.route.exact_router import route_board_exact from freeroute.route.grid_router import GridRouter, _ends_of from freeroute.route.pipeline import ( _mm_to_board, + _net_widths_board, _rule_clearance_dsn, _rule_width_dsn, + _via_drill_board, _via_half_board, ) @@ -183,7 +185,7 @@ def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]: def diagnose( dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False, - access_cost: float = 0.0, + access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, ) -> dict: dsn = parse_dsn(dsn_text) names = {i + 1: n.name for i, n in enumerate(dsn.nets)} @@ -199,6 +201,10 @@ def diagnose( resolution=resolution, shove=shove, access_cost=access_cost, + # the product's configuration: net-class widths and hole spacing + net_widths=_net_widths_board(dsn, scale), + via_drill=_via_drill_board(dsn, scale), + hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, ) routed = seen["router"] best = routed._diag_best @@ -270,11 +276,12 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--grid-resolution", type=int, default=2, metavar="K") ap.add_argument("--shove", action="store_true", help="as mckicad routes") ap.add_argument("--access-cost", type=float, default=0.0) + ap.add_argument("--hole-to-hole", type=float, default=0.25, metavar="MM") ap.add_argument("--json", type=Path, help="also write the full table here") args = ap.parse_args(argv) report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove, - args.access_cost) + args.access_cost, args.hole_to_hole) for row in report["failures"]: extra = "" for w in row.get("walled", []): diff --git a/src/freeroute/board/build.py b/src/freeroute/board/build.py index 37e301d..4bd2db7 100644 --- a/src/freeroute/board/build.py +++ b/src/freeroute/board/build.py @@ -268,15 +268,39 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor for x, y in corners: p = _to_board_offset(place, x, y, dsn_pin.rotation) pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y)))) - if len(pts) >= 3: - simplex = Simplex.from_corners(pts) - if not simplex.is_empty(): - return simplex + # An oval's padded corners are not in outline order, and a mirrored + # part reverses the winding: take the convex hull, then either winding. + hull = _convex_hull(pts) + if len(hull) >= 3: + for corners in (hull, hull[::-1]): + simplex = Simplex.from_corners(corners) + if not simplex.is_empty(): + return simplex xs = [p.x for p in pts] or [0] ys = [p.y for p in pts] or [0] return IntBox(min(xs), min(ys), max(xs), max(ys)) +def _convex_hull(pts: list[IntPoint]) -> list[IntPoint]: + """The convex hull of ``pts`` counter-clockwise, collinear points dropped.""" + ps = sorted({(p.x, p.y) for p in pts}) + if len(ps) < 3: + return [IntPoint(x, y) for x, y in ps] + + def cross(o, a, b): + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + def half(points): + out: list[tuple[int, int]] = [] + for p in points: + while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0: + out.pop() + out.append(p) + return out[:-1] + + return [IntPoint(x, y) for x, y in half(ps) + half(ps[::-1])] + + def _pad_tile(shape: Shape, transform: CoordinateTransform): """A board-relative pad tile from a DSN padstack shape (centred near origin).""" if isinstance(shape, Rectangle): diff --git a/src/freeroute/board/search_tree.py b/src/freeroute/board/search_tree.py index f2b8bb6..db37279 100644 --- a/src/freeroute/board/search_tree.py +++ b/src/freeroute/board/search_tree.py @@ -188,6 +188,8 @@ class ShapeSearchTree: overlap). Static pad-vs-pad spacing in the source design is not counted. """ for shape in self.all_shapes(): + if not shape.routed: + continue # a pair with routed copper is checked from that side expanded = shape.tile.offset(clearance) grown = None # exact clearance region, built lazily only for diagonals for other in self.overlapping(expanded): diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 112079b..4db2f55 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -38,6 +38,7 @@ from freeroute.geometry import ( IntPoint, Polyline, PolylineShape, + Simplex, segment_box, segment_octagon, shove_segment, @@ -154,7 +155,9 @@ def route_board_exact( net = pin.net_nos[0] if pin.net_nos else -1 pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers) if pin_layers: - tree.insert(TreeShape(next(owner), net, pin_layers, box)) + # a rotated pad is checked against its outline, not its box + exact = pin.shape if _is_rotated(pin.shape) else None + tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact)) for net, copper_layers, box in board.get_fixed_copper(): on = frozenset(layer for layer in copper_layers if layer in signal_layers) if on and not box.is_empty(): @@ -667,6 +670,10 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None): return boxes +def _is_rotated(shape) -> bool: + return isinstance(shape, Simplex) and not shape.is_int_box() + + def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]: return [ pin diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py index 9056bae..47c3ca6 100644 --- a/src/freeroute/route/grid_router.py +++ b/src/freeroute/route/grid_router.py @@ -33,7 +33,8 @@ import random from freeroute.board.board import BasicBoard from freeroute.board.items import ConductionArea, ObstacleArea, Pin, Via -from freeroute.geometry import IntBox, IntPoint +from freeroute.geometry import IntBox, IntPoint, Simplex +from freeroute.geometry.octagon import overlaps_2d from freeroute.geometry.polyline import corner_box __all__ = ["GridRouter", "RouteResult", "route_board"] @@ -233,7 +234,7 @@ class GridRouter: (dx, dy) for dx in range(-reach, reach + 1) for dy in range(-reach, reach + 1) - if (dx or dy) and math.hypot(dx, dy) * self.step < need + if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need ] # A via's copper is wider than a trace. Another net's trace needs its # centre via_half + clearance + half_width from the via's centre, so a @@ -299,6 +300,9 @@ class GridRouter: # per-connection occupancy (owner = connection index) self._cell_owner: dict[Node, int] = {} self._via_owner: dict[Cell, int] = {} + # every connection whose via covers a cell, in claim order: a via two + # connections share still blocks after the first of them is ripped + self._via_claims: dict[Cell, list[int]] = {} # trace halos: node -> indices of the connections claiming it self._halo: dict[Node, list[int]] = {} self._rasterize_obstacles() @@ -379,6 +383,19 @@ class GridRouter: shape.contains(IntPoint(c.x + dx, c.y + dy)) for dx in (-h, h) for dy in (-h, h) ) + def _pad_cells(self, shape, box: IntBox, margin: int): + """Cells where a trace square ``margin`` wide around the centre overlaps + the pad: its box when it is one, else its exact (rotated) outline, + which is what the exact engine checks against.""" + cells = self._cells_in_box(box, margin) + if not isinstance(shape, Simplex) or shape.is_int_box(): + yield from cells + return + for cell in cells: + c = self._cell_center(cell) + if overlaps_2d(shape, IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)): + yield cell + def _rasterize_obstacles(self) -> None: margin = self.clearance + self.half_width outline = self.board.outline @@ -404,7 +421,7 @@ class GridRouter: continue net = item.net_nos[0] if item.net_nos else 0 pin_layers = [layer for layer in item.layers if layer in self.layers] - for cell in self._cells_in_box(box, margin): + for cell in self._pad_cells(item.shape, 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, @@ -593,18 +610,19 @@ class GridRouter: return self.half_width if conn.narrow else self._net_half.get(conn.net_no, self.half_width) def _spread_for(self, half: int) -> list[tuple[int, int]]: - """Cells a trace of ``half`` also needs free: those whose centre is - within its extra half width (plus half a cell, for the grid).""" + """Cells a trace of ``half`` also needs free: every cell out to its + extra half width, rounded up to whole cells. Blocks, halos and via + footprints are sized for a default-width trace, and copper is checked + as boxes, so anything within that square could be too close.""" extra = half - self.half_width if extra <= 0: return [] - reach = math.ceil(extra / self.step) + 1 - limit = extra + self.step / 2 + reach = math.ceil(extra / self.step) return [ (dx, dy) for dx in range(-reach, reach + 1) for dy in range(-reach, reach + 1) - if (dx or dy) and math.hypot(dx, dy) * self.step <= limit + if dx or dy ] def _halo_for(self, half: int) -> list[tuple[int, int]]: @@ -617,7 +635,7 @@ class GridRouter: (dx, dy) for dx in range(-reach, reach + 1) for dy in range(-reach, reach + 1) - if (dx or dy) and math.hypot(dx, dy) * self.step < need + if (dx or dy) and max(abs(dx), abs(dy)) * self.step < need ] return offsets @@ -1184,9 +1202,11 @@ class GridRouter: conn.via_halo = [] for cell in conn.via_cells: for c in self._via_cells(cell): - if c not in self._via_owner: - self._via_owner[c] = conn.index + claims = self._via_claims.setdefault(c, []) + if conn.index not in claims: + claims.append(conn.index) conn.via_halo.append(c) + self._via_owner.setdefault(c, conn.index) ends = ((conn.a_end, wire_segs[0], 0), (conn.b_end, wire_segs[-1], -1)) if wire_segs else () conn.stubs = [] for pin, (layer, cells), at in ends: @@ -1236,8 +1256,16 @@ class GridRouter: if self._cell_owner.get((*cell, layer)) == conn.index: del self._cell_owner[(*cell, layer)] for cell in conn.via_halo: + claims = self._via_claims.get(cell) + if claims is not None and conn.index in claims: + claims.remove(conn.index) if self._via_owner.get(cell) == conn.index: - del self._via_owner[cell] + if claims: + self._via_owner[cell] = claims[0] + else: + del self._via_owner[cell] + if claims is not None and not claims: + del self._via_claims[cell] conn.via_halo = [] for node in conn.halo_nodes: claims = self._halo.get(node) @@ -1261,7 +1289,20 @@ class GridRouter: conn.rip_count += 1 self._settle(conn, +1) - def _crossed_owners(self, path: list[Node], net_no: int, conns: list[_Connection]) -> set[int]: + def _crossed_owners( + self, path: list[Node], net_no: int, conns: list[_Connection], spread=None + ) -> set[int]: + """Connections whose copper or clearance ``path`` crosses; a trace + wider than the default crosses them on its ``spread`` cells too.""" + spread = self._spread if spread is None else spread + if spread: + path = list( + dict.fromkeys( + (x + dx, y + dy, layer) + for x, y, layer in path + for dx, dy in ((0, 0), *spread) + ) + ) owners: set[int] = set() for cx, cy, cl in path: owner = self._cell_owner.get((cx, cy, cl)) @@ -1380,7 +1421,9 @@ class GridRouter: path = self._search(conn, allow_ripup=True, conns=conns) if path is None: return False - crossed = self._crossed_owners(path, conn.net_no, conns) + crossed = self._crossed_owners( + path, conn.net_no, conns, self._spread_for(self._conn_half(conn)) + ) # and the routes on the stubs from the pins to the path's ends for end, node in ((conn.a_end, path[0]), (conn.b_end, path[-1])): if isinstance(end, Pin): diff --git a/tests/route/test_grid_exact_agreement.py b/tests/route/test_grid_exact_agreement.py new file mode 100644 index 0000000..9c08559 --- /dev/null +++ b/tests/route/test_grid_exact_agreement.py @@ -0,0 +1,227 @@ +"""The grid must not believe copper legal that the exact check then refuses. + +Each case is one way the two used to disagree on roomba-ESP12E: a rotated oval +pad modelled as an inflated box (and opened to its own net), a via shared by +two connections of a net losing its footprint when one of them is ripped, and +a wider net class searched as if it were the default width. +""" + +from __future__ import annotations + +import math + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.geometry import IntBox, Simplex +from freeroute.route.grid_router import GridRouter +from freeroute.route.pipeline import ( + _rule_clearance_dsn, + _rule_width_dsn, + _via_half_board, + build_exact_routing_result, +) + +# --- rotated oval pads ------------------------------------------------------- + + +def _socket_dsn(rotation: float, layout: str) -> str: + """A 1x4 2.54 mm socket strip (rect pin 1, oval pins 2-4) at ``rotation``, + each pin its own net, routed to a small SMD pad 0.9 mm away.""" + cx, cy = 100000, -100000 + t = math.radians(rotation) + ax = (math.cos(t), math.sin(t)) + perp = (-math.sin(t), math.cos(t)) + places = [] + for i in range(4): + px, py = cx + 2540 * i * ax[0], cy + 2540 * i * ax[1] + side = 1 if i % 2 == 0 else -1 + if layout == "y": + tx, ty = px, py + side * 9000 + else: + tx, ty = px + side * 8000 * perp[0], py + side * 8000 * perp[1] + places.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)") + nets = " ".join(f"(net N{i} (pins P3-{i + 1} T{i + 1}-1))" for i in range(4)) + return f"""(pcb socket (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) (layer B.Cu (type signal)) + (boundary (path pcb 0 125000 -115000 85000 -115000 85000 -75000 125000 -75000 + 125000 -115000)) + (via "Via[0-1]_800:400_um") + (rule (width 300) (clearance 250))) + (placement + (component S (place P3 {cx} {cy} front {rotation})) + (component T {" ".join(places)})) + (library + (image S (pin R 1 0 0) (pin O 2 2540 0) (pin O 3 5080 0) (pin O 4 7620 0)) + (image T (pin t 1 0 0)) + (padstack O (shape (path F.Cu 1727.2 0 -152.4 0 152.4)) + (shape (path B.Cu 1727.2 0 -152.4 0 152.4)) (attach off)) + (padstack R (shape (rect F.Cu -863.6 -1016 863.6 1016)) + (shape (rect B.Cu -863.6 -1016 863.6 1016)) (attach off)) + (padstack t (shape (rect F.Cu -500 -500 500 500)) (attach off)) + (padstack "Via[0-1]_800:400_um" (shape (circle F.Cu 800)) (shape (circle B.Cu 800)) + (attach off))) + (network {nets} + (class c N0 N1 N2 N3 (circuit (use_via "Via[0-1]_800:400_um")) + (rule (width 300) (clearance 250)))))""" + + +def test_a_rotated_oval_pad_is_its_outline_not_its_bounding_box(): + board = build_board(parse_dsn(_socket_dsn(60, "y"))) + oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2") + assert isinstance(oval.shape, Simplex) and not oval.shape.is_int_box() + box = oval.shape.bounding_box() + assert oval.shape.contains(oval.location) + assert not oval.shape.contains(box.ll) and not oval.shape.contains(box.ur) + + +@pytest.mark.parametrize("layout", ["y", "perp"]) +def test_routes_leaving_a_rotated_socket_pass_the_exact_check(layout): + result = build_exact_routing_result( + parse_dsn(_socket_dsn(60, layout)), resolution=2, shove=True + ) + assert result.unrouted == [] + + +# --- a via two connections share -------------------------------------------- + +_SHARED_VIA = """(pcb shared (parser) (resolution um 10) (unit um) + (structure + (layer Top (type signal)) (layer Bottom (type signal)) + (boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0)) + (via "Via[0-1]_800:400_um") + (rule (width 300) (clearance 250))) + (library + (padstack p (shape (rect Top -400 -400 400 400)) (attach off)) + (padstack "Via[0-1]_800:400_um" (shape (circle Top 800)) (shape (circle Bottom 800)) + (attach off)) + (image PAD (pin p 1 0 0))) + (placement (component PAD + (place G1 3000 -5000 front 0) (place G2 3000 -15000 front 0) + (place G3 17000 -10000 front 0) (place S1 3000 -10000 front 0) + (place S2 17000 -3000 front 0))) + (network (net GND (pins G1-1 G2-1 G3-1)) (net SIG (pins S1-1 S2-1))))""" + + +def _line(a, b, layer): + (x, y), (x1, y1) = a, b + out = [(x, y, layer)] + while x != x1: + x += 1 if x1 > x else -1 + out.append((x, y, layer)) + while y != y1: + y += 1 if y1 > y else -1 + out.append((x, y, layer)) + return out + + +def test_a_shared_via_keeps_its_footprint_when_one_user_is_ripped(): + dsn = parse_dsn(_SHARED_VIA) + s = dsn.resolution.value + router = GridRouter( + build_board(dsn), + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + via_half=_via_half_board(dsn, s), + ) + conns = router._conns = router._build_connections() + gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})] + sig = next(c for c in conns if c not in gnd) + first, second = gnd + g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location) + via = (g3[0] - 10, g3[1]) + + def through_via(conn): + a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location) + return _line(a, via, 0) + _line(via, b, 1) + + router._commit(first, through_via(first)) + router._commit(second, through_via(second)) + footprint = [c for c in router._via_cells(via) if c != via] + router._rip(first) + + assert second.routed and via in second.via_cells + assert all(router._via_owner.get(c) == second.index for c in footprint) + # another net still keeps its distance from the via that is left + assert all(router._enter_cost_base(c, 0, sig.net_no, False, conns) is None for c in footprint) + + +# --- a wider net class -------------------------------------------------------- + +_WIDE = """(pcb wide (parser) (resolution um 10) (unit um) + (structure + (layer F.Cu (type signal)) + (boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0)) + (rule (width 300) (clearance 250))) + (placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0) + (place Q1 10000 {ylo} front 0) (place Q2 10000 {yhi} front 0) + (place Q3 10000 -1000 front 0) (place Q4 10000 -9000 front 0))) + (library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off))) + (network (net PWR (pins P1-1 P2-1)) (net SIG (pins Q1-1 Q3-1)) (net SIG2 (pins Q2-1 Q4-1)) + (class Power PWR (rule (width 500) (clearance 250)))))""" + + +@pytest.mark.parametrize("resolution", [1, 2]) +@pytest.mark.parametrize("gap", [900, 1000, 1075]) +def test_a_wide_net_past_pads_it_does_not_fit_between_is_not_refused(gap, resolution): + # 0.3 mm fits the gap between the two pads, 0.5 mm does not: the grid + # must see that, rather than lay it wide and have the exact check drop it + ylo, yhi = -5000 - gap // 2 - 600, -5000 + gap // 2 + 600 + result = build_exact_routing_result( + parse_dsn(_WIDE.format(ylo=ylo, yhi=yhi)), resolution=resolution + ) + assert result.unrouted == [] + + +def _wide_router(): + dsn = parse_dsn(_WIDE.format(ylo=-6050, yhi=-3950)) + s = dsn.resolution.value + pwr = next(i for i, net in enumerate(dsn.nets, 1) if net.name == "PWR") + return GridRouter( + build_board(dsn), + trace_width=round(_rule_width_dsn(dsn) * s), + clearance=round(_rule_clearance_dsn(dsn) * s), + resolution=2, + net_widths={pwr: 5000}, + ), pwr + + +def test_a_wide_trace_keeps_clear_of_a_default_one_at_the_grid_diagonal(): + router, _pwr = _wide_router() + # 0.5 mm against 0.3 mm needs 0.25 + 0.25 + 0.15 = 0.65 mm between centres, + # on either axis, since the exact check compares boxes + need = 2500 + router.clearance + router.half_width + for dx, dy in [(2, 2), (2, 1), (1, 2)]: + assert max(dx, dy) * router.step < need + assert (dx, dy) in router._halo_for(2500) + + +def test_a_wide_rip_up_route_counts_what_its_extra_width_crosses(): + router, pwr_net = _wide_router() + conns = router._conns = router._build_connections() + pwr = next(c for c in conns if c.net_no == pwr_net) + other = next(c for c in conns if c.net_no != pwr_net) + # a default-width trace two cells from the wide route's path + x, y = 20, 5 + router._commit(other, _line((x - 3, y + 2), (x + 3, y + 2), 0)) + path = _line((x - 3, y), (x + 3, y), 0) + spread = router._spread_for(2500) + assert other.index not in router._crossed_owners(path, pwr.net_no, conns) + assert other.index in router._crossed_owners(path, pwr.net_no, conns, spread) + + +def test_a_pad_cell_check_uses_the_rotated_outline(): + board = build_board(parse_dsn(_socket_dsn(60, "y"))) + router = GridRouter(board, trace_width=3000, clearance=2500, resolution=2) + oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2") + box = oval.shape.bounding_box() + margin = router.clearance + router.half_width + exact = set(router._pad_cells(oval.shape, box, margin)) + boxed = set(router._cells_in_box(box, margin)) + assert exact < boxed + for cell in boxed - exact: + c = router._cell_center(cell) + square = IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin) + assert oval.shape.intersection(square).dimension() != 2