Every connection is routed N times on the empty board, where routes may share cells at a price that doubles each round for every other net on a cell, and a cell that stays shared gains history. The real routing then pays that history on free cells, so the first routes stop taking the space later routes (a fine-pitch part's escapes) have no way round. Off by default. The old history term never steered: it only accrued at rip-up fights, after the first routes had claimed the contested space, and routed connections are never revisited in later passes.
338 lines
12 KiB
Python
338 lines
12 KiB
Python
"""freeroute command-line interface: Specctra DSN in, routed SES out.
|
|
|
|
Flag-compatible with the FreeRouting JAR (``-de`` / ``-do`` / ``-mp``) so it can
|
|
be dropped in wherever ``java -jar freerouting.jar`` was invoked, plus the
|
|
freeroute-specific engine selection (``--engine``) and its opt-in passes.
|
|
|
|
Not every engine supports every pass. The matrix below is the single source of
|
|
truth; an unsupported combination is an error, never a silent no-op::
|
|
|
|
engine --pack --shove --diagonal --no-rip-up -mp/--max-passes
|
|
grid no no no yes yes
|
|
exact no yes yes yes yes
|
|
room yes yes no no no
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
|
|
from freeroute.dsn.reader import parse_dsn
|
|
from freeroute.route.orders import best_of_orders, promote_failures
|
|
from freeroute.route.pipeline import (
|
|
build_exact_routing_result,
|
|
build_rooms_routing_result,
|
|
build_routing_result,
|
|
)
|
|
from freeroute.ses import write_ses
|
|
|
|
#: which optional flags each engine actually implements. Anything not listed for
|
|
#: an engine is rejected by :func:`_validate` rather than quietly ignored.
|
|
ENGINE_OPTIONS: dict[str, frozenset[str]] = {
|
|
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}),
|
|
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "negotiate", "rollback"}),
|
|
"room": frozenset({"shove", "pack"}),
|
|
}
|
|
|
|
#: option key -> the CLI spelling to name in an error message
|
|
_FLAG_NAMES = {
|
|
"pack": "--pack",
|
|
"shove": "--shove",
|
|
"diagonal": "--diagonal",
|
|
"rip_up": "--no-rip-up",
|
|
"max_passes": "-mp/--max-passes",
|
|
"seed": "--seed",
|
|
"orders": "--orders",
|
|
"promote": "--promote-failures",
|
|
"access": "--access-cost",
|
|
"negotiate": "--negotiate",
|
|
"rollback": "--no-rollback",
|
|
}
|
|
|
|
|
|
def _supported_by(option: str) -> str:
|
|
engines = [e for e, opts in ENGINE_OPTIONS.items() if option in opts]
|
|
return " or ".join(f"--engine {e}" for e in engines)
|
|
|
|
|
|
def _validate(parser: argparse.ArgumentParser, engine: str, requested: list[str]) -> None:
|
|
"""Reject any requested option the selected engine does not implement."""
|
|
supported = ENGINE_OPTIONS[engine]
|
|
for option in requested:
|
|
if option not in supported:
|
|
parser.error(
|
|
f"{_FLAG_NAMES[option]} is not supported by --engine {engine} "
|
|
f"(supported by: {_supported_by(option)})"
|
|
)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
prog="freeroute",
|
|
description="Native PCB autorouter — route a Specctra DSN and write an SES session file.",
|
|
)
|
|
parser.add_argument("dsn", nargs="?", help="input Specctra .dsn file")
|
|
parser.add_argument(
|
|
"-de", "--design", dest="design", help="input .dsn (FreeRouting-compatible alias)"
|
|
)
|
|
parser.add_argument(
|
|
"-do", "-o", "--output", dest="output", help="output .ses file (default: stdout)"
|
|
)
|
|
parser.add_argument(
|
|
"-mp",
|
|
"--max-passes",
|
|
type=int,
|
|
default=None,
|
|
help="rip-up-and-retry pass bound (grid and exact engines; default 10)",
|
|
)
|
|
parser.add_argument(
|
|
"--engine",
|
|
choices=sorted(ENGINE_OPTIONS),
|
|
default="grid",
|
|
help=(
|
|
"routing track: grid (default; multi-layer rip-up maze, highest coverage), "
|
|
"exact (orthogonal, exact-geometry DRC-verified output), "
|
|
"room (continuous expansion rooms, routes off-grid channels)"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--pack",
|
|
action="store_true",
|
|
help="coordinated multi-trace channel packing (room engine only)",
|
|
)
|
|
parser.add_argument(
|
|
"--shove",
|
|
action="store_true",
|
|
help="shove committed traces aside to recover dropped nets (exact and room engines)",
|
|
)
|
|
parser.add_argument(
|
|
"--diagonal",
|
|
action="store_true",
|
|
help="45-degree recovery pass for dropped 2-pin nets (exact engine only)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-rip-up",
|
|
action="store_true",
|
|
help="disable rip-up-and-retry (grid and exact engines)",
|
|
)
|
|
parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
|
|
parser.add_argument(
|
|
"--grid-resolution",
|
|
type=int,
|
|
default=None,
|
|
metavar="K",
|
|
help="grid and exact engines: cells K times finer than trace + clearance; "
|
|
"default 2 for exact (reaches 0.5 mm pitch pins), 1 for grid",
|
|
)
|
|
parser.add_argument(
|
|
"--heuristic-weight",
|
|
type=float,
|
|
default=1.0,
|
|
metavar="W",
|
|
help="exact engine: weighted A* (W >= 1); larger is faster, routes at most "
|
|
"W times the shortest length (default 1)",
|
|
)
|
|
parser.add_argument(
|
|
"--edge-clearance",
|
|
type=float,
|
|
metavar="MM",
|
|
help="copper to board-edge clearance in mm, e.g. KiCad's min_copper_edge_clearance "
|
|
"(the DSN does not carry it); default 0: copper only has to stay inside the outline",
|
|
)
|
|
parser.add_argument(
|
|
"--seed",
|
|
type=int,
|
|
metavar="N",
|
|
help="grid and exact engines: shuffle the order connections are routed in; "
|
|
"the same seed gives the same result (default: fewer-pin nets first)",
|
|
)
|
|
parser.add_argument(
|
|
"--orders",
|
|
type=int,
|
|
metavar="N",
|
|
help="grid and exact engines: route N connection orders in parallel and keep the "
|
|
"one leaving the fewest connections unrouted (the default order is one of them)",
|
|
)
|
|
parser.add_argument(
|
|
"--promote-failures",
|
|
type=int,
|
|
metavar="R",
|
|
help="grid and exact engines: up to R rounds, each routing the connections that "
|
|
"failed so far first; keeps the best round",
|
|
)
|
|
parser.add_argument(
|
|
"--access-cost",
|
|
type=float,
|
|
metavar="C",
|
|
help="grid and exact engines: extra cost for a route to pass where it would close "
|
|
"another pin's way out while that pin still has connections to route (default 0: off)",
|
|
)
|
|
parser.add_argument(
|
|
"--negotiate",
|
|
type=int,
|
|
metavar="N",
|
|
help="grid and exact engines: before routing, N rounds of negotiation on the empty "
|
|
"board, where routes may share space at a price that rises where they compete; "
|
|
"the contested cells then cost extra in the real routing (default 0: off)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-rollback",
|
|
dest="rollback",
|
|
action="store_false",
|
|
help="grid and exact engines: keep every rip-up, as before. By default the "
|
|
"connections a rip-up displaces are rerouted at once and the rip-up is undone "
|
|
"if fewer connections end up routed",
|
|
)
|
|
parser.add_argument(
|
|
"--hole-to-hole",
|
|
type=float,
|
|
default=0.25,
|
|
metavar="MM",
|
|
help="grid and exact engines: least distance between the edges of any two via "
|
|
"holes, of one net or two (KiCad's min_hole_to_hole; default 0.25)",
|
|
)
|
|
parser.add_argument(
|
|
"--jobs",
|
|
type=int,
|
|
metavar="J",
|
|
help="processes for --orders (default: one per order, up to the CPU count)",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
dsn_path = args.design or args.dsn
|
|
if not dsn_path:
|
|
parser.error("no input DSN (pass a path, or -de <file>)")
|
|
|
|
requested = [
|
|
option
|
|
for option, asked in (
|
|
("pack", args.pack),
|
|
("shove", args.shove),
|
|
("diagonal", args.diagonal),
|
|
("rip_up", args.no_rip_up),
|
|
("max_passes", args.max_passes is not None),
|
|
("seed", args.seed is not None),
|
|
("orders", args.orders is not None),
|
|
("promote", args.promote_failures is not None),
|
|
("access", args.access_cost is not None),
|
|
("negotiate", args.negotiate is not None),
|
|
("rollback", not args.rollback),
|
|
)
|
|
if asked
|
|
]
|
|
_validate(parser, args.engine, requested)
|
|
|
|
try:
|
|
layers = [int(x) for x in args.layers.split(",")] if args.layers else None
|
|
except ValueError:
|
|
parser.error(f"--layers must be comma-separated integers (got: {args.layers!r})")
|
|
|
|
try:
|
|
with open(dsn_path, encoding="utf-8") as f:
|
|
dsn_text = f.read()
|
|
except OSError as e:
|
|
print(f"freeroute: cannot read {dsn_path}: {e}", file=sys.stderr)
|
|
return 2
|
|
|
|
dsn = parse_dsn(dsn_text)
|
|
rip_up = not args.no_rip_up
|
|
max_passes = args.max_passes if args.max_passes is not None else 10
|
|
|
|
if args.orders is not None and args.orders < 1:
|
|
parser.error("--orders must be at least 1")
|
|
if args.engine == "exact":
|
|
options = {
|
|
"layers": layers,
|
|
"shove": args.shove,
|
|
"diagonal": args.diagonal,
|
|
"rip_up": rip_up,
|
|
"max_passes": max_passes,
|
|
"edge_clearance_mm": args.edge_clearance,
|
|
"resolution": 2 if args.grid_resolution is None else args.grid_resolution,
|
|
"heuristic_weight": args.heuristic_weight,
|
|
"access_cost": args.access_cost or 0.0,
|
|
"negotiate": args.negotiate or 0,
|
|
"hole_to_hole_mm": args.hole_to_hole,
|
|
"rollback": args.rollback,
|
|
}
|
|
elif args.engine == "grid":
|
|
options = {
|
|
"layers": layers,
|
|
"rip_up": rip_up,
|
|
"max_passes": max_passes,
|
|
"edge_clearance_mm": args.edge_clearance,
|
|
"resolution": 1 if args.grid_resolution is None else args.grid_resolution,
|
|
"access_cost": args.access_cost or 0.0,
|
|
"negotiate": args.negotiate or 0,
|
|
"hole_to_hole_mm": args.hole_to_hole,
|
|
"rollback": args.rollback,
|
|
}
|
|
if args.engine == "room":
|
|
result = build_rooms_routing_result(
|
|
dsn, layers=layers, shove=args.shove, pack=args.pack,
|
|
edge_clearance_mm=args.edge_clearance,
|
|
)
|
|
elif args.promote_failures and args.promote_failures > 1:
|
|
if args.orders:
|
|
parser.error("--orders and --promote-failures cannot be combined")
|
|
result, rounds = promote_failures(
|
|
dsn_text, args.engine, options, rounds=args.promote_failures, seed=args.seed
|
|
)
|
|
counts = ", ".join(str(m) for m, _z in rounds)
|
|
print(f"freeroute: {len(rounds)} rounds, unrouted {counts}; kept the best", file=sys.stderr)
|
|
elif args.orders and args.orders > 1:
|
|
result, kept, tried = best_of_orders(
|
|
dsn_text, args.engine, options, orders=args.orders, seed=args.seed, jobs=args.jobs
|
|
)
|
|
counts = ", ".join(f"{'default' if s is None else s}: {m}" for s, (m, _z) in tried)
|
|
print(
|
|
f"freeroute: tried {len(tried)} orders, kept "
|
|
f"{'the default order' if kept is None else f'--seed {kept}'} (unrouted {counts})",
|
|
file=sys.stderr,
|
|
)
|
|
elif args.engine == "exact":
|
|
result = build_exact_routing_result(dsn, seed=args.seed, **options)
|
|
else:
|
|
result = build_routing_result(dsn, seed=args.seed, **options)
|
|
|
|
ses_text = write_ses(dsn, result)
|
|
missing = [u for u in result.unrouted if not u[3]]
|
|
in_zone = [u for u in result.unrouted if u[3]]
|
|
if missing:
|
|
# the SES is still written; say plainly what it is missing
|
|
print(f"freeroute: {len(missing)} connection(s) not routed:", file=sys.stderr)
|
|
for net, a, b, _zone in missing:
|
|
print(f" {net}: {a} to {b}", file=sys.stderr)
|
|
if result.narrowed:
|
|
print(
|
|
f"freeroute: {len(result.narrowed)} connection(s) of a wider net class only fitted "
|
|
"at the default width; their nets are written at the default width:",
|
|
file=sys.stderr,
|
|
)
|
|
for net, a, b in result.narrowed:
|
|
print(f" {net}: {a} to {b}", file=sys.stderr)
|
|
if in_zone:
|
|
print(
|
|
f"freeroute: {len(in_zone)} more connection(s) left to a zone fill that may join them:",
|
|
file=sys.stderr,
|
|
)
|
|
for net, a, b, zone in in_zone:
|
|
print(f" {net}: {a} to {b} (both in zone {zone})", file=sys.stderr)
|
|
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(ses_text)
|
|
else:
|
|
sys.stdout.write(ses_text)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|