Route with freeroute's best available engine, not its grid default
Some checks failed
CI / Lint and Format (push) Has been cancelled
CI / Test Python 3.11 on macos-latest (push) Has been cancelled
CI / Test Python 3.12 on macos-latest (push) Has been cancelled
CI / Test Python 3.13 on macos-latest (push) Has been cancelled
CI / Test Python 3.10 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Build Package (push) Has been cancelled
Some checks failed
CI / Lint and Format (push) Has been cancelled
CI / Test Python 3.11 on macos-latest (push) Has been cancelled
CI / Test Python 3.12 on macos-latest (push) Has been cancelled
CI / Test Python 3.13 on macos-latest (push) Has been cancelled
CI / Test Python 3.10 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.11 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.12 on ubuntu-latest (push) Has been cancelled
CI / Test Python 3.13 on ubuntu-latest (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Build Package (push) Has been cancelled
freeroute's CLI defaults to the grid MVP, so mckicad was getting its weakest router: the continuous room engine (routes channels the grid cannot, emits exactly DRC-clean geometry) and channel packing (fits traces a greedy pass drops) were unreachable. Probe 'freeroute --help' once per CLI path and select the best engine the build advertises, defaulting to --engine room --pack. freeroute exits 2 on an unsupported (engine, flag) pair rather than ignoring it, so also gate each flag on the engine's own option matrix (packing is room-only; 45-degree routing is exact-track-only). An older freeroute that advertises no flags still gets the bare -de/-do drop-in call. All of it is overridable via routing_config.
This commit is contained in:
parent
a215a36122
commit
963206d548
@ -45,6 +45,50 @@ class FreeRoutingError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Cache of {cli_path: supported flags}, so `freeroute --help` is probed at most
|
||||||
|
# once per CLI path.
|
||||||
|
_FREEROUTE_FLAGS: dict[str, set[str]] = {}
|
||||||
|
|
||||||
|
_FREEROUTE_OPTIONAL_FLAGS = ("--engine", "--pack", "--shove", "--diagonal")
|
||||||
|
|
||||||
|
# freeroute's engine/option matrix. Its CLI *errors* (exit 2) on an unsupported
|
||||||
|
# combination rather than ignoring it, so only send a flag the chosen engine
|
||||||
|
# actually implements. Channel packing is a room-engine feature; 45-degree
|
||||||
|
# routing lives on the exact track.
|
||||||
|
_FREEROUTE_ENGINE_FLAGS: dict[str, set[str]] = {
|
||||||
|
"grid": set(),
|
||||||
|
"exact": {"--shove", "--diagonal"},
|
||||||
|
"room": {"--pack", "--shove"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def freeroute_supported_flags(freeroute_cli: str) -> set[str]:
|
||||||
|
"""Which optional flags the installed freeroute CLI understands.
|
||||||
|
|
||||||
|
freeroute's better engines (the continuous room router, channel packing,
|
||||||
|
45-degree routing) landed after its first CLI, so probe ``--help`` once and
|
||||||
|
only pass flags this build actually advertises. An older freeroute simply
|
||||||
|
gets the bare ``-de``/``-do`` invocation instead of failing on an unknown
|
||||||
|
argument.
|
||||||
|
"""
|
||||||
|
cached = _FREEROUTE_FLAGS.get(freeroute_cli)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
flags: set[str] = set()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[freeroute_cli, "--help"], capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
help_text = (result.stdout or "") + (result.stderr or "")
|
||||||
|
flags = {flag for flag in _FREEROUTE_OPTIONAL_FLAGS if flag in help_text}
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
logger.debug("Could not probe freeroute --help; using the bare invocation")
|
||||||
|
|
||||||
|
_FREEROUTE_FLAGS[freeroute_cli] = flags
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
def find_kicad_python() -> str | None:
|
def find_kicad_python() -> str | None:
|
||||||
"""Locate a Python interpreter that can ``import pcbnew``.
|
"""Locate a Python interpreter that can ``import pcbnew``.
|
||||||
|
|
||||||
@ -134,7 +178,16 @@ class FreeRoutingEngine:
|
|||||||
"automatic_neckdown": True,
|
"automatic_neckdown": True,
|
||||||
"postroute_optimization": True,
|
"postroute_optimization": True,
|
||||||
"max_iterations": 1000,
|
"max_iterations": 1000,
|
||||||
"improvement_threshold": 0.01
|
"improvement_threshold": 0.01,
|
||||||
|
# Native freeroute engine selection (ignored by the FreeRouting JAR).
|
||||||
|
# Default to the continuous room engine with channel packing: it routes
|
||||||
|
# channels the grid MVP cannot, its output is exactly DRC-clean, and
|
||||||
|
# packing fits traces a greedy pass drops. Diagonal (45 degree) lives on
|
||||||
|
# the exact track, not the room track, so it is off by default here.
|
||||||
|
"freeroute_engine": "room",
|
||||||
|
"freeroute_pack": True,
|
||||||
|
"freeroute_shove": False,
|
||||||
|
"freeroute_diagonal": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Layer configuration
|
# Layer configuration
|
||||||
@ -357,7 +410,9 @@ class FreeRoutingEngine:
|
|||||||
# (not imported) so its GPL license stays cleanly separated from mckicad.
|
# (not imported) so its GPL license stays cleanly separated from mckicad.
|
||||||
freeroute_cli = find_freeroute_cli()
|
freeroute_cli = find_freeroute_cli()
|
||||||
if freeroute_cli:
|
if freeroute_cli:
|
||||||
ok, ses_path = self._run_freeroute_cli(freeroute_cli, dsn_path, output_directory)
|
ok, ses_path = self._run_freeroute_cli(
|
||||||
|
freeroute_cli, dsn_path, output_directory, routing_config
|
||||||
|
)
|
||||||
if ok:
|
if ok:
|
||||||
return True, ses_path
|
return True, ses_path
|
||||||
logger.warning("freeroute produced no route; falling back to the FreeRouting JAR")
|
logger.warning("freeroute produced no route; falling back to the FreeRouting JAR")
|
||||||
@ -424,19 +479,53 @@ class FreeRoutingEngine:
|
|||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
def _run_freeroute_cli(
|
def _run_freeroute_cli(
|
||||||
self, freeroute_cli: str, dsn_path: str, output_directory: str
|
self,
|
||||||
|
freeroute_cli: str,
|
||||||
|
dsn_path: str,
|
||||||
|
output_directory: str,
|
||||||
|
routing_config: dict[str, Any] | None = None,
|
||||||
) -> tuple[bool, str | None]:
|
) -> tuple[bool, str | None]:
|
||||||
"""Route with the native freeroute CLI (a drop-in for the JAR's -de/-do).
|
"""Route with the native freeroute CLI (a drop-in for the JAR's -de/-do).
|
||||||
|
|
||||||
|
Selects freeroute's best *available* engine rather than accepting its
|
||||||
|
default grid router: the continuous room engine routes channels the grid
|
||||||
|
cannot and emits exactly DRC-clean geometry, and channel packing fits
|
||||||
|
traces a greedy pass drops. Options are only passed when the installed
|
||||||
|
CLI advertises them, so an older freeroute still routes.
|
||||||
|
|
||||||
Returns (success, ses_path). Unlike the JAR's ``-do <directory>``,
|
Returns (success, ses_path). Unlike the JAR's ``-do <directory>``,
|
||||||
freeroute writes a single ``-do <file>``, so the SES path is explicit.
|
freeroute writes a single ``-do <file>``, so the SES path is explicit.
|
||||||
"""
|
"""
|
||||||
|
config = {**self.routing_config, **(routing_config or {})}
|
||||||
name = os.path.splitext(os.path.basename(dsn_path))[0]
|
name = os.path.splitext(os.path.basename(dsn_path))[0]
|
||||||
ses_path = os.path.join(output_directory, f"{name}.ses")
|
ses_path = os.path.join(output_directory, f"{name}.ses")
|
||||||
|
|
||||||
|
cmd = [freeroute_cli, "-de", dsn_path, "-do", ses_path]
|
||||||
|
supported = freeroute_supported_flags(freeroute_cli)
|
||||||
|
|
||||||
|
engine = str(config.get("freeroute_engine") or "")
|
||||||
|
engine_flags = _FREEROUTE_ENGINE_FLAGS.get(engine, set())
|
||||||
|
if engine and "--engine" in supported:
|
||||||
|
cmd += ["--engine", engine]
|
||||||
|
|
||||||
|
for flag, key in (
|
||||||
|
("--pack", "freeroute_pack"),
|
||||||
|
("--shove", "freeroute_shove"),
|
||||||
|
("--diagonal", "freeroute_diagonal"),
|
||||||
|
):
|
||||||
|
if not config.get(key) or flag not in supported:
|
||||||
|
continue
|
||||||
|
if engine and flag not in engine_flags:
|
||||||
|
logger.warning(
|
||||||
|
f"freeroute {flag} is not supported by --engine {engine}; skipping it"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
cmd.append(flag)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"Routing with native freeroute: {freeroute_cli}")
|
logger.info(f"Routing with native freeroute: {' '.join(cmd)}")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[freeroute_cli, "-de", dsn_path, "-do", ses_path],
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
|||||||
82
tests/test_freeroute_engines.py
Normal file
82
tests/test_freeroute_engines.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""mckicad selects freeroute's best *available* engine, not its grid default.
|
||||||
|
|
||||||
|
freeroute's CLI exits 2 on an unsupported (engine, flag) combination rather than
|
||||||
|
ignoring it, and older freeroute builds don't advertise the flags at all — so the
|
||||||
|
command has to be built from both the CLI's probed capabilities and the engine's
|
||||||
|
own option matrix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from mckicad.utils.freerouting import FreeRoutingEngine
|
||||||
|
|
||||||
|
ALL_FLAGS = {"--engine", "--pack", "--shove", "--diagonal"}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_capturing(captured):
|
||||||
|
def _run(cmd, **_kwargs):
|
||||||
|
captured.append(cmd)
|
||||||
|
out = cmd[cmd.index("-do") + 1]
|
||||||
|
with open(out, "w") as f:
|
||||||
|
f.write("(session board)")
|
||||||
|
return MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
return _run
|
||||||
|
|
||||||
|
|
||||||
|
def _route(tmp_path, supported, config=None):
|
||||||
|
engine = FreeRoutingEngine()
|
||||||
|
dsn = tmp_path / "board.dsn"
|
||||||
|
dsn.write_text("(pcb)")
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
with (
|
||||||
|
patch("mckicad.utils.freerouting.freeroute_supported_flags", return_value=supported),
|
||||||
|
patch("mckicad.utils.freerouting.subprocess.run", side_effect=_run_capturing(captured)),
|
||||||
|
):
|
||||||
|
ok, ses = engine._run_freeroute_cli(
|
||||||
|
"/usr/bin/freeroute", str(dsn), str(tmp_path), config
|
||||||
|
)
|
||||||
|
assert ok and ses
|
||||||
|
return captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults_to_room_engine_with_packing(tmp_path):
|
||||||
|
cmd = _route(tmp_path, ALL_FLAGS)
|
||||||
|
assert "--engine" in cmd and cmd[cmd.index("--engine") + 1] == "room"
|
||||||
|
assert "--pack" in cmd
|
||||||
|
# 45-degree routing is an exact-track feature; sending it with room would
|
||||||
|
# make freeroute exit 2.
|
||||||
|
assert "--diagonal" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_older_freeroute_gets_the_bare_invocation(tmp_path):
|
||||||
|
cmd = _route(tmp_path, set()) # a build that advertises no optional flags
|
||||||
|
assert "--engine" not in cmd
|
||||||
|
assert "--pack" not in cmd
|
||||||
|
assert cmd[1] == "-de" # still the plain JAR-compatible drop-in call
|
||||||
|
|
||||||
|
|
||||||
|
def test_incompatible_flag_is_skipped_not_sent(tmp_path):
|
||||||
|
cmd = _route(
|
||||||
|
tmp_path,
|
||||||
|
ALL_FLAGS,
|
||||||
|
{"freeroute_engine": "room", "freeroute_diagonal": True},
|
||||||
|
)
|
||||||
|
assert "--diagonal" not in cmd # room does not implement it
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_engine_can_use_diagonal(tmp_path):
|
||||||
|
cmd = _route(
|
||||||
|
tmp_path,
|
||||||
|
ALL_FLAGS,
|
||||||
|
{
|
||||||
|
"freeroute_engine": "exact",
|
||||||
|
"freeroute_pack": False,
|
||||||
|
"freeroute_diagonal": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert cmd[cmd.index("--engine") + 1] == "exact"
|
||||||
|
assert "--diagonal" in cmd
|
||||||
|
assert "--pack" not in cmd # exact does not implement packing
|
||||||
Loading…
x
Reference in New Issue
Block a user