"""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 )") 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())