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").
This commit is contained in:
Ryan Malloy 2026-07-13 12:57:05 -06:00
parent 20dc5fd96a
commit 700ed06843
2 changed files with 168 additions and 6 deletions

View File

@ -28,11 +28,20 @@ win needs a continuous expansion-room router (the next gap). Also deferred:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
import math
from freeroute.board.board import BasicBoard from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin from freeroute.board.items import Pin
from freeroute.board.search_tree import ShapeSearchTree, TreeShape 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 from .grid_router import RouteResult, route_board
@ -78,10 +87,17 @@ def route_board_exact(
max_passes: int = 10, max_passes: int = 10,
rip_up: bool = True, rip_up: bool = True,
shove: bool = False, shove: bool = False,
diagonal: bool = False,
max_shove_depth: int = 5, max_shove_depth: int = 5,
shove_cap: int = 3, shove_cap: int = 3,
) -> ExactRouteResult: ) -> 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 [ signal_layers = layers or [
i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal i for i, layer in enumerate(board.layer_structure.arr) if layer.is_signal
] ]
@ -165,6 +181,13 @@ def route_board_exact(
if done: if done:
shoves += 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) clean = RouteResult(half_width)
for net_no, acc in accepted.items(): for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires 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) 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 ---------------------------------------------------------- # --- shove recovery ----------------------------------------------------------

View File

@ -111,11 +111,13 @@ def route_dsn_board_exact(
max_passes: int = 10, max_passes: int = 10,
rip_up: bool = True, rip_up: bool = True,
shove: bool = False, shove: bool = False,
diagonal: bool = False,
) -> tuple[ExactRouteResult, int, list[str]]: ) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal, """Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names. DRC-verified) track; return the exact result, the scale, and layer names.
``shove`` enables moving existing traces aside to recover dropped nets; ``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) board = build_board(dsn)
scale = max(dsn.resolution.value, 1) scale = max(dsn.resolution.value, 1)
width_board = round(_rule_width_dsn(dsn) * scale) width_board = round(_rule_width_dsn(dsn) * scale)
@ -128,15 +130,18 @@ def route_dsn_board_exact(
max_passes=max_passes, max_passes=max_passes,
rip_up=rip_up, rip_up=rip_up,
shove=shove, shove=shove,
diagonal=diagonal,
) )
return exact, scale, [layer.name for layer in dsn.layers] return exact, scale, [layer.name for layer in dsn.layers]
def build_exact_routing_result( 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: ) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit 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) return _to_routing_result(exact.result, dsn, scale, layer_names)
@ -177,6 +182,7 @@ def route(
engine: str = "grid", engine: str = "grid",
shove: bool = False, shove: bool = False,
pack: bool = False, pack: bool = False,
diagonal: bool = False,
) -> str: ) -> str:
"""Route a Specctra DSN string and return the routed SES string. """Route a Specctra DSN string and return the routed SES string.
@ -190,7 +196,7 @@ def route(
""" """
dsn = parse_dsn(dsn_text) dsn = parse_dsn(dsn_text)
if engine == "exact": 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": elif engine == "room":
result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack) result = build_rooms_routing_result(dsn, layers=layers, shove=shove, pack=pack)
else: else: