"""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; wire paths and via locations are converted back to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and ``(via x y)`` scopes. """ from __future__ import annotations import re from freeroute.board import build_board from freeroute.dsn import DsnBoard, parse_dsn 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"] #: fallback trace width / clearance in DSN units when the DSN has no rules _DEFAULT_WIDTH_DSN = 2000 _DEFAULT_CLEARANCE_DSN = 2000 #: fallback via padstack name when the DSN declares none _DEFAULT_VIA = "Via" def _rule_width_dsn(dsn: DsnBoard) -> float: rules = dsn.structure_rules.width_rules return rules[0].value if rules else _DEFAULT_WIDTH_DSN def _net_widths_board(dsn: DsnBoard, scale: int) -> dict[int, int]: """Track width of every net whose class asks for more than the default, in board units, by net number (build_board numbers nets in DSN order).""" default = _rule_width_dsn(dsn) by_name: dict[str, float] = {} for net_class in dsn.net_classes: if not net_class.width_rules: continue width = net_class.width_rules[0].value if width > default: for name in net_class.net_names: by_name[name] = max(by_name.get(name, 0.0), width) return { i + 1: round(by_name[net.name] * scale) for i, net in enumerate(dsn.nets) if net.name in by_name } 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 _via_padstack(dsn: DsnBoard) -> str: return dsn.via_padstack_names[0] if dsn.via_padstack_names else _DEFAULT_VIA def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | None: """A length in millimetres as board units, or None.""" if value_mm is None: return None unit_mm = {"inch": 25.4, "mil": 0.0254, "mm": 1.0, "cm": 10.0, "um": 0.001}[dsn.unit] return round(value_mm / unit_mm * scale) def _via_drill_board(dsn: DsnBoard, scale: int) -> int | None: """The via's drill in board units, from KiCad's padstack name (``Via[0-1]_600:300_um`` is a 600 um pad with a 300 um drill); None when the name does not say, and the pad size stands in for it.""" match = re.search(r"_([\d.]+):([\d.]+)_um", _via_padstack(dsn)) if not match: return None return round(float(match.group(2)) * _dsn_units_per_um(dsn) * scale) def _dsn_units_per_um(dsn: DsnBoard) -> float: return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0) def _via_half_board(dsn: DsnBoard, scale: int) -> int | None: """Half the via pad's size in board units, from the DSN's via padstack. None when the DSN names no via padstack with a shape, so the engines fall back to their old estimate. """ padstack = dsn.padstack(_via_padstack(dsn)) if padstack is None or not padstack.shapes: return None half = 0.0 for shape in padstack.shapes: if hasattr(shape, "diameter"): half = max(half, shape.diameter / 2) elif len(getattr(shape, "coords", [])) >= 4: xs, ys = shape.coords[0::2], shape.coords[1::2] half = max(half, (max(xs) - min(xs)) / 2, (max(ys) - min(ys)) / 2) return round(half * scale) or None def route_dsn_board( dsn: DsnBoard, *, resolution: int = 1, edge_clearance_mm: float | None = None, layers: list[int] | None = None, rip_up: bool = True, max_passes: int = 10, seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> tuple[RouteResult, int, list[str]]: """Route a parsed :class:`DsnBoard`; return the board-unit result, the scale, and the layer names. ``layers`` restricts routing to those signal-layer indices (default: all signal layers, i.e. multi-layer with vias); ``rip_up`` toggles rip-up-and-retry (``False`` = greedy single pass).""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) result = route_board( board, resolution=resolution, via_half=_via_half_board(dsn, scale), edge_clearance=edge_clearance_board, trace_width=width_board, clearance=clearance_board, layers=layers, rip_up=rip_up, max_passes=max_passes, seed=seed, route_first=route_first, access_cost=access_cost, rollback=rollback, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, ) return result, scale, [layer.name for layer in dsn.layers] def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str]) -> RoutingResult: """Convert a board-unit :class:`RouteResult` to a DSN-unit RoutingResult.""" width_dsn = _rule_width_dsn(dsn) via_name = _via_padstack(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, segments in route.wires.items(): name = numbers_to_names.get(net_no, str(net_no)) # its class's width when every connection of the net went in at it half = getattr(route, "net_half", {}).get(net_no) net_width = 2 * half / scale if half else width_dsn for layer_index, path in segments: layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu" 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=net_width, coords=coords)) for net_no, locations in route.vias.items(): name = numbers_to_names.get(net_no, str(net_no)) for loc in locations: result.add_via(name, RoutedVia(padstack=via_name, x=loc.x / scale, y=loc.y / scale)) result.unrouted = [ (numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", []) ] result.narrowed = [ (numbers_to_names.get(n, str(n)), a, b) for n, a, b in getattr(route, "narrowed", []) ] return result def build_routing_result( dsn: DsnBoard, *, resolution: int = 1, layers: list[int] | None = None, rip_up: bool = True, max_passes: int = 10, edge_clearance_mm: float | None = None, seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> RoutingResult: """Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult.""" route, scale, layer_names = route_dsn_board( dsn, layers=layers, rip_up=rip_up, max_passes=max_passes, edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed, route_first=route_first, access_cost=access_cost, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) return _to_routing_result(route, dsn, scale, layer_names) def route_dsn_board_exact( dsn: DsnBoard, *, resolution: int = 1, heuristic_weight: float = 1.0, edge_clearance_mm: float | None = None, layers: list[int] | None = None, max_passes: int = 10, rip_up: bool = True, shove: bool = False, diagonal: bool = False, seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> 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; ``diagonal`` enables the 45-degree recovery pass for still-dropped 2-pin nets.""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) exact = route_board_exact( board, resolution=resolution, heuristic_weight=heuristic_weight, via_half=_via_half_board(dsn, scale), edge_clearance=edge_clearance_board, trace_width=width_board, clearance=clearance_board, layers=layers, max_passes=max_passes, rip_up=rip_up, shove=shove, diagonal=diagonal, seed=seed, route_first=route_first, access_cost=access_cost, rollback=rollback, net_widths=_net_widths_board(dsn, scale), via_drill=_via_drill_board(dsn, scale), hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0, ) return exact, scale, [layer.name for layer in dsn.layers] def build_exact_routing_result( dsn: DsnBoard, *, resolution: int = 1, heuristic_weight: float = 1.0, layers: list[int] | None = None, shove: bool = False, diagonal: bool = False, rip_up: bool = True, max_passes: int = 10, edge_clearance_mm: float | None = None, seed: int | None = None, route_first: frozenset | None = None, access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, rollback: bool = True, ) -> 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, diagonal=diagonal, rip_up=rip_up, max_passes=max_passes, edge_clearance_mm=edge_clearance_mm, resolution=resolution, heuristic_weight=heuristic_weight, seed=seed, route_first=route_first, access_cost=access_cost, hole_to_hole_mm=hole_to_hole_mm, rollback=rollback, ) return _to_routing_result(exact.result, dsn, scale, layer_names) def route_dsn_board_rooms( dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False, edge_clearance_mm: float | 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. ``shove`` enables occupancy- aware gate placement and shove-in-rooms recovery of otherwise-dropped nets. ``pack`` enables the coordinated multi-trace channel packer.""" board = build_board(dsn) scale = max(dsn.resolution.value, 1) edge_clearance_board = _mm_to_board(dsn, scale, edge_clearance_mm) width_board = round(_rule_width_dsn(dsn) * scale) clearance_board = round(_rule_clearance_dsn(dsn) * scale) rooms = route_board_rooms( board, via_half=_via_half_board(dsn, scale), edge_clearance=edge_clearance_board, trace_width=width_board, clearance=clearance_board, layers=layers, shove=shove, pack=pack, ) return rooms, scale, [layer.name for layer in dsn.layers] def build_rooms_routing_result( dsn: DsnBoard, *, layers: list[int] | None = None, shove: bool = False, pack: bool = False, edge_clearance_mm: float | 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, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm ) 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, pack: bool = False, diagonal: bool = False, edge_clearance_mm: float | None = None, resolution: int = 1, ) -> 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), ``"exact"`` (orthogonal, exact-geometry, DRC-verified — cleaner output where it succeeds), or ``"room"`` (continuous expansion-room — exact free-space decomposition, routes off-grid channels the grid/exact tracks cannot). ``shove`` (exact and room engines) tries moving existing traces aside; on the room engine it also turns on occupancy-aware gate placement. """ dsn = parse_dsn(dsn_text) if engine == "exact": result = build_exact_routing_result( dsn, layers=layers, shove=shove, diagonal=diagonal, edge_clearance_mm=edge_clearance_mm, resolution=resolution, ) elif engine == "room": result = build_rooms_routing_result( dsn, layers=layers, shove=shove, pack=pack, edge_clearance_mm=edge_clearance_mm ) else: result = build_routing_result( dsn, layers=layers, edge_clearance_mm=edge_clearance_mm, resolution=resolution ) return write_ses(dsn, result)