diff --git a/src/freeroute/route/__init__.py b/src/freeroute/route/__init__.py new file mode 100644 index 0000000..22f7587 --- /dev/null +++ b/src/freeroute/route/__init__.py @@ -0,0 +1,27 @@ +"""Autorouting for freeroute. + +An MVP grid-based maze router and the DSN-in / SES-out pipeline that makes +freeroute a Java-free replacement for the ``freerouting.jar`` step:: + + from freeroute.route import route + ses_text = route(dsn_text) + +The router (:class:`GridRouter`) is intentionally simple — single-layer A* over +a uniform occupancy grid, enough to reach connectivity on boards whose nets +route without crossing. FreeRouting's free-space expansion-room maze, rip-up, +multi-layer via search, and shove are deferred. +""" + +from __future__ import annotations + +from .grid_router import GridRouter, RouteResult, route_board +from .pipeline import build_routing_result, route, route_dsn_board + +__all__ = [ + "GridRouter", + "RouteResult", + "route_board", + "route", + "route_dsn_board", + "build_routing_result", +] diff --git a/src/freeroute/route/grid_router.py b/src/freeroute/route/grid_router.py new file mode 100644 index 0000000..2adaa8f --- /dev/null +++ b/src/freeroute/route/grid_router.py @@ -0,0 +1,259 @@ +"""A grid-based maze router (MVP). + +This is an *MVP* autorouter, not a port of FreeRouting's expansion-room maze. +Per the phase brief the milestone is connectivity parity with the reference JAR +on a simple board, not FreeRouting-quality optimization. It routes on a single +signal layer with an A* search over a uniform occupancy grid: + +* a cell is blocked if it overlaps another net's pad or a keepout, inflated by + ``clearance + half trace width``; +* each net's ratsnest is connected pin-to-pin; a routed trace's cells then block + other nets (so routes do not overlap); +* the resulting cell path is turned into a trace polyline whose endpoints are the + exact pin locations. + +Deferred (router phase, exact-geometry track): FreeRouting's free-space +expansion rooms, rip-up-and-retry, multi-layer via search, and shove. Those +raise quality/completeness; this reaches connectivity on boards whose nets route +on one layer without crossing. +""" + +from __future__ import annotations + +import heapq +import math + +from freeroute.board.board import BasicBoard +from freeroute.board.items import ObstacleArea, Pin +from freeroute.geometry import IntPoint + +__all__ = ["GridRouter", "RouteResult", "route_board"] + +# 8-connected neighbourhood (orthogonal + diagonal) for 45-degree routing. +_NEIGHBOURS = [ + (1, 0), + (-1, 0), + (0, 1), + (0, -1), + (1, 1), + (1, -1), + (-1, 1), + (-1, -1), +] + + +class RouteResult: + """Per-net routing outcome in *board* units. + + ``wires`` maps a net number to a list of trace paths (each a list of + :class:`IntPoint`). ``routed_net_numbers`` are the nets that got >= 1 trace. + """ + + __slots__ = ("wires", "half_width", "layer") + + def __init__(self, half_width: int, layer: int) -> None: + self.wires: dict[int, list[list[IntPoint]]] = {} + self.half_width = half_width + self.layer = layer + + def add(self, net_no: int, path: list[IntPoint]) -> None: + self.wires.setdefault(net_no, []).append(path) + + @property + def routed_net_numbers(self) -> set[int]: + return {n for n, paths in self.wires.items() if paths} + + +class GridRouter: + """Routes a :class:`BasicBoard` on one layer with a uniform-grid A* search.""" + + def __init__( + self, + board: BasicBoard, + *, + trace_width: int, + clearance: int, + layer: int = 0, + ) -> None: + self.board = board + self.trace_width = max(trace_width, 1) + self.clearance = max(clearance, 0) + self.layer = layer + self.half_width = self.trace_width // 2 + # cell size must fit a trace plus its clearance to a neighbouring trace + self.step = max(self.trace_width + self.clearance, 1) + + box = board.bounding_box + self.origin_x = box.ll.x + self.origin_y = box.ll.y + self.cols = max(1, math.ceil((box.ur.x - box.ll.x) / self.step) + 1) + self.rows = max(1, math.ceil((box.ur.y - box.ll.y) / self.step) + 1) + + # cell -> set of net numbers whose pad blocks it; keepout cells; trace cells + self._pad_block: dict[tuple[int, int], set[int]] = {} + self._keepout: set[tuple[int, int]] = set() + self._trace_block: dict[tuple[int, int], int] = {} + self._rasterize_obstacles() + + # --- grid helpers ------------------------------------------------------- + + def _cell_of(self, point: IntPoint) -> tuple[int, int]: + gx = round((point.x - self.origin_x) / self.step) + gy = round((point.y - self.origin_y) / self.step) + gx = min(max(gx, 0), self.cols - 1) + gy = min(max(gy, 0), self.rows - 1) + return gx, gy + + def _cell_center(self, gx: int, gy: int) -> IntPoint: + return IntPoint(self.origin_x + gx * self.step, self.origin_y + gy * 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) + 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 _rasterize_obstacles(self) -> None: + margin = self.clearance + self.half_width + for item in self.board.get_items(): + if isinstance(item, Pin): + if self.layer not in item.layers: + continue + box = item.shape.bounding_box() if item.shape is not None else None + if box is None or box.is_empty(): + continue + net = item.net_nos[0] if item.net_nos else 0 + for cell in self._cells_in_box(box, margin): + self._pad_block.setdefault(cell, set()).add(net) + elif isinstance(item, ObstacleArea): + if item.layer != self.layer: + continue + box = item.bounding_box() + if box.is_empty(): + continue + for cell in self._cells_in_box(box, self.clearance): + self._keepout.add(cell) + + def _blocked(self, cell: tuple[int, int], net_no: int) -> bool: + if cell in self._keepout: + return True + pads = self._pad_block.get(cell) + if pads and any(n != net_no for n in pads): + return True + trace = self._trace_block.get(cell) + return trace is not None and trace != net_no + + # --- A* search ---------------------------------------------------------- + + def _search( + self, start: tuple[int, int], goal: tuple[int, int], net_no: int + ) -> list[tuple[int, int]] | None: + if start == goal: + return [start] + open_heap: list[tuple[float, tuple[int, int]]] = [] + heapq.heappush(open_heap, (0.0, start)) + came_from: dict[tuple[int, int], tuple[int, int]] = {} + g_score: dict[tuple[int, int], float] = {start: 0.0} + + def h(cell): + return math.hypot(cell[0] - goal[0], cell[1] - goal[1]) + + while open_heap: + _, current = heapq.heappop(open_heap) + if current == goal: + return _reconstruct(came_from, current) + cx, cy = current + for dx, dy in _NEIGHBOURS: + nxt = (cx + dx, cy + dy) + if not (0 <= nxt[0] < self.cols and 0 <= nxt[1] < self.rows): + continue + # the goal cell may be blocked by its own pad's net-agnostic + # rasterization; always allow stepping onto the goal + if nxt != goal and self._blocked(nxt, net_no): + continue + step_cost = 1.0 if dx == 0 or dy == 0 else math.sqrt(2) + tentative = g_score[current] + step_cost + if tentative < g_score.get(nxt, math.inf): + came_from[nxt] = current + g_score[nxt] = tentative + heapq.heappush(open_heap, (tentative + h(nxt), nxt)) + return None + + def _mark_trace(self, cells: list[tuple[int, int]], net_no: int) -> None: + for cell in cells: + self._trace_block[cell] = net_no + + # --- public routing ----------------------------------------------------- + + def route(self) -> RouteResult: + """Route every net with >= 2 pins on this router's layer.""" + result = RouteResult(self.half_width, self.layer) + pins_by_net = self._pins_by_net() + # route nets with fewer pins first (usually easier / shorter) + for net_no in sorted(pins_by_net, key=lambda n: len(pins_by_net[n])): + pins = pins_by_net[net_no] + if len(pins) < 2: + continue + for a, b in zip(pins, pins[1:], strict=False): + path = self._route_connection(a, b, net_no) + if path is not None: + result.add(net_no, path) + return result + + def _pins_by_net(self) -> dict[int, list[Pin]]: + by_net: dict[int, list[Pin]] = {} + for pin in self.board.get_pins(): + if self.layer not in pin.layers: + continue + for net_no in pin.net_nos: + by_net.setdefault(net_no, []).append(pin) + return by_net + + def _route_connection(self, a: Pin, b: Pin, net_no: int) -> list[IntPoint] | None: + start = self._cell_of(a.location) + goal = self._cell_of(b.location) + cells = self._search(start, goal, net_no) + if cells is None: + return None + self._mark_trace(cells, net_no) + # cell centres, with exact pad locations as the true endpoints + points = [a.location] + points.extend(self._cell_center(gx, gy) for gx, gy in cells) + points.append(b.location) + return _simplify(points) + + +def _reconstruct(came_from, current): + path = [current] + while current in came_from: + current = came_from[current] + path.append(current) + path.reverse() + return path + + +def _simplify(points: list[IntPoint]) -> list[IntPoint]: + """Drop duplicate and collinear points from a polyline.""" + out: list[IntPoint] = [] + for p in points: + if out and out[-1].x == p.x and out[-1].y == p.y: + continue + if len(out) >= 2: + a, b = out[-2], out[-1] + # collinear if cross product of (b-a) and (p-a) is zero + if (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x) == 0: + out[-1] = p + continue + out.append(p) + return out + + +def route_board( + board: BasicBoard, *, trace_width: int, clearance: int, layer: int = 0 +) -> RouteResult: + """Convenience wrapper: build a :class:`GridRouter` and route the board.""" + router = GridRouter(board, trace_width=trace_width, clearance=clearance, layer=layer) + return router.route() diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py new file mode 100644 index 0000000..f2cff0a --- /dev/null +++ b/src/freeroute/route/pipeline.py @@ -0,0 +1,76 @@ +"""End-to-end routing pipeline: DSN text in, SES text out. + +Ties together the parser, board construction, the grid router, and the SES +writer — the Java-free replacement for the ``freerouting.jar`` step: + + dsn_text -> parse_dsn -> build_board -> route_board -> write_ses -> ses_text + +Router output is in board units; it is converted back to DSN units (dividing by +the resolution) for the SES ``(wire (path ...))`` scopes. +""" + +from __future__ import annotations + +from freeroute.board import build_board +from freeroute.dsn import DsnBoard, parse_dsn +from freeroute.ses import RoutedWire, RoutingResult, write_ses + +from .grid_router import RouteResult, route_board + +__all__ = ["route", "route_dsn_board", "build_routing_result"] + +#: fallback trace width / clearance in DSN units when the DSN has no rules +_DEFAULT_WIDTH_DSN = 2000 +_DEFAULT_CLEARANCE_DSN = 2000 + + +def _rule_width_dsn(dsn: DsnBoard) -> float: + rules = dsn.structure_rules.width_rules + return rules[0].value if rules else _DEFAULT_WIDTH_DSN + + +def _rule_clearance_dsn(dsn: DsnBoard) -> float: + for rule in dsn.structure_rules.clearance_rules: + if not rule.class_pairs: # the layer-wide default clearance + return rule.value + return _DEFAULT_CLEARANCE_DSN + + +def route_dsn_board(dsn: DsnBoard) -> tuple[RouteResult, int, list[str]]: + """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, + and the layer names.""" + board = build_board(dsn) + scale = max(dsn.resolution.value, 1) + width_board = round(_rule_width_dsn(dsn) * scale) + clearance_board = round(_rule_clearance_dsn(dsn) * scale) + result = route_board(board, trace_width=width_board, clearance=clearance_board, layer=0) + return result, scale, [layer.name for layer in dsn.layers] + + +def build_routing_result(dsn: DsnBoard) -> RoutingResult: + """Route ``dsn`` and convert the board-unit paths to a DSN-unit + :class:`~freeroute.ses.RoutingResult` for SES emission.""" + route, scale, layer_names = route_dsn_board(dsn) + layer_name = layer_names[route.layer] if layer_names else "F.Cu" + width_dsn = _rule_width_dsn(dsn) + # net_number is assigned in DSN order by build_board, so index i -> number i+1 + numbers_to_names = {i + 1: n.name for i, n in enumerate(dsn.nets)} + + result = RoutingResult() + for net_no, paths in route.wires.items(): + name = numbers_to_names.get(net_no, str(net_no)) + for path in paths: + coords: list[float] = [] + for point in path: + coords.append(point.x / scale) + coords.append(point.y / scale) + if len(coords) >= 4: + result.add_wire(name, RoutedWire(layer=layer_name, width=width_dsn, coords=coords)) + return result + + +def route(dsn_text: str) -> str: + """Route a Specctra DSN string and return the routed SES string.""" + dsn = parse_dsn(dsn_text) + result = build_routing_result(dsn) + return write_ses(dsn, result) diff --git a/tests/dsn/fixtures/simple_2net.dsn b/tests/dsn/fixtures/simple_2net.dsn new file mode 100644 index 0000000..6fee08c --- /dev/null +++ b/tests/dsn/fixtures/simple_2net.dsn @@ -0,0 +1,73 @@ +(pcb "simple_2net.dsn" + (parser + (string_quote ") + (space_in_quoted_tokens on) + (host_cad "freeroute-test") + (host_version "1.0") + ) + (resolution um 10) + (unit um) + (structure + (layer Top + (type signal) + (property + (index 0) + ) + ) + (layer Bottom + (type signal) + (property + (index 1) + ) + ) + (boundary + (path pcb 0 0 0 200000 0 200000 -80000 0 -80000 0 0) + ) + (via "Via[0-1]_600:300_um") + (rule + (width 2000) + (clearance 2000) + ) + ) + (library + (padstack Rect_Pad + (shape (rect Top -1000 -1000 1000 1000)) + (attach off) + ) + (padstack "Via[0-1]_600:300_um" + (shape (circle Top 600)) + (shape (circle Bottom 600)) + (attach off) + ) + (image PAD + (pin Rect_Pad 1 0 0) + ) + ) + (placement + (component PAD + (place A1 20000 -20000 front 0) + (place A2 180000 -20000 front 0) + (place B1 20000 -60000 front 0) + (place B2 180000 -60000 front 0) + ) + ) + (network + (net NET_A + (pins A1-1 A2-1) + ) + (net NET_B + (pins B1-1 B2-1) + ) + (class default + (circuit + (use_via "Via[0-1]_600:300_um") + ) + (rule + (width 2000) + (clearance 2000) + ) + ) + ) + (wiring + ) +) diff --git a/tests/route/test_router.py b/tests/route/test_router.py new file mode 100644 index 0000000..24c2139 --- /dev/null +++ b/tests/route/test_router.py @@ -0,0 +1,145 @@ +"""Tests for the MVP grid maze router and the DSN -> SES pipeline. + +Fast tests assert the routing invariants (traces connect their pads, stay on a +valid layer, stay in bounds). An oracle-gated test checks connectivity parity +against the reference FreeRouting JAR on the simple board. + +Fixtures: ``tests/dsn/fixtures/simple_2net.dsn`` (guaranteed routable). +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +from freeroute.board import build_board +from freeroute.dsn import parse_dsn +from freeroute.dsn.sexp import parse +from freeroute.route import route, route_dsn_board + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures" +SIMPLE = FIXTURES / "simple_2net.dsn" + + +def _net_out(ses_text: str): + root = parse(ses_text) + routes = root.child("routes") + network = routes.child("network_out") + return {n.values()[0].text: n.children("wire") for n in network.children("net")} + + +def _wire_paths(wire): + path = wire.child("path") + vals = [v.text for v in path.values()] + layer = vals[0] + nums = [float(v) for v in vals[2:]] + coords = list(zip(nums[0::2], nums[1::2], strict=False)) + return layer, coords + + +# --- connectivity ----------------------------------------------------------- + + +def test_simple_board_routes_both_nets(): + result, scale, layers = route_dsn_board(parse_dsn(SIMPLE.read_text())) + assert result.routed_net_numbers == {1, 2} # NET_A, NET_B + + +def test_pipeline_emits_valid_ses_with_both_nets(): + ses = route(SIMPLE.read_text()) + top = parse(ses) # must be a single balanced S-expression + assert top.head == "session" + nets = _net_out(ses) + assert set(nets) == {"NET_A", "NET_B"} + assert all(wires for wires in nets.values()) # each net has >= 1 wire + + +# --- invariants ------------------------------------------------------------- + + +def test_traces_stay_on_a_valid_layer(): + ses = route(SIMPLE.read_text()) + layer_names = {"Top", "Bottom"} + for wires in _net_out(ses).values(): + for wire in wires: + layer, _ = _wire_paths(wire) + assert layer in layer_names + + +def test_trace_endpoints_sit_on_the_net_pads(): + # DSN pad locations per net (component place coords; pin offset is 0,0) + pads = { + "NET_A": {(20000.0, -20000.0), (180000.0, -20000.0)}, + "NET_B": {(20000.0, -60000.0), (180000.0, -60000.0)}, + } + ses = route(SIMPLE.read_text()) + for net_name, wires in _net_out(ses).items(): + endpoints = set() + for wire in wires: + _, coords = _wire_paths(wire) + assert len(coords) >= 2, "a trace needs at least two points" + endpoints.add(coords[0]) + endpoints.add(coords[-1]) + # every trace endpoint is one of the net's pads + assert endpoints <= pads[net_name] + # both pads of the net are touched + assert endpoints == pads[net_name] + + +def test_traces_stay_within_the_board_outline(): + ses = route(SIMPLE.read_text()) + # simple_2net boundary: x in [0, 200000], y in [-80000, 0] + for wires in _net_out(ses).values(): + for wire in wires: + _, coords = _wire_paths(wire) + for x, y in coords: + assert 0 <= x <= 200000 + assert -80000 <= y <= 0 + + +def test_every_routed_net_joins_its_pin_pair(): + # a routed net's trace path must actually connect its two pins in board units + result, scale, _ = route_dsn_board(parse_dsn(SIMPLE.read_text())) + board = build_board(parse_dsn(SIMPLE.read_text())) + pins_by_net: dict[int, set[tuple[int, int]]] = {} + for pin in board.get_pins(): + for net_no in pin.net_nos: + pins_by_net.setdefault(net_no, set()).add((pin.location.x, pin.location.y)) + for net_no, paths in result.wires.items(): + touched = set() + for path in paths: + touched.add((path[0].x, path[0].y)) + touched.add((path[-1].x, path[-1].y)) + assert touched == pins_by_net[net_no] + + +# --- robustness ------------------------------------------------------------- + + +def test_router_does_not_crash_on_larger_board(): + # the real KiCad board is dense; the router must complete without error and + # route at least some nets (full coverage is a later, multi-layer concern). + result, _, _ = route_dsn_board(parse_dsn((FIXTURES / "kicad_routable.dsn").read_text())) + assert isinstance(result.routed_net_numbers, set) + + +# --- oracle parity ---------------------------------------------------------- + + +@pytest.mark.oracle +def test_connectivity_parity_with_oracle_on_simple_board(): + from oracle import HAS_ORACLE, route_dsn, routed_net_set + + if not HAS_ORACLE: + pytest.skip("FreeRouting oracle unavailable") + our_ses = route(SIMPLE.read_text()) + our_nets = routed_net_set(our_ses) + oracle_ses = route_dsn(SIMPLE, max_passes=3, timeout=300) + oracle_nets = routed_net_set(oracle_ses) + assert oracle_nets, "oracle routed nothing on a routable board" + # connectivity parity: freeroute connects every net the reference connects + assert oracle_nets <= our_nets