Files
rpmroute/src/freeroute/cli.py
T
rsp2k cdb3c7b568 Say which connections were not routed
freeroute exited 0 and said nothing when it dropped connections, which
cost real diagnosis time: on rc4 two simple two-pin nets came back with no
copper and no explanation. The grid now records each connection it could
not route, the exact check adds each it rejects, and the CLI lists them on
stderr (the SES is still written; the exit code stays 0, as the JAR's).

A connection whose ends both sit in one zone of its net is listed apart,
as left to the zone fill: freeroute does not route through a zone it
cannot trust to be solid, but KiCad's fill often joins those pads anyway.
2026-09-27 12:34:20 -06:00

226 lines
7.7 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.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"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes"}),
"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",
}
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",
)
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),
)
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.engine == "exact":
result = build_exact_routing_result(
dsn,
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,
)
elif args.engine == "room":
result = build_rooms_routing_result(
dsn, layers=layers, shove=args.shove, pack=args.pack,
edge_clearance_mm=args.edge_clearance,
)
else:
result = build_routing_result(
dsn, 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,
)
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 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())