From 700ed0684364541f0f12fbfa3f0e398cf8f0e2cc Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 13 Jul 2026 12:57:05 -0600 Subject: [PATCH] Route 45-degree traces on the exact track (opt-in diagonal mode) Add diagonal=True to the exact router: after orthogonal routing, each 2-pin net is retried as the shortest exactly-clean octilinear (0/45/90/135-degree) trace. A dropped net is recovered; an orthogonal route is replaced only when the diagonal is strictly shorter. Candidates are the direct 45-degree segment or the two diagonal-plus-axis two-benders; each segment's diagonal copper is covered by the exact octagon and its clearance checked with clearance_conflict_shape, so a trace fits a diagonal corridor the bounding-box cover would reject. Endpoints stay on the pads; diagonal copper is stored with its exact octagon so later nets clear it precisely; same-net copper is ignored during the check so the candidate is verified against every other net before the orthogonal copper is ripped. With diagonal=False the pass is skipped and output is byte-for-byte the orthogonal router. Threaded through route_dsn_board_exact / build_exact_routing_result / route(engine="exact"). --- src/freeroute/route/exact_router.py | 160 +++++++++++++++++++++++++++- src/freeroute/route/pipeline.py | 14 ++- 2 files changed, 168 insertions(+), 6 deletions(-) diff --git a/src/freeroute/route/exact_router.py b/src/freeroute/route/exact_router.py index 260e839..e6349cc 100644 --- a/src/freeroute/route/exact_router.py +++ b/src/freeroute/route/exact_router.py @@ -28,11 +28,20 @@ win needs a continuous expansion-room router (the next gap). Also deferred: from __future__ import annotations from dataclasses import dataclass, field +import math 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, shove_segment +from freeroute.geometry import ( + IntBox, + IntPoint, + Polyline, + PolylineShape, + segment_box, + segment_octagon, + shove_segment, +) from .grid_router import RouteResult, route_board @@ -78,10 +87,17 @@ def route_board_exact( max_passes: int = 10, rip_up: bool = True, shove: bool = False, + diagonal: bool = False, max_shove_depth: int = 5, shove_cap: int = 3, ) -> ExactRouteResult: - """Route ``board`` orthogonally and verify the result with exact geometry.""" + """Route ``board`` orthogonally and verify the result with exact geometry. + + ``diagonal`` (opt-in) adds a 45-degree recovery pass: a still-dropped 2-pin net + is retried as an octilinear (0/45/90/135-degree) trace whose diagonal copper is + covered by an exact integer octagon, so it fits diagonal corridors the + orthogonal L cannot. With ``diagonal=False`` the pass is skipped and the output + is byte-for-byte the orthogonal router.""" signal_layers = layers or [ i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal ] @@ -165,6 +181,13 @@ def route_board_exact( if done: shoves += done + if diagonal: + outline = board.bounding_box + for net_no in sorted(_routable_nets(board, signal_layers)): + _diagonalize( + net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline + ) + clean = RouteResult(half_width) for net_no, acc in accepted.items(): clean.wires[net_no] = acc.wires @@ -174,6 +197,139 @@ def route_board_exact( return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves) +# --- 45-degree (diagonal) recovery ------------------------------------------- + + +def _octilinear_candidates(a: IntPoint, b: IntPoint) -> list[list[IntPoint]]: + """Octilinear (0/45/90/135-degree) polylines from ``a`` to ``b``. + + A direct diagonal when the offset is exactly 45-degree, otherwise the two + two-segment paths that spend ``min(|dx|, |dy|)`` on a 45-degree run and the + rest on an axis run (diagonal-first and diagonal-last).""" + dx, dy = b.x - a.x, b.y - a.y + sx = (dx > 0) - (dx < 0) + sy = (dy > 0) - (dy < 0) + adx, ady = abs(dx), abs(dy) + if adx == ady: + raw = [[a, b]] + else: + m = min(adx, ady) + raw = [ + [a, IntPoint(a.x + sx * m, a.y + sy * m), b], # diagonal first + [a, IntPoint(b.x - sx * m, b.y - sy * m), b], # diagonal last + ] + out: list[list[IntPoint]] = [] + for corners in raw: + deduped: list[IntPoint] = [] + for p in corners: + if deduped and deduped[-1].x == p.x and deduped[-1].y == p.y: + continue + deduped.append(p) + if len(deduped) >= 2: + out.append(deduped) + return out + + +def _candidate_tiles(corners, net_no, layer, tree, half_width, clearance, outline): + """``(length, tiles)`` if this octilinear candidate is exactly clean, else + ``None``. ``tiles`` are ``(box, exact, seg)`` triples ready to insert: a plain + box for an orthogonal segment or corner, a bounding box + octagon copper + seg + for a diagonal segment.""" + tiles: list[tuple[IntBox, object, object]] = [] + length = 0.0 + for p, q in zip(corners, corners[1:], strict=False): + dx, dy = q.x - p.x, q.y - p.y + length += math.hypot(dx, dy) + if dx == 0 or dy == 0: # orthogonal + box = segment_box(p, q, half_width) + if not outline.contains_box(box): + return None + if tree.clearance_conflict(box, net_no, layer, clearance): + return None + tiles.append((box, None, None)) + elif abs(dx) == abs(dy): # 45-degree diagonal + copper = segment_octagon(p, q, half_width) + if not outline.contains_box(copper.bounding_box()): + return None + grown = segment_octagon(p, q, half_width + clearance) + if tree.clearance_conflict_shape(grown, net_no, layer): + return None + tiles.append((copper.bounding_box(), copper, (p, q, half_width))) + else: # not octilinear -- reject + return None + for corner in corners[1:-1]: + square = IntBox( + corner.x - half_width, + corner.y - half_width, + corner.x + half_width, + corner.y + half_width, + ) + if not outline.contains_box(square): + return None + if tree.clearance_conflict(square, net_no, layer, clearance): + return None + tiles.append((square, None, None)) + return length, tiles + + +def _wire_length(wires) -> float: + total = 0.0 + for _layer, corners in wires: + for p, q in zip(corners, corners[1:], strict=False): + total += math.hypot(q.x - p.x, q.y - p.y) + return total + + +def _diagonalize( + net_no, board, tree, accepted, owner, signal_layers, half_width, clearance, outline +) -> bool: + """Route (or shorten) a 2-pin net with the shortest exactly-clean octilinear + trace. Recovers a dropped net; replaces an orthogonal route only when the + diagonal is strictly shorter. Endpoints stay on the pads; diagonal copper is + stored as an exact octagon so later nets clear it precisely. Same-net copper is + ignored by the clearance check, so the candidate is verified against every + *other* net before the old copper is removed. Returns ``True`` on a change.""" + pins = _net_pins(board, net_no, signal_layers) + if len(pins) != 2: + return False + a, b = pins + layer = next(iter(set(a.layers) & set(b.layers) & set(signal_layers)), None) + if layer is None: + return False + + acc = accepted.get(net_no) + if acc is not None and acc.vias: + return False # keep a multi-layer orthogonal route; diagonal MVP is single-layer + + best = None + for corners in _octilinear_candidates(a.location, b.location): + found = _candidate_tiles(corners, net_no, layer, tree, half_width, clearance, outline) + if found is None: + continue + length, tiles = found + if best is None or length < best[0]: + best = (length, corners, tiles) + if best is None: + return False + length, corners, tiles = best + + if acc is not None: + if length >= _wire_length(acc.wires): + return False # diagonal is not shorter -- keep the orthogonal route + for oid in acc.owners: + tree.remove_owner(oid) # rip the orthogonal copper; diagonal already clean of others + + new = _Accepted(wires=[(layer, corners)], vias=[]) + for box, exact, seg in tiles: + oid = next(owner) + tree.insert( + TreeShape(oid, net_no, frozenset({layer}), box, routed=True, exact=exact, seg=seg) + ) + new.owners.append(oid) + accepted[net_no] = new + return True + + # --- shove recovery ---------------------------------------------------------- diff --git a/src/freeroute/route/pipeline.py b/src/freeroute/route/pipeline.py index 272991a..f697a50 100644 --- a/src/freeroute/route/pipeline.py +++ b/src/freeroute/route/pipeline.py @@ -111,11 +111,13 @@ def route_dsn_board_exact( max_passes: int = 10, rip_up: bool = True, shove: bool = False, + diagonal: bool = False, ) -> tuple[ExactRouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, DRC-verified) track; return the exact result, the scale, and layer names. ``shove`` enables moving existing traces aside to recover dropped nets; - ``rip_up`` toggles the underlying grid's rip-up-and-retry.""" + ``rip_up`` toggles the underlying grid's rip-up-and-retry; ``diagonal`` enables + the 45-degree recovery pass for still-dropped 2-pin nets.""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) width_board = round(_rule_width_dsn(dsn) * scale) @@ -128,15 +130,18 @@ def route_dsn_board_exact( max_passes=max_passes, rip_up=rip_up, shove=shove, + diagonal=diagonal, ) return exact, scale, [layer.name for layer in dsn.layers] def build_exact_routing_result( - dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False + dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, diagonal: bool = False ) -> RoutingResult: """Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult.""" - exact, scale, layer_names = route_dsn_board_exact(dsn, layers=layers, shove=shove) + exact, scale, layer_names = route_dsn_board_exact( + dsn, layers=layers, shove=shove, diagonal=diagonal + ) return _to_routing_result(exact.result, dsn, scale, layer_names) @@ -177,6 +182,7 @@ def route( engine: str = "grid", shove: bool = False, pack: bool = False, + diagonal: bool = False, ) -> str: """Route a Specctra DSN string and return the routed SES string. @@ -190,7 +196,7 @@ def route( """ dsn = parse_dsn(dsn_text) if engine == "exact": - result = build_exact_routing_result(dsn, layers=layers, shove=shove) + result = build_exact_routing_result(dsn, layers=layers, shove=shove, diagonal=diagonal) elif engine == "room": result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack) else: