Expose the exact and room engines in the CLI

The grid MVP was the only track reachable from the command line, so the
continuous room engine, channel packing, shove, and 45-degree routing were
unusable by anything shelling out to freeroute.

Add --engine {grid,exact,room} (default grid, unchanged behaviour) plus
--pack / --shove / --diagonal, and honour -mp on the tracks that implement
rip-up passes. Not every engine supports every pass; ENGINE_OPTIONS is the
matrix and an unsupported combination exits 2 with a message naming the
engines that do support the flag, rather than silently no-opping.
This commit is contained in:
2026-07-13 18:15:02 -06:00
parent accec686f8
commit 758ba1fbe3
3 changed files with 264 additions and 15 deletions
+120 -8
View File
@@ -1,7 +1,16 @@
"""freeroute command-line interface: Specctra DSN in, routed SES out.
Flag-compatible with the FreeRouting JAR (``-de`` / ``-do``) so it can be
dropped in wherever ``java -jar freerouting.jar`` was invoked.
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
@@ -10,11 +19,48 @@ import argparse
import sys
from freeroute.dsn.reader import parse_dsn
from freeroute.route.pipeline import build_routing_result
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"}),
}
def main(argv: list[str] | None = None) -> int:
#: 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.",
@@ -31,17 +77,67 @@ def main(argv: list[str] | None = None) -> int:
"--max-passes",
type=int,
default=None,
help="accepted for FreeRouting CLI compatibility (advisory; freeroute self-bounds passes)",
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("--no-rip-up", action="store_true", help="disable rip-up-and-retry")
parser.add_argument("--layers", help="comma-separated signal layer indices to route on")
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>)")
layers = [int(x) for x in args.layers.split(",")] if args.layers else None
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:
@@ -51,7 +147,23 @@ def main(argv: list[str] | None = None) -> int:
return 2
dsn = parse_dsn(dsn_text)
result = build_routing_result(dsn, layers=layers, rip_up=not args.no_rip_up)
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,
)
elif args.engine == "room":
result = build_rooms_routing_result(dsn, layers=layers, shove=args.shove, pack=args.pack)
else:
result = build_routing_result(dsn, layers=layers, rip_up=rip_up, max_passes=max_passes)
ses_text = write_ses(dsn, result)
if args.output: