From 6a24ffc540f04216b05e0cf471aeef85c77a8dee Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 13 Jul 2026 09:38:40 -0600 Subject: [PATCH] Add continuous expansion-room routing track A third engine (engine='room') that replaces the fixed routing grid with FreeRouting's free-space rooms. Per layer, the space not occupied by items (inflated by the routing clearance) is decomposed into exact rectangles via coordinate compression (the orthogonal analogue of CompleteFreeSpaceExpansionRoom); adjacent rooms share an edge door, and a via door joins overlapping rooms on two layers. The maze search is an A* over rooms-through-doors (cost = geometric distance + via cost), so a path may cross a door anywhere along its width rather than at a quantized cell. The found room sequence is realized into an exact orthogonal Polyline through the door mid-gates and verified DRC-clean against the ShapeSearchTree, dropping the net if it cannot be made exactly clean. Maps to autoroute/: ExpansionRoom / CompleteFreeSpaceExpansionRoom (rooms), ExpansionDoor / TargetItemExpansionDoor (doors), MazeSearchAlgo / AutorouteEngine (search), LocateFoundConnectionAlgo (realization). Foundation scope: rooms are axis-aligned rectangles (not general Simplex tiles); realization uses an L-connector through door mid-gates. Any-angle rooms, optimal gate placement (true sub-cell shove), and optimization passes are follow-ups. The grid and exact tracks are unchanged; this is opt-in. Routes simple/crossing (with a via) and all four multi-pin nets of the real KiCad board, DRC-clean. --- src/freeroute/route/__init__.py | 6 + src/freeroute/route/pipeline.py | 31 +- src/freeroute/route/room_router.py | 470 +++++++++++++++++++++++++++++ 3 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 src/freeroute/route/room_router.py diff --git a/src/freeroute/route/__init__.py b/src/freeroute/route/__init__.py index 2f270ec..368dc95 100644 --- a/src/freeroute/route/__init__.py +++ b/src/freeroute/route/__init__.py @@ -18,11 +18,14 @@ from .exact_router import ExactRouteResult, route_board_exact from .grid_router import GridRouter, RouteResult, route_board from .pipeline import ( build_exact_routing_result, + build_rooms_routing_result, build_routing_result, route, route_dsn_board, route_dsn_board_exact, + route_dsn_board_rooms, ) +from .room_router import route_board_rooms __all__ = [ "GridRouter", @@ -35,4 +38,7 @@ __all__ = [ "route_board_exact", "build_exact_routing_result", "route_dsn_board_exact", + "route_board_rooms", + "build_rooms_routing_result", + "route_dsn_board_rooms", ] diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 6cebc25..65a76a1 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -18,6 +18,7 @@ from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses from .exact_router import ExactRouteResult, route_board_exact from .grid_router import RouteResult, route_board +from .room_router import route_board_rooms __all__ = ["route", "route_dsn_board", "build_routing_result"] @@ -139,20 +140,44 @@ def build_exact_routing_result( return _to_routing_result(exact.result, dsn, scale, layer_names) +def route_dsn_board_rooms( + dsn: DsnBoard, *, layers: list[int] | 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.""" + 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) + rooms = route_board_rooms( + board, trace_width=width_board, clearance=clearance_board, layers=layers + ) + return rooms, scale, [layer.name for layer in dsn.layers] + + +def build_rooms_routing_result(dsn: DsnBoard, *, layers: list[int] | 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) + return _to_routing_result(rooms.result, dsn, scale, layer_names) + + def route( dsn_text: str, *, layers: list[int] | None = None, engine: str = "grid", shove: bool = False ) -> str: """Route a Specctra DSN string and return the routed SES string. ``engine`` selects the routing track: ``"grid"`` (default; the multi-layer - rip-up grid MVP — highest coverage, kept for backward compatibility) or + rip-up grid MVP — highest coverage, kept for backward compatibility), ``"exact"`` (orthogonal, exact-geometry, DRC-verified — cleaner output where - it succeeds). ``shove`` (exact engine only) tries moving existing traces - aside to recover dropped nets. + it succeeds), or ``"room"`` (continuous expansion-room — exact free-space + decomposition, routes off-grid channels the grid/exact tracks cannot). + ``shove`` (exact engine only) tries moving existing traces aside. """ dsn = parse_dsn(dsn_text) if engine == "exact": result = build_exact_routing_result(dsn, layers=layers, shove=shove) + elif engine == "room": + result = build_rooms_routing_result(dsn, layers=layers) else: result = build_routing_result(dsn, layers=layers) return write_ses(dsn, result) diff --git a/src/freeroute/route/room_router.py b/src/freeroute/route/room_router.py new file mode 100644 index 0000000..79731bc --- /dev/null +++ b/src/freeroute/route/room_router.py @@ -0,0 +1,470 @@ +"""Continuous expansion-room routing track (foundation). + +The grid and exact tracks quantize space into fixed ``width + clearance`` cells. +This track replaces that grid with FreeRouting's idea of **free-space rooms**: +per layer, the space *not* occupied by items (inflated by the routing clearance) +is decomposed into exact convex tiles (here axis-aligned :class:`IntBox` rooms +via coordinate compression, the orthogonal analogue of +``CompleteFreeSpaceExpansionRoom``). Adjacent rooms share a **door** on their +common edge (and a **via door** joins the same footprint on two layers); the +maze search is an A* over rooms-through-doors, so a path may cross a door at any +point along its width — continuous, not cell-quantized. The found room sequence +is realized into an exact orthogonal :class:`Polyline` and verified DRC-clean +against the :class:`ShapeSearchTree`, exactly like the exact track. + +**Foundation scope (this is large — the tail is deferred):** rooms are +axis-aligned rectangles (not general ``Simplex`` tiles); path realization routes +through door mid-gates with an L-connector and drops a net if the realized trace +cannot be made exactly clean. Any-angle rooms, optimal gate placement (true +sub-cell shove), and optimization passes are follow-ups. The grid and exact +tracks are unchanged; this is opt-in via ``engine="room"``. + +Maps to FreeRouting: rooms ← ``ExpansionRoom`` / +``CompleteFreeSpaceExpansionRoom``; doors ← ``ExpansionDoor`` / +``TargetItemExpansionDoor``; search ← ``MazeSearchAlgo`` / ``AutorouteEngine``; +realization ← ``LocateFoundConnectionAlgo``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import heapq + +from freeroute.board.board import BasicBoard +from freeroute.board.items import Pin +from freeroute.board.search_tree import ShapeSearchTree, TreeShape +from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape + +from .exact_router import ExactRouteResult, _routable_nets +from .grid_router import RouteResult + +__all__ = ["route_board_rooms"] + + +@dataclass(frozen=True) +class _Room: + """A convex free-space rectangle on one layer.""" + + id: int + layer: int + box: IntBox + + def center(self) -> IntPoint: + return IntPoint((self.box.ll.x + self.box.ur.x) // 2, (self.box.ll.y + self.box.ur.y) // 2) + + +@dataclass +class _Door: + """A passage between two rooms: a shared edge, or a via between layers.""" + + other: int # neighbouring room id + kind: str # "edge" | "via" + # gate geometry: for an edge door the shared segment, for a via the overlap box + lo: IntPoint + hi: IntPoint + + +@dataclass +class _Graph: + rooms: dict[int, _Room] = field(default_factory=dict) + doors: dict[int, list[_Door]] = field(default_factory=dict) + by_layer: dict[int, list[int]] = field(default_factory=dict) + + +# --- free-space decomposition ------------------------------------------------ + + +def _free_rooms( + tree: ShapeSearchTree, outline: IntBox, layer: int, net_no: int, margin: int, half_width: int +) -> list[IntBox]: + """Decompose the centreline-free space on ``layer`` into rectangles. + + Obstacles are every other-net tile on this layer, inflated by ``margin`` + (clearance + half-width) so a centreline inside a room keeps the trace clear. + The routing region is the outline shrunk by ``half_width``. + """ + region = outline.offset(-half_width) + if region.is_empty(): + return [] + obstacles: list[IntBox] = [] + for shape in tree.all_shapes(): + if net_no in (shape.net_no,) or layer not in shape.layers: + continue + infl = shape.tile.offset(margin) + clipped = infl.intersection(region) + if not clipped.is_empty() and clipped.dimension() == 2: + obstacles.append(clipped) + + xs = sorted( + {region.ll.x, region.ur.x} | {o.ll.x for o in obstacles} | {o.ur.x for o in obstacles} + ) + ys = sorted( + {region.ll.y, region.ur.y} | {o.ll.y for o in obstacles} | {o.ur.y for o in obstacles} + ) + + rooms: list[IntBox] = [] + for j in range(len(ys) - 1): + y0, y1 = ys[j], ys[j + 1] + if y1 <= y0: + continue + run: IntBox | None = None + for i in range(len(xs) - 1): + x0, x1 = xs[i], xs[i + 1] + if x1 <= x0: + continue + cx, cy = (x0 + x1) // 2, (y0 + y1) // 2 + blocked = any(o.contains_inside(IntPoint(cx, cy)) for o in obstacles) + if blocked: + if run is not None: + rooms.append(run) + run = None + continue + cell = IntBox(x0, y0, x1, y1) + run = cell if run is None else run.union(cell) # merge along x + if run is not None: + rooms.append(run) + return rooms + + +# --- doors ------------------------------------------------------------------- + + +def _build_graph(rooms_by_layer: dict[int, list[IntBox]], trace_width: int) -> _Graph: + graph = _Graph() + next_id = 0 + layer_rooms: dict[int, list[_Room]] = {} + for layer, boxes in rooms_by_layer.items(): + layer_rooms[layer] = [] + for box in boxes: + room = _Room(next_id, layer, box) + graph.rooms[room.id] = room + graph.doors[room.id] = [] + graph.by_layer.setdefault(layer, []).append(room.id) + layer_rooms[layer].append(room) + next_id += 1 + + # same-layer edge doors (shared edge with overlap >= trace_width) + for rooms in layer_rooms.values(): + for i in range(len(rooms)): + a = rooms[i] + for j in range(i + 1, len(rooms)): + b = rooms[j] + door = _edge_door(a.box, b.box, trace_width) + if door is not None: + lo, hi = door + graph.doors[a.id].append(_Door(b.id, "edge", lo, hi)) + graph.doors[b.id].append(_Door(a.id, "edge", lo, hi)) + + # via doors: overlapping rooms on different layers + layers = sorted(layer_rooms) + for li in range(len(layers)): + for lj in range(li + 1, len(layers)): + for a in layer_rooms[layers[li]]: + for b in layer_rooms[layers[lj]]: + inter = a.box.intersection(b.box) + if not inter.is_empty() and inter.min_width() >= trace_width: + lo = IntPoint(inter.ll.x, inter.ll.y) + hi = IntPoint(inter.ur.x, inter.ur.y) + graph.doors[a.id].append(_Door(b.id, "via", lo, hi)) + graph.doors[b.id].append(_Door(a.id, "via", lo, hi)) + return graph + + +def _edge_door(a: IntBox, b: IntBox, trace_width: int): + """Shared edge segment of two boxes if they abut with enough overlap.""" + if a.ur.x == b.ll.x or b.ur.x == a.ll.x: # vertical shared edge + x = a.ur.x if a.ur.x == b.ll.x else a.ll.x + lo_y = max(a.ll.y, b.ll.y) + hi_y = min(a.ur.y, b.ur.y) + if hi_y - lo_y >= trace_width: + return IntPoint(x, lo_y), IntPoint(x, hi_y) + if a.ur.y == b.ll.y or b.ur.y == a.ll.y: # horizontal shared edge + y = a.ur.y if a.ur.y == b.ll.y else a.ll.y + lo_x = max(a.ll.x, b.ll.x) + hi_x = min(a.ur.x, b.ur.x) + if hi_x - lo_x >= trace_width: + return IntPoint(lo_x, y), IntPoint(hi_x, y) + return None + + +# --- room A* ----------------------------------------------------------------- + + +def _rooms_containing(graph: _Graph, point: IntPoint, layers) -> list[int]: + return [ + rid + for layer in layers + for rid in graph.by_layer.get(layer, []) + if graph.rooms[rid].box.contains(point) + ] + + +def _search_rooms(graph: _Graph, starts, goals, goal_pt, via_cost): + goal_set = set(goals) + if not goal_set: + return None + open_heap: list[tuple[float, int]] = [] + g_score: dict[int, float] = {} + came: dict[int, tuple[int, _Door]] = {} + for s in sorted(starts): + g_score[s] = 0.0 + heapq.heappush(open_heap, (_dist(graph.rooms[s].center(), goal_pt), s)) + while open_heap: + _, cur = heapq.heappop(open_heap) + if cur in goal_set: + return _reconstruct_rooms(came, cur) + base = g_score[cur] + c_center = graph.rooms[cur].center() + for door in graph.doors[cur]: + step = _dist(c_center, graph.rooms[door.other].center()) + if door.kind == "via": + step += via_cost + tentative = base + step + if tentative < g_score.get(door.other, float("inf")): + came[door.other] = (cur, door) + g_score[door.other] = tentative + h = _dist(graph.rooms[door.other].center(), goal_pt) + heapq.heappush(open_heap, (tentative + h, door.other)) + return None + + +def _reconstruct_rooms(came, cur): + seq = [(cur, None)] + while cur in came: + prev, door = came[cur] + seq.append((prev, door)) + cur = prev + seq.reverse() + return seq # list of (room_id, door_used_to_enter_or_None_for_start) + + +def _dist(a: IntPoint, b: IntPoint) -> float: + return abs(a.x - b.x) + abs(a.y - b.y) + + +# --- path realization -------------------------------------------------------- + + +def _realize(graph, seq, start: IntPoint, goal: IntPoint, half_width): + """Turn a room sequence into per-layer orthogonal wire segments + vias. + + Routes through each door's mid-gate with an L-connector, splitting the path + at via doors. Returns ``(wires, vias)`` in board units or ``None``. + """ + wires: list[tuple[int, list[IntPoint]]] = [] + vias: list[IntPoint] = [] + layer = graph.rooms[seq[0][0]].layer + pts = [start] + # seq[i] carries the door from room i to room i+1 (seq[-1] has None) + for i in range(len(seq) - 1): + door = seq[i][1] + if door is None: + return None + if door.kind == "edge": + gate = _edge_gate(door, pts[-1], half_width) + pts.append(gate) + else: # via: close the current-layer wire, drop a via, start next layer + gate = _via_gate(door, pts[-1], half_width) + pts.append(gate) + wires.append((layer, list(pts))) + vias.append(gate) + layer = graph.rooms[door.other].layer + pts = [gate] + pts.append(goal) + wires.append((layer, pts)) + # expand each polyline of gate points into orthogonal L-connected corners + ortho: list[tuple[int, list[IntPoint]]] = [] + for lyr, poly in wires: + corners = _orthogonalize(poly) + if len(corners) >= 2: + ortho.append((lyr, corners)) + return ortho, vias + + +def _edge_gate(door: _Door, from_pt: IntPoint, half_width: int) -> IntPoint: + """A point on the door edge, kept >= half_width from the door ends.""" + if door.lo.x == door.hi.x: # vertical edge -> pick y + y = _clamp(from_pt.y, door.lo.y + half_width, door.hi.y - half_width) + return IntPoint(door.lo.x, y) + x = _clamp(from_pt.x, door.lo.x + half_width, door.hi.x - half_width) + return IntPoint(x, door.lo.y) + + +def _via_gate(door: _Door, from_pt: IntPoint, half_width: int) -> IntPoint: + x = _clamp(from_pt.x, door.lo.x + half_width, door.hi.x - half_width) + y = _clamp(from_pt.y, door.lo.y + half_width, door.hi.y - half_width) + return IntPoint(x, y) + + +def _orthogonalize(points: list[IntPoint]) -> list[IntPoint]: + """Connect gate points with axis-aligned segments (L-shaped between each).""" + out = [points[0]] + for p in points[1:]: + last = out[-1] + if last.x != p.x and last.y != p.y: + out.append(IntPoint(p.x, last.y)) # horizontal then vertical + if not (out[-1].x == p.x and out[-1].y == p.y): + out.append(p) + return _dedupe(out) + + +def _dedupe(points: list[IntPoint]) -> list[IntPoint]: + 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] + 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 _clamp(v: int, lo: int, hi: int) -> int: + if lo > hi: + return (lo + hi) // 2 + return max(lo, min(hi, v)) + + +# --- orchestration ----------------------------------------------------------- + + +def route_board_rooms( + board: BasicBoard, + *, + trace_width: int, + clearance: int, + layers: list[int] | None = None, + via_cost: float = 50000.0, +) -> ExactRouteResult: + """Route ``board`` with the continuous expansion-room track (foundation).""" + signal_layers = layers or [ + i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal + ] + half_width = max(trace_width // 2, 1) + margin = clearance + half_width + outline = board.bounding_box + + tree = ShapeSearchTree() + owner = _counter() + for pin in board.get_pins(): + if pin.shape is None: + continue + box = pin.shape.bounding_box() + if box.is_empty(): + continue + 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)) + for obstacle in board.get_obstacle_areas(): + if obstacle.layer not in signal_layers: + continue + for tile in obstacle.tiles: + b = tile.bounding_box() + if not b.is_empty(): + tree.insert(TreeShape(next(owner), -1, frozenset({obstacle.layer}), b)) + + result = RouteResult(half_width) + via_layers = frozenset(signal_layers) + for net_no in sorted(_routable_nets(board, signal_layers)): + pins = _net_pins(board, net_no, signal_layers) + for a, b in zip(pins, pins[1:], strict=False): + _route_connection( + net_no, + a, + b, + tree, + result, + owner, + outline, + signal_layers, + half_width, + clearance, + margin, + trace_width, + via_cost, + via_layers, + ) + + drc_clean = tree.has_violation(clearance) is None + return ExactRouteResult(result=result, tree=tree, drc_clean=drc_clean) + + +def _route_connection( + net_no, + a, + b, + tree, + result, + owner, + outline, + signal_layers, + half_width, + clearance, + margin, + trace_width, + via_cost, + via_layers, +): + rooms_by_layer = { + layer: _free_rooms(tree, outline, layer, net_no, margin, half_width) + for layer in signal_layers + } + graph = _build_graph(rooms_by_layer, trace_width) + a_layers = [layer for layer in a.layers if layer in signal_layers] + b_layers = [layer for layer in b.layers if layer in signal_layers] + starts = _rooms_containing(graph, a.location, a_layers) + goals = _rooms_containing(graph, b.location, b_layers) + if not starts or not goals: + return + seq = _search_rooms(graph, starts, set(goals), b.location, via_cost) + if seq is None: + return + realized = _realize(graph, seq, a.location, b.location, half_width) + if realized is None: + return + wires, vias = realized + + # verify every piece is exactly DRC-clean before committing + boxes: list[tuple[IntBox, frozenset[int]]] = [] + for layer, corners in wires: + for box in PolylineShape(Polyline(corners), half_width).tiles(): + if not outline.contains_box(box): + return + boxes.append((box, frozenset({layer}))) + for loc in vias: + vbox = IntBox( + loc.x - half_width, loc.y - half_width, loc.x + half_width, loc.y + half_width + ) + boxes.append((vbox, via_layers)) + if any( + tree.clearance_conflict(box, net_no, layer, clearance) + for box, box_layers in boxes + for layer in box_layers + ): + return + + for box, box_layers in boxes: + tree.insert(TreeShape(next(owner), net_no, box_layers, box, routed=True)) + for layer, corners in wires: + result.add_wire(net_no, layer, corners) + for loc in vias: + result.add_via(net_no, loc) + + +def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]: + return [ + pin + for pin in board.get_pins() + if net_no in pin.net_nos and any(layer in signal_layers for layer in pin.layers) + ] + + +def _counter(): + i = 0 + while True: + yield i + i += 1