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 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 ----------------------------------------------------------

View File

@ -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: