Compare commits

..
Author SHA1 Message Date
rsp2k dc11b4aa14 Route past round pads by their outline, not their bounding square
A circle pad was its bounding box in both the grid and the exact check, so a
4 mm mounting hole's square corners refused legal routes 0.8 mm clear of the
real copper. It is now a 16-sided polygon whose sides touch the circle, so
the router is never closer than KiCad measures. diagnose.py takes --seed.
2026-10-02 04:14:39 -06:00
rsp2k de00937084 Merge fix/roomba: grid and exact check agree on rotated pads, shared vias and wide nets 2026-09-30 14:02:54 -06:00
rsp2k f79134fee6 Make the grid and the exact check agree on rotated pads, shared vias and wide nets
Rotated oval pads were built from padded corners that are not in outline
order, so they fell back to their bounding box; neighbouring boxes of a
rotated socket overlap, and the grid's own-pad exemption opened the
overlap to the pin's net, which the exact check then refused. Pads are now
the convex hull of their corners, the exact tree checks a rotated pad
against that outline, and the grid blocks cells against the same outline.

A via reused by a second connection of its net left that connection with
no claim on the via's footprint, so ripping the first owner freed cells
around a via still on the board. Footprint cells now keep every claimant.

A net class wider than the default was searched as a default trace: its
spread was empty below half a cell, halos were circular while the exact
check compares boxes, and a rip-up route only counted what its centre
crossed. The spread now covers the extra width in whole cells, halos are
square, and rip-up victims include what the spread crosses.

bench/diagnose.py now routes with net-class widths and hole spacing, as
the CLI does.
2026-09-30 12:08:25 -06:00
rsp2k ef8398c9b2 Merge fix/ttn-9: straight pin exits, rip-up can reach a pin's way out, exact checks between stubs 2026-09-30 11:11:39 -06:00
rsp2k 8a33dc4576 bench/drc_check.sh: route a KiCad board and judge it with DRC at every severity
Copies the board (stripping its tracks unless --keep), exports the DSN
through mckicad, routes, applies the session and runs DRC with zones
refilled, counting hole problems whatever their severity. FREEROUTE and
MCK choose the builds under test.
2026-09-30 10:54:12 -06:00
rsp2k 8b3ebc53cf Leave fine-pitch pins straight and let rip-up reach a pin's way out
A 1.0 mm trace from a 1.27 mm pitch pin (the RN2483 row on the TTN
Enschede mote) has to stay within 0.06 mm of the pin's centreline until
it is past the neighbouring pads. No grid column is that close, and the
router could not get there:

- A stub ran as one slanted segment to its cell centre. The exact check
  sees that as its bounding box, which lands on the next pad. Orthogonal
  stubs now leave straight along the pin's axis and jog to the cell
  only at the end.
- End nodes were chosen as in free space even in the rip-up search, so
  once another net crossed a pin's only exit, the pin had no end left
  and rip-up could never take the exit back. In rip-up mode those cells
  and stubs are now enterable at the rip penalty, and routes on a stub
  are ripped along with the ones the path crosses.
- Two neighbouring pins' stubs each claim the cells a centred trace
  would crowd, so their claims overlapped well before their copper
  did. Stub against stub is now checked on the copper.
- Escapes also look one column either side, so two pins leaving the
  same way can jog apart.

TTN Enschede mote, exact engine with --shove: 12 unrouted before, 0
after; KiCad DRC on the result has no clearance violations and no
unconnected items. The 0.5 mm pitch row test at resolution 1 now
routes all 5 pins (it pinned 2).
2026-09-30 09:49:05 -06:00
rsp2k 7a42230f36 Merge fix/fixed-via-holes: exact hole spacing to fixed vias, vias on one route kept apart 2026-09-29 09:13:47 -06:00
rsp2k 83ad0f8ef7 Keep a route's own vias apart while it is searched
Committed vias were checked for hole spacing, but a route's own vias are
not committed until it is done, so a route hopping layers (more of them
on 6 layers) could put two of its vias 0.41 mm apart, as th-eda's
6-layer flight controller showed. A via is now also checked against
layer changes earlier on its own route.
2026-09-29 03:10:52 -06:00
rsp2k 6e852a929b Hole spacing to a fixed via measured from where it is, with its own drill
A fixed via sits anywhere, not on a cell centre, and may have a bigger
drill than the router's via; checking hole spacing between cell centres
with one drill let the flight controller's routed 0.2 mm via come 0.19
mm from a hand-placed 0.25 mm one. The board now carries each fixed
via's drill (from KiCad's padstack name), and cells are checked against
fixed vias exactly.
2026-09-29 02:34:17 -06:00
rsp2k f56bb10996 Test: the session never repeats wiring already on the board 2026-09-29 01:17:59 -06:00
rsp2k 8f656006a6 Merge fix/hole-spacing-pads: hole spacing from through-hole pads and fixed vias 2026-09-29 01:16:59 -06:00
rsp2k 6cc2406583 Hole spacing also from through-hole pads and fixed vias
A route could drop a via next to (or onto) a through-hole pad of its own
net, where it changes layer for free, and a via reusing a fixed via's
cell came out a little off the fixed via's centre, beside it. No via now
goes within hole spacing of a through-hole pad of any net; a via in a pad
is checked exactly against every via and hole; and a routed via on a
fixed via of its net is not written. charge-sense: 4 hole_to_hole to 0;
flight controller: 2 to 0, unconnected unchanged.
2026-09-29 01:13:16 -06:00
rsp2k 30297e94dc Merge feat/net-widths: fixed wiring, plane via checks, class widths, via spacing, fan-out connectivity 2026-09-29 00:26:02 -06:00
rsp2k 45b3753cff Count copper already on the board as connections already made
Fixed tracks and vias only kept other nets away, so a pin fanned out to
its plane was routed to the plane again, beside its own fan-out. Pins,
zones and planes that fixed copper of their net touches now form one
connected group: a fanned-out supply pin needs nothing more, a fanned-out
signal routes from its via, and copper touching no pin or zone adds
nothing to route.
2026-09-29 00:16:56 -06:00
rsp2k 7d8e5d5c10 bench/diagnose.py: tell fixed copper, pads and keepouts apart as walls 2026-09-29 00:14:40 -06:00
rsp2k 8152619541 Write each plane via once in the exact engine's output too
The exact engine rebuilds its output from each connection's own vias,
so connections sharing a plane via each wrote a copy: th-eda's flight
controller had 71 spots carrying 2 to 4 vias of one net. Vias are now
written once per net and spot, and never on a via already on the board.
2026-09-28 23:56:23 -06:00
rsp2k 714fa873ae Keep hole-to-hole spacing between every two vias, one net's included
Every pin of a supply drops its own via to the plane, and nothing kept
two of them apart: the flight controller came back with 199 co-located
holes and 109 hole-to-hole errors, nearly all VDD_3V3 and GND. A via now
keeps its drill plus --hole-to-hole (default 0.25 mm, KiCad's) from
every via centre, fixed ones included, except an own-net via at the same
cell, which it reuses; such a via is written once, and none is written
on top of a via already on the board.
2026-09-28 21:52:52 -06:00
rsp2k 4a844e8cd7 Route wider net classes at their width, falling back where they don't fit
The grid is laid out for the default width; a wider trace also needs the
cells its extra half width reaches to be free, and claims a wider halo.
A connection that does not fit at its class width (typically at a
fine-pitch pin) is routed at the default instead and reported. A net is
written at its class width when all of its connections went in at it,
and the exact check verifies it at that width; nets that shove or the
diagonal pass reshaped go out at the default.
2026-09-28 21:16:40 -06:00
rsp2k 783dd8f0d1 Test: a pad inside a via keepout reaches the plane past it 2026-09-28 21:09:43 -06:00
rsp2k 8a82143517 Check plane and in-pad vias where they will actually go
A start that was already a plane end joined the plane at once, with no
check that its via fits: under a sensor's via keepout the via went in
the pad and the exact check refused it. A via leaving or reaching a
walled-in pin goes in that pin's pad, but was only checked at the grid
cell's centre; it is now checked exactly at the pin against via
keepouts and other nets' pads and fixed copper.
2026-09-28 21:08:51 -06:00
rsp2k 8a50bde531 Read the DSN's wiring as fixed copper of its nets
Tracks and vias already on the board were ignored, so other nets could
be routed straight through them (the flight controller's 82 GND
stitching vias and its guard tracks). They are now copper of their net:
other nets keep a pad's distance from them in the grid, the exact check
and the room engine, and they add no connections to route.
2026-09-28 21:06:35 -06:00
rsp2k 0f6a2a0812 Merge fix/plane-goal-stub: routes ending on a plane no longer crash 2026-09-28 19:55:07 -06:00
rsp2k 245cd91c0e Don't cost a pin stub for a route that ends on a plane
A plane end is marked with its zone, not a pin, so a route from a pad
outside the plane's outline crashed looking for the pin's location
(the flight-controller board's first placement).
2026-09-28 19:55:07 -06:00
rsp2k 019b043cd5 Merge feat/ripup-rollback: undo rip-ups that lose connections (on by default) 2026-09-28 16:17:30 -06:00
rsp2k a2e108bc51 Rollback on by default; --no-rollback keeps every rip-up
Over 8 net orders it left 25 to 30 fewer connections unrouted on the
dense sense boards and 16 to 18 fewer on two PCBench boards, and was
never clearly worse. KiCad DRC on the sense harness: 41 opens to 28.
2026-09-28 16:17:28 -06:00
rsp2k 3462089e09 --rollback: undo a rip-up that leaves fewer connections routed
The connections a rip-up route crosses are rerouted at once in free
space. If that leaves fewer connections routed than before, the new
route comes out and every displaced connection goes back on its old
path, with its rip count as it was. Even trades are kept.
2026-09-28 14:54:29 -06:00
rsp2k 35891c02ff Merge feat/rule-area-keepouts: keepout exits, via-only walled pins, rule-area support 2026-09-28 10:32:44 -06:00
rsp2k 3e4f53a469 Keepout exits and rule areas
A route may not leave a cell inside a wire keepout as a wire, nor reach
a pin there by one: it changes layer instead, and the via goes in the
pad. A pin whose every stub enters a keepout is reached by a via only.

A structure keepout named rule_area... (mckicad names KiCad rule areas
so) is checked for overlap only, as KiCad's DRC does, with the free ends
of each wire tested for their round caps.
2026-09-28 10:08:02 -06:00
rsp2k bb75dc5067 DSN reader: only the declared string_quote character opens a string
KiCad declares " and writes a keycap part number as a bare ', which the
tokenizer took for an unterminated string (PCBench f.60_keyboard).
2026-09-28 09:41:27 -06:00
rsp2k 9de8329027 Merge feat/pin-access: vias in pads inside keepouts, exact stub checks, --access-cost 2026-09-28 09:38:18 -06:00
rsp2k f1d0f8df70 Pin access: --access-cost, vias in pads inside wire keepouts, exact stub checks
--access-cost C makes a route pay C to pass where it would close another
pin's way out (its end cells, the via it may need there, and the
clearance around both) while that pin still has connections to route.

A pin inside a wire keepout can only leave by a via; its stub to the
cell centre was track inside the keepout, which the exact check refused.
The via now goes on the pin itself. Pin stubs are also checked exactly
against wire keepouts, as the exact engine will check them.

bench/diagnose.py reports what each refused connection collided with.
2026-09-28 09:15:11 -06:00
rsp2k c44ff272ae bench/diagnose.py: sort an exact-engine run's failures by cause
rejected by the exact check, unroutable even alone, walled in (a pin's
free region is closed off by other copper, with the nets that close it),
or congestion. The board state is the best pass's, rebuilt by replaying
its paths.
2026-09-28 00:30:05 -06:00
rsp2k 05f31fc828 Merge feat/promote-failures: reroute with failed connections first; --orders 2026-09-27 22:53:47 -06:00
rsp2k 01ca108831 --promote-failures: reroute with the connections that failed moved first
Each round routes every connection that has failed so far ahead of the
rest, which keep their order. The promoted set only grows, so two groups
cannot take turns failing; the best round is kept.
2026-09-27 22:34:06 -06:00
rsp2k 0f5dacbd88 --orders: route several connection orders in parallel, keep the best
The default order is always one of the candidates, so the result is
never worse than a plain run; ties go to the earlier candidate.
2026-09-27 22:14:59 -06:00
rsp2k e67ccc04a0 Keep benchmark board lists and results out of git 2026-09-27 15:07:13 -06:00
rsp2k ed2e4c3fac bench: compare routing variants over several connection orders
Runs every board with every variant under the same seeds and compares
them seed by seed, so a change is judged against the routing-order
noise rather than one lucky or unlucky run.
2026-09-27 15:06:30 -06:00
rsp2k 76c3af94b8 --seed: shuffle the order connections are routed in
The router is deterministic, but its result depends heavily on which
connection routes first. A seed shuffles that order reproducibly, so a
change can be judged over several orders instead of the one it happened
to get.
2026-09-27 15:04:35 -06:00
rsp2k cf8b7ece60 uv.lock: record the CalVer version from pyproject 2026-09-27 15:04:35 -06:00
rsp2k f8804512ac Merge feat/plane-layers: route to inner plane layers with a via 2026-09-27 15:01:38 -06:00
rsp2k 56d576e613 Route to inner plane layers with a via, not pad to pad
On a four-layer board with a GND plane on an inner power layer, freeroute
ignored the plane (it only used zones on routing layers) and chained every
GND pad to the next with tracks across the board. Now a through-hole pad
inside a plane is joined to it already, and for a net with a plane every
other group connects straight to the plane: a route may end on any cell
inside it where a via fits (checked lazily, like other vias), and ending
there places that via. Signal nets are unaffected.

Tested on a four-layer DSN: three SMD GND pads each get a short stub and
their own via, the THT GND pad needs nothing, and no track crosses the
board. Ahead of the TerraHawk ESC, a four-layer board.
2026-09-27 14:19:13 -06:00
31 changed files with 2995 additions and 123 deletions
+4
View File
@@ -20,3 +20,7 @@ htmlcov/
# Editor / OS
.DS_Store
*.swp
# Benchmark inputs and results: board lists name local, often private, designs
/bench/boards*.txt
/bench/results/
+206
View File
@@ -0,0 +1,206 @@
"""Route boards under several connection orders and compare variants fairly.
One routing run says little: which connection routes first moves a result by
ten connections either way on a crowded board. This runs every board with
every variant under the same seeds (``--seed``), so variant A and variant B
face the same order on seed 3, and compares them seed by seed.
uv run python bench/bench.py BOARDS -v base= -v hist1="--history-cost 1" \\
--seeds 6 --jobs 4 --out bench/results/hist.jsonl
BOARDS is a text file, one board per line: a name, a DSN path and any flags
that board always needs, e.g.
sense /path/to/charge-sense.dsn --edge-clearance 0.5
# comments and blank lines are ignored
A variant is ``NAME=FLAGS``, or ``NAME@SRC=FLAGS`` to run the code under SRC
(a worktree's ``src`` directory) rather than the installed package. The first
variant is the baseline. Results are appended to --out as JSON lines, and runs
already in it are skipped, so an interrupted benchmark picks up where it left
off. Exits non-zero if any run crashed or timed out.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import math
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import tempfile
import threading
import time
NOT_ROUTED = re.compile(r"freeroute: (\d+) connection\(s\) not routed")
TO_ZONE = re.compile(r"freeroute: (\d+) more connection\(s\) left to a zone fill")
def parse_boards(path: Path) -> list[tuple[str, Path, list[str]]]:
boards = []
for line in path.read_text().splitlines():
words = shlex.split(line, comments=True)
if not words:
continue
if len(words) < 2:
raise SystemExit(f"{path}: want 'name path [flags]', got {line!r}")
dsn = Path(words[1]).expanduser()
if not dsn.is_file():
raise SystemExit(f"{path}: no such DSN {dsn}")
boards.append((words[0], dsn, words[2:]))
return boards
def parse_variant(spec: str) -> tuple[str, str | None, list[str]]:
head, sep, flags = spec.partition("=")
if not sep or not head:
raise SystemExit(f"variant {spec!r}: want NAME=FLAGS or NAME@SRC=FLAGS")
name, _, src = head.partition("@")
if src and not (Path(src) / "freeroute").is_dir():
raise SystemExit(f"variant {name}: {src} has no freeroute package")
return name, src or None, shlex.split(flags)
def run_one(board, variant, seed: int, timeout: float) -> dict:
name, dsn, board_flags = board
vname, src, flags = variant
env = dict(os.environ)
if src:
env["PYTHONPATH"] = src + os.pathsep + env.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory() as tmp:
cmd = [
sys.executable, "-m", "freeroute.cli", str(dsn), "-do", f"{tmp}/out.ses",
*board_flags, *flags, "--seed", str(seed),
]
start = time.monotonic()
proc = subprocess.Popen(
cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
)
expired = threading.Event()
def expire() -> None:
expired.set()
proc.kill()
timer = threading.Timer(timeout, expire)
timer.start()
err = proc.stderr.read()
# reap it here rather than through Popen, to keep its peak memory
_, wstatus, usage = os.wait4(proc.pid, 0)
timer.cancel()
proc.returncode = os.waitstatus_to_exitcode(wstatus)
proc.stderr.close()
status = "timeout" if expired.is_set() else (
"ok" if proc.returncode == 0 else f"exit {proc.returncode}"
)
rss_mb = round(usage.ru_maxrss / 1024)
secs = round(time.monotonic() - start, 1)
missing = NOT_ROUTED.search(err)
zone = TO_ZONE.search(err)
return {
"board": name, "variant": vname, "seed": seed, "status": status,
"unrouted": int(missing.group(1)) if missing else 0,
"to_zone": int(zone.group(1)) if zone else 0,
"secs": secs, "rss_mb": rss_mb,
"tail": err.strip().splitlines()[-1] if status != "ok" and err.strip() else "",
}
def mean_sd(xs: list[float]) -> tuple[float, float]:
m = sum(xs) / len(xs)
sd = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0
return m, sd
def report(rows: list[dict], boards, variants) -> None:
ok = {(r["board"], r["variant"], r["seed"]): r for r in rows if r["status"] == "ok"}
base = variants[0][0]
print(f"\nunrouted per board (lower is better), baseline {base!r}\n")
print(f"{'board':14} {'variant':14} {'n':>3} {'mean':>6} {'sd':>5} {'min':>4} {'max':>4}"
f" {'secs':>6} vs baseline, seed by seed")
for bname, _, _ in boards:
for vname, _, _ in variants:
runs = sorted((k[2], r) for k, r in ok.items() if k[:2] == (bname, vname))
if not runs:
print(f"{bname:14} {vname:14} no finished runs")
continue
xs = [r["unrouted"] for _, r in runs]
m, sd = mean_sd(xs)
t, _ = mean_sd([r["secs"] for _, r in runs])
line = (f"{bname:14} {vname:14} {len(xs):>3} {m:>6.1f} {sd:>5.1f} {min(xs):>4}"
f" {max(xs):>4} {t:>6.0f}")
if vname != base:
diffs = [r["unrouted"] - ok[(bname, base, s)]["unrouted"]
for s, r in runs if (bname, base, s) in ok]
if diffs:
dm, dsd = mean_sd(diffs)
se = dsd / math.sqrt(len(diffs)) if len(diffs) > 1 else float("inf")
better = sum(d < 0 for d in diffs)
worse = sum(d > 0 for d in diffs)
# a difference inside two standard errors is not a finding
verdict = ("better" if dm < 0 else "worse") if abs(dm) > 2 * se else "no clear change"
line += (f" {dm:+.1f} ± {se:.1f} ({better} better, {worse} worse,"
f" {len(diffs) - better - worse} same) {verdict}")
print(line)
failed = [r for r in rows if r["status"] != "ok"]
for r in failed:
print(f"FAILED {r['board']} {r['variant']} seed {r['seed']}: {r['status']} {r['tail']}")
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("boards", type=Path, help="board list: name, DSN path, flags")
ap.add_argument("-v", "--variant", action="append", required=True,
help="NAME=FLAGS or NAME@SRC=FLAGS; the first is the baseline")
ap.add_argument("--seeds", type=int, default=6, help="orders per board and variant")
ap.add_argument("--first-seed", type=int, default=0)
ap.add_argument("--jobs", type=int, default=4, help="runs at once")
ap.add_argument("--timeout", type=float, default=1800, help="seconds per run")
ap.add_argument("--out", type=Path, required=True, help="JSON lines, appended")
ap.add_argument("--report-only", action="store_true", help="summarise --out, run nothing")
args = ap.parse_args(argv)
boards = parse_boards(args.boards)
variants = [parse_variant(v) for v in args.variant]
if len({v[0] for v in variants}) != len(variants):
raise SystemExit("variant names must differ")
seeds = range(args.first_seed, args.first_seed + args.seeds)
rows: list[dict] = []
if args.out.exists():
rows = [json.loads(line) for line in args.out.read_text().splitlines() if line.strip()]
done = {(r["board"], r["variant"], r["seed"]) for r in rows if r["status"] == "ok"}
todo = [(b, v, s) for b in boards for v in variants for s in seeds
if (b[0], v[0], s) not in done]
if not args.report_only and todo:
args.out.parent.mkdir(parents=True, exist_ok=True)
print(f"{len(todo)} runs, {args.jobs} at a time", file=sys.stderr)
# retried runs replace their earlier failure
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in done]
with ThreadPoolExecutor(args.jobs) as pool, args.out.open("w") as out:
for r in rows:
out.write(json.dumps(r) + "\n")
futures = [pool.submit(run_one, b, v, s, args.timeout) for b, v, s in todo]
for n, fut in enumerate(as_completed(futures), 1):
r = fut.result()
rows.append(r)
out.write(json.dumps(r) + "\n")
out.flush()
print(f"[{n}/{len(todo)}] {r['board']} {r['variant']} seed {r['seed']}:"
f" {r['status']} unrouted {r['unrouted']} ({r['secs']}s,"
f" {r['rss_mb']} MB)", file=sys.stderr)
wanted = {(b[0], v[0], s) for b in boards for v in variants for s in seeds}
rows = [r for r in rows if (r["board"], r["variant"], r["seed"]) in wanted]
report(rows, boards, variants)
return 1 if any(r["status"] != "ok" for r in rows) or len(rows) < len(wanted) else 0
if __name__ == "__main__":
raise SystemExit(main())
+305
View File
@@ -0,0 +1,305 @@
"""Why did each connection fail? Sort the failures of one exact-engine run.
uv run python bench/diagnose.py board.dsn --edge-clearance 0.5 [--json out.json]
Every connection the exact engine leaves unrouted lands in one bucket:
rejected the grid routed it, the exact geometry check refused its copper
alone it cannot be routed even on a board with no other routes: the
pads, keepouts or the grid itself are the limit, not congestion
walled-in in the routed board one of its pins has no way out: a flood from
the pin's end cells through free cells stops short (pin access)
congestion both pins can get out, but no free path joins them
The board state is the one the result came from (the best pass), rebuilt by
replaying that pass's paths. Diagnostic only: it reaches into the router.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import sys
from freeroute.board import build_board
from freeroute.board.search_tree import ShapeSearchTree
from freeroute.dsn import parse_dsn
from freeroute.geometry.octagon import overlaps_2d
from freeroute.route import grid_router
from freeroute.route.exact_router import route_board_exact
from freeroute.route.grid_router import GridRouter, _ends_of
from freeroute.route.pipeline import (
_mm_to_board,
_net_widths_board,
_rule_clearance_dsn,
_rule_width_dsn,
_via_drill_board,
_via_half_board,
)
# a pin whose free neighbourhood holds fewer nodes than this is walled in
FLOOD_LIMIT = 200
def _capture_router():
"""Patch GridRouter so the instance route_board_exact builds, and the paths
of its best pass, can be read back afterwards."""
seen: dict = {}
original_commit = GridRouter._commit
original_snapshot = GridRouter._snapshot
def commit(self, conn, path):
original_commit(self, conn, path)
self._diag_paths[conn.index] = (list(path), conn.a_end, conn.b_end)
def snapshot(self, conns):
self._diag_best = {c.index: self._diag_paths[c.index] for c in conns if c.routed}
seen["router"] = self
return original_snapshot(self, conns)
def init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
self._diag_paths = {}
self._diag_best = {}
seen["kwargs"] = kwargs
seen["args"] = args
original_init = GridRouter.__init__
GridRouter.__init__ = init
GridRouter._commit = commit
GridRouter._snapshot = snapshot
return seen
def _capture_conflicts() -> list:
"""Record each exact-check refusal: the copper box, its kind, and the
shape it came too close to. The check stops at a connection's first
conflict, so refusals and rejected connections line up in order."""
log: list = []
original = ShapeSearchTree.clearance_conflict
def conflict(self, box, net_no, layer, clearance, kind="wire"):
hit = original(self, box, net_no, layer, clearance, kind)
if hit:
# the shape that tripped the check, found the way the check does
other = None
expanded = box.offset(clearance)
for s in self.overlapping(expanded):
if s.net_no == net_no or not s.on_layer(layer):
continue
if s.blocks != "all" and s.blocks != kind + "s":
continue
if not s.clearance:
if s.tile.overlaps(box):
other = s
break
continue
if (s.exact is not None and overlaps_2d(s.exact, expanded)) or (
s.exact is None and s.tile.overlaps(expanded)
):
other = s
break
log.append((net_no, kind, layer, box, clearance, other))
return hit
ShapeSearchTree.clearance_conflict = conflict
return log
def _wall_name(wall, names) -> str:
if isinstance(wall, str):
if wall.startswith("fixed copper of net "):
net = int(wall.rsplit(" ", 1)[1])
return f"fixed copper of {names.get(net, net)}"
return wall
return names.get(wall, "pads/keepouts")
def _gap(a, b) -> int:
dx = max(b.ll.x - a.ur.x, a.ll.x - b.ur.x, 0)
dy = max(b.ll.y - a.ur.y, a.ll.y - b.ur.y, 0)
return max(dx, dy)
def _fresh(seen) -> GridRouter:
router = grid_router.GridRouter(*seen["args"], **seen["kwargs"])
router._conns = router._build_connections()
return router
def _static_wall(router: GridRouter, node) -> str:
"""What static copper closes a node: a keepout, copper already on the
board (a fixed via or track, by net), or a pad."""
if node in router._keepout:
return "keepout"
fixed = getattr(router, "_diag_fixed", None)
if fixed is None:
fixed = router._diag_fixed = {}
margin = router.clearance + router.half_width
for net, layers, box in router.board.get_fixed_copper():
for cell in router._cells_in_box(box, margin):
for layer in layers:
fixed[(*cell, layer)] = net
net = fixed.get(node)
if net is not None:
return f"fixed copper of net {net}"
return "pads"
def _flood(router: GridRouter, pin, net_no: int, conns) -> tuple[int, Counter]:
"""How many nodes a pin can reach through free cells (up to FLOOD_LIMIT),
and which other nets' copper bounds the region."""
walls: Counter = Counter()
# the search starts on every end node unconditionally (it may leave one
# inside a wire keepout by a via), so the flood does too
start = list(router._end_nodes(pin, net_no, conns))
seen = set(start)
frontier = list(start)
while frontier and len(seen) < FLOOD_LIMIT:
x, y, layer = frontier.pop()
steps = [(x + dx, y + dy, layer) for dx, dy in router._neighbours]
if router._via_placeable((x, y), net_no, conns):
steps += [(x, y, other) for other in router.layers if other != layer]
elif len(seen) == 1:
# the pin's own exit is a via that other copper leaves no room for
via_nodes = [(*c, lay) for c in router._via_cells((x, y)) for lay in router.layers]
for owner in router._crossed_owners(via_nodes, net_no, conns):
walls[conns[owner].net_no] += 1
for n in steps:
if n in seen or not (0 <= n[0] < router.cols and 0 <= n[1] < router.rows):
continue
if router._enter_cost(n[:2], n[2], net_no, False, conns) is None:
# trace, via footprint or clearance halo, as rip-up sees them
owners = router._crossed_owners([n], net_no, conns)
for owner in owners:
walls[conns[owner].net_no] += 1
if not owners:
walls[_static_wall(router, n)] += 1
continue
seen.add(n)
frontier.append(n)
return len(seen), walls
def diagnose(
dsn_text: str, edge_clearance_mm: float | None, resolution: int = 2, shove: bool = False,
access_cost: float = 0.0, hole_to_hole_mm: float = 0.25, seed: int | None = None,
) -> dict:
dsn = parse_dsn(dsn_text)
names = {i + 1: n.name for i, n in enumerate(dsn.nets)}
scale = max(dsn.resolution.value, 1)
seen = _capture_router()
conflicts = _capture_conflicts()
exact = route_board_exact(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * scale),
clearance=round(_rule_clearance_dsn(dsn) * scale),
via_half=_via_half_board(dsn, scale),
edge_clearance=_mm_to_board(dsn, scale, edge_clearance_mm),
resolution=resolution,
shove=shove,
access_cost=access_cost,
seed=seed,
# the product's configuration: net-class widths and hole spacing
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
)
routed = seen["router"]
best = routed._diag_best
grid_failed = {(c.net_no, *_ends_of(c)) for c in routed._conns if c.index not in best}
# the board as the best pass left it
state = _fresh(seen)
conns = state._conns
for idx, (path, a_end, b_end) in best.items():
conns[idx].a_end, conns[idx].b_end = a_end, b_end
state._commit(conns[idx], path)
empty = _fresh(seen)
# refusals in the order the check made them, one per rejected connection
per_net: dict[int, list] = {}
for net_no, kind, layer, box, clearance, other in conflicts:
if other is None:
what = "?"
elif other.net_no == -1:
what = "keepout" if other.clearance else "rule area"
else:
what = ("trace/via of " if other.routed else "pad of ") + names.get(other.net_no, "?")
need = clearance if other is None or other.clearance else 0
short = (need - _gap(box, other.tile)) / scale / 1000 if other is not None else 0
per_net.setdefault(net_no, []).append(
{"copper": kind, "layer": layer, "against": what, "short_mm": round(short, 4)}
)
by_key = {(c.net_no, *_ends_of(c)): c for c in conns}
rows = []
for failure in exact.result.failed:
net_no, a, b = failure[0], failure[1], failure[2]
zone = failure[3] if len(failure) > 3 else ""
if zone:
continue # left to a zone fill, not a failure
key = (net_no, a, b)
row = {"net": names.get(net_no, str(net_no)), "a": a, "b": b}
conn = by_key.get(key)
if key not in grid_failed:
row["kind"] = "rejected"
if per_net.get(net_no):
row["conflict"] = per_net[net_no].pop(0)
elif conn is None:
row["kind"] = "unknown"
elif empty._search(empty._conns[conn.index], allow_ripup=False, conns=empty._conns) is None:
row["kind"] = "alone"
else:
walled = []
for end, group in ((a, conn.a_group), (b, conn.b_group)):
for pin in group[0][:1]: # a group's first pin is its route end
reach, walls = _flood(state, pin, net_no, conns)
if reach < FLOOD_LIMIT:
walled.append({
"pin": end, "reach": reach,
"walls": [
_wall_name(n, names) for n, _ in walls.most_common(4)
],
})
row["kind"] = "walled-in" if walled else "congestion"
if walled:
row["walled"] = walled
rows.append(row)
return {"failures": rows, "counts": dict(Counter(r["kind"] for r in rows))}
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("dsn", type=Path)
ap.add_argument("--edge-clearance", type=float, metavar="MM")
ap.add_argument("--grid-resolution", type=int, default=2, metavar="K")
ap.add_argument("--shove", action="store_true", help="as mckicad routes")
ap.add_argument("--access-cost", type=float, default=0.0)
ap.add_argument("--seed", type=int, help="route in this shuffled order, as bench.py does")
ap.add_argument("--hole-to-hole", type=float, default=0.25, metavar="MM")
ap.add_argument("--json", type=Path, help="also write the full table here")
args = ap.parse_args(argv)
report = diagnose(args.dsn.read_text(), args.edge_clearance, args.grid_resolution, args.shove,
args.access_cost, args.hole_to_hole, args.seed)
for row in report["failures"]:
extra = ""
for w in row.get("walled", []):
extra += f" [{w['pin']} reaches {w['reach']} cells, walled by {', '.join(w['walls'])}]"
if "conflict" in row:
c = row["conflict"]
extra += (f" [{c['copper']} on layer {c['layer']} {c['short_mm']} mm too close"
f" to {c['against']}]")
print(f"{row['kind']:11} {row['net']}: {row['a']} to {row['b']}{extra}")
total = sum(report["counts"].values())
print(f"\n{total} failed: " + ", ".join(f"{k} {v}" for k, v in sorted(report["counts"].items())),
file=sys.stderr)
if args.json:
args.json.write_text(json.dumps(report, indent=1))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Route one KiCad board with freeroute and judge the result with KiCad's DRC.
#
# bench/drc_check.sh [--keep] <board.kicad_pcb> <out-dir> [freeroute args...]
#
# Copies the board (and its .kicad_pro) into <out-dir>, strips its tracks and
# vias unless --keep is given (then they reach the router as fixed wiring),
# exports the DSN through mckicad, routes, applies the session, and runs DRC
# with zones refilled at every severity. Prints one line: time, copper added,
# shorts, opens, hole problems (whatever severity the board gives them) and
# the other errors. The source board is never touched.
#
# FREEROUTE (default: this checkout's .venv/bin/freeroute) and MCK (default
# ~/claude/mckicad) pick the builds to judge.
set -euo pipefail
keep=0
if [[ "${1:-}" == "--keep" ]]; then keep=1; shift; fi
src="$1"; out="$2"; shift 2
here="$(cd "$(dirname "$0")/.." && pwd)"
FREEROUTE="${FREEROUTE:-$here/.venv/bin/freeroute}"
MCK="${MCK:-$HOME/claude/mckicad}"
rm -rf "$out"; mkdir -p "$out"
cp "${src%.kicad_pcb}.kicad_pro" "$out/board.kicad_pro" 2>/dev/null || true
if [[ $keep == 1 ]]; then
cp "$src" "$out/board.kicad_pcb"
else
(cd "$MCK" && uv run --no-sync python - "$src" "$out/board.kicad_pcb" <<'EOF'
import sys
from mckicad.utils.sexp_tree import parse
text = open(sys.argv[1]).read()
spans = sorted((c.span for c in parse(text).children if c.tag in ("segment", "arc", "via")), reverse=True)
for a, b in spans:
text = text[:a] + text[b:]
open(sys.argv[2], "w").write(text)
EOF
)
fi
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.freerouting import FreeRoutingEngine
assert FreeRoutingEngine().export_dsn_from_kicad('$out/board.kicad_pcb', '$out/board.dsn', {})") \
>/dev/null 2>&1 || { echo "$out: DSN export failed"; exit 1; }
start=$(date +%s.%N)
"$FREEROUTE" "$out/board.dsn" -do "$out/board.ses" "$@" >"$out/route.log" 2>&1 || true
secs=$(python3 -c "print(round($(date +%s.%N)-$start,1))")
[[ -s "$out/board.ses" ]] || { echo "$out: no session ($(tail -1 "$out/route.log"))"; exit 1; }
(cd "$MCK" && uv run --no-sync python -c "
from mckicad.utils.ses_apply import apply_ses_to_board as a
r = a('$out/board.kicad_pcb', '$out/board.ses', '$out/board.kicad_pcb')
print(r['segments_added'], r['vias_added'])" 2>/dev/null) >"$out/apply.txt"
kicad-cli pcb drc --refill-zones --format json --severity-all \
-o "$out/drc.json" "$out/board.kicad_pcb" >/dev/null 2>&1 || true
python3 - "$out" "$secs" <<'EOF'
import collections, json, sys
out, secs = sys.argv[1:]
seg, via = open(f"{out}/apply.txt").read().split()
d = json.load(open(f"{out}/drc.json"))
errors = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") == "error")
other = collections.Counter(v["type"] for v in d["violations"] if v.get("severity") != "error")
holes = {k: errors.pop(k, 0) + other.get(k, 0) for k in ("holes_co_located", "hole_to_hole")}
print(f"{out}: {secs}s {seg} seg {via} via shorts {errors.pop('shorting_items', 0)} "
f"opens {len(d.get('unconnected_items', []))} holes {holes} errors {dict(errors)}")
EOF
+21
View File
@@ -70,6 +70,27 @@ class BasicBoard:
def get_traces(self) -> list[Trace]:
return [it for it in self._items if isinstance(it, Trace)]
def get_fixed_copper(self) -> list[tuple[int, frozenset[int], IntBox]]:
"""Copper already routed on the board (the DSN's ``wiring``), as
``(net, layers, box)``. A track is its segments, its corners and the
round caps at its ends (as squares); a via is its largest pad."""
from freeroute.geometry.polyline import Polyline, PolylineShape, corner_box
out: list[tuple[int, frozenset[int], IntBox]] = []
for item in self._items:
if not item.fixed or not item.net_nos:
continue
net = item.net_nos[0]
if isinstance(item, Trace) and not item.is_empty():
layers = frozenset({item.layer})
for box in PolylineShape(Polyline(item.corners), item.half_width).tiles():
out.append((net, layers, box))
for end in (item.corners[0], item.corners[-1]):
out.append((net, layers, corner_box(end, item.half_width)))
elif isinstance(item, Via) and item.shape is not None:
out.append((net, frozenset(item.layers), item.shape.bounding_box()))
return out
def get_vias(self) -> list[Via]:
return [it for it in self._items if isinstance(it, Via)]
+120 -8
View File
@@ -12,9 +12,10 @@ model. It mirrors what FreeRouting's ``Structure.create_board`` / ``Library`` /
* padstacks x placement -> :class:`Pin` items
* keepouts -> :class:`ObstacleArea` items
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad its
bounding box (documented approximation pending an exact circle tile); a convex
polygon pad an exact :class:`Simplex`. Every pad is placed centred on its pin
Pad geometry: a rectangle pad becomes an exact :class:`IntBox`; a circle pad a
16-sided :class:`Simplex` drawn just outside it (its corners stand out by 2% of
the radius, where the bounding box's stood out by 41%); a convex polygon pad an
exact :class:`Simplex`. Every pad is placed centred on its pin
location, so it contains that location by construction. Component rotation is
applied to the pin position; the pad tile itself is not rotated (irrelevant to
the containment invariant, and pads are centre-symmetric in practice).
@@ -23,6 +24,7 @@ the containment invariant, and pads are centre-symmetric in practice).
from __future__ import annotations
import math
import re
from freeroute.dsn.model import DsnBoard
from freeroute.dsn.shapes import Circle, Path, Polygon, Rectangle, Shape
@@ -31,13 +33,16 @@ from freeroute.geometry.polygon_shape import PolygonShape
from .board import BasicBoard
from .clearance import ClearanceMatrix
from .items import ConductionArea, ObstacleArea, Pin
from .items import ConductionArea, ObstacleArea, Pin, Trace, Via
from .layer import Layer, LayerStructure
from .net import Nets
from .transform import CoordinateTransform
__all__ = ["build_board"]
# DSN units per micrometre, for drills KiCad gives in um in via padstack names
_DSN_PER_UM = {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}
def build_board(dsn: DsnBoard) -> BasicBoard:
"""Construct a :class:`BasicBoard` from a parsed :class:`DsnBoard`."""
@@ -56,6 +61,7 @@ def build_board(dsn: DsnBoard) -> BasicBoard:
_insert_keepouts(dsn, board, transform)
_insert_image_keepouts(dsn, board, transform)
_insert_zones(dsn, board, transform, nets)
_insert_wiring(dsn, board, transform, nets)
return board
@@ -244,7 +250,7 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(centre.x))
cy = round(transform.dsn_to_board(centre.y))
return IntBox(cx - r, cy - r, cx + r, cy + r)
return _circle_tile(cx, cy, r)
if isinstance(shape, Rectangle):
x0, y0, x1, y1 = shape.coords[:4]
corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
@@ -263,8 +269,12 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
for x, y in corners:
p = _to_board_offset(place, x, y, dsn_pin.rotation)
pts.append(IntPoint(round(transform.dsn_to_board(p.x)), round(transform.dsn_to_board(p.y))))
if len(pts) >= 3:
simplex = Simplex.from_corners(pts)
# An oval's padded corners are not in outline order, and a mirrored
# part reverses the winding: take the convex hull, then either winding.
hull = _convex_hull(pts)
if len(hull) >= 3:
for corners in (hull, hull[::-1]):
simplex = Simplex.from_corners(corners)
if not simplex.is_empty():
return simplex
xs = [p.x for p in pts] or [0]
@@ -272,6 +282,52 @@ def _placed_pad_tile(shape: Shape, place, dsn_pin, transform: CoordinateTransfor
return IntBox(min(xs), min(ys), max(xs), max(ys))
_CIRCLE_SIDES = 16
def _circle_tile(cx: int, cy: int, r: int):
"""A polygon around the circle, every side touching it. A square around a
big round pad (a mounting hole) puts phantom copper in its corners that
blocks legal routes past it; this stays outside the circle, so the router
is still never closer than KiCad measures."""
if r < 8:
return IntBox(cx - r, cy - r, cx + r, cy + r)
reach = r / math.cos(math.pi / _CIRCLE_SIDES)
corners = []
for k in range(_CIRCLE_SIDES):
a = (k + 0.5) * 2 * math.pi / _CIRCLE_SIDES
dx, dy = reach * math.cos(a), reach * math.sin(a)
# round away from the centre so the polygon never cuts into the circle
corners.append(IntPoint(cx + math.copysign(math.ceil(abs(dx)), dx),
cy + math.copysign(math.ceil(abs(dy)), dy)))
corners = [IntPoint(int(p.x), int(p.y)) for p in corners]
for winding in (corners, corners[::-1]):
simplex = Simplex.from_corners(winding)
if not simplex.is_empty():
return simplex
return IntBox(cx - r, cy - r, cx + r, cy + r)
def _convex_hull(pts: list[IntPoint]) -> list[IntPoint]:
"""The convex hull of ``pts`` counter-clockwise, collinear points dropped."""
ps = sorted({(p.x, p.y) for p in pts})
if len(ps) < 3:
return [IntPoint(x, y) for x, y in ps]
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
def half(points):
out: list[tuple[int, int]] = []
for p in points:
while len(out) >= 2 and cross(out[-2], out[-1], p) <= 0:
out.pop()
out.append(p)
return out[:-1]
return [IntPoint(x, y) for x, y in half(ps) + half(ps[::-1])]
def _pad_tile(shape: Shape, transform: CoordinateTransform):
"""A board-relative pad tile from a DSN padstack shape (centred near origin)."""
if isinstance(shape, Rectangle):
@@ -281,7 +337,7 @@ def _pad_tile(shape: Shape, transform: CoordinateTransform):
r = round(transform.dsn_to_board(shape.diameter / 2))
cx = round(transform.dsn_to_board(shape.center_x))
cy = round(transform.dsn_to_board(shape.center_y))
return IntBox(cx - r, cy - r, cx + r, cy + r) # bounding-box approximation
return _circle_tile(cx, cy, r)
if isinstance(shape, (Polygon, Path)):
coords = shape.coords
pts = [
@@ -333,10 +389,20 @@ def _insert_keepouts(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTran
tiles=tiles,
blocks_wires=keepout.kind != "via_keepout",
blocks_vias=keepout.kind != "wire_keepout",
clearance=not is_rule_area(keepout.area.name),
)
)
#: a structure keepout named with this prefix is a KiCad rule area (mckicad
#: names them on export); it is checked for overlap only
RULE_AREA_PREFIX = "rule_area"
def is_rule_area(name: str | None) -> bool:
return bool(name) and name.startswith(RULE_AREA_PREFIX)
def _area_border_tiles(shape: Shape, transform: CoordinateTransform) -> list:
"""Convex board tiles covering a keepout border shape.
@@ -461,6 +527,52 @@ def _placed_area_tiles(shape: Shape, place, transform: CoordinateTransform) -> l
return [tile for tile in PolygonShape(pts).split_to_convex() if not tile.is_empty()]
def _insert_wiring(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Tracks and vias already on the board, as fixed copper of their nets."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
signal_layers = [i for i, layer in enumerate(dsn.layers) if layer.is_signal]
numbers = {net.name: net.net_number for net in nets}
for wire in dsn.wires:
if wire.net not in numbers or wire.layer not in layer_no:
continue
c = wire.coords
corners = [transform.dsn_to_board_point(c[i], c[i + 1]) for i in range(0, len(c) - 1, 2)]
if len(corners) < 2:
continue
board.insert(
Trace(
net_nos=[numbers[wire.net]], fixed=True, layer=layer_no[wire.layer],
half_width=max(round(transform.dsn_to_board(wire.width / 2)), 1), corners=corners,
)
)
for via in dsn.vias:
padstack = dsn.padstack(via.padstack)
if via.net not in numbers or padstack is None or not padstack.shapes:
continue
layers: set[int] = set()
for shape in padstack.shapes:
if shape.layer in ("signal", "pcb"):
layers.update(signal_layers)
elif shape.layer in layer_no:
layers.add(layer_no[shape.layer])
location = transform.dsn_to_board_point(via.x, via.y)
pad = _pad_tile(max(padstack.shapes, key=_shape_extent), transform)
box = pad.bounding_box()
drill = re.search(r"_([\d.]+):([\d.]+)_um", via.padstack)
drill_board = (
round(transform.dsn_to_board(float(drill.group(2)) * _DSN_PER_UM.get(dsn.unit, 1.0)))
if drill else 0
)
board.insert(
Via(
net_nos=[numbers[via.net]], fixed=True, padstack_name=via.padstack,
drill=drill_board, location=location, layers=sorted(layers or signal_layers),
shape=IntBox(box.ll.x + location.x, box.ll.y + location.y,
box.ur.x + location.x, box.ur.y + location.y),
)
)
def _insert_zones(dsn: DsnBoard, board: BasicBoard, transform: CoordinateTransform, nets) -> None:
"""Each DSN plane as a ConductionArea of its net on its layer."""
layer_no = {layer.name: i for i, layer in enumerate(dsn.layers)}
+10
View File
@@ -76,6 +76,11 @@ class ObstacleArea(Item):
#: tracks" rule area, where vias are allowed), via_keepout only vias
blocks_wires: bool = True
blocks_vias: bool = True
#: False for a KiCad rule area: copper may not overlap it but may touch
#: it, since KiCad's DRC applies no clearance to a rule area. Every other
#: keepout may stand for copper (a net tie, a track kept as it is), so it
#: keeps the clearance unless named as a rule area.
clearance: bool = True
def is_obstacle(self) -> bool:
return True
@@ -109,6 +114,11 @@ class Via(Item):
padstack_name: str = ""
location: IntPoint = field(default_factory=lambda: IntPoint(0, 0))
#: layer indices the via's pads occupy, and the largest pad as a box
layers: list[int] = field(default_factory=list)
shape: object = None
#: drill diameter in board units, when the padstack name gives it
drill: int = 0
@dataclass
+28
View File
@@ -50,6 +50,8 @@ class TreeShape:
#: which copper a keepout tile stops: "all", "wires" (a wire_keepout,
#: vias allowed) or "vias" (a via_keepout)
blocks: str = "all"
#: False for a rule area: copper may touch it, only overlap counts
clearance: bool = True
def on_layer(self, layer: int) -> bool:
return layer in self.layers
@@ -129,6 +131,8 @@ class ShapeSearchTree:
Uses a strict 2-D overlap of the ``clearance``-expanded box, so tiles
exactly ``clearance`` apart (touching after expansion) are allowed.
A rule area (``clearance=False``) only has to be clear of the copper
itself.
"""
expanded = box.offset(clearance)
for shape in self.overlapping(expanded):
@@ -136,6 +140,10 @@ class ShapeSearchTree:
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue # a wire-only keepout does not stop a via, and so on
if not shape.clearance:
if shape.tile.overlaps(box):
return True
continue
if shape.exact is not None:
if overlaps_2d(shape.exact, expanded):
return True
@@ -143,6 +151,17 @@ class ShapeSearchTree:
return True
return False
def rule_area_overlap(self, box: IntBox, layer: int, kind: str = "wire") -> bool:
"""True if ``box`` overlaps a rule area on ``layer`` that stops ``kind``."""
for shape in self.overlapping(box):
if shape.clearance or not shape.on_layer(layer):
continue
if shape.blocks != "all" and shape.blocks != kind + "s":
continue
if shape.tile.overlaps(box):
return True
return False
def clearance_conflict_shape(
self, region, net_no: int, layer: int
) -> bool:
@@ -169,6 +188,8 @@ class ShapeSearchTree:
overlap). Static pad-vs-pad spacing in the source design is not counted.
"""
for shape in self.all_shapes():
if not shape.routed:
continue # a pair with routed copper is checked from that side
expanded = shape.tile.offset(clearance)
grown = None # exact clearance region, built lazily only for diagonals
for other in self.overlapping(expanded):
@@ -178,6 +199,13 @@ class ShapeSearchTree:
continue # static input pair (e.g. pad vs pad) — not a routing DRC
if not (shape.layers & other.layers):
continue
if not (shape.clearance and other.clearance):
# a rule area: only an overlap is a violation
mine = shape.exact if shape.exact is not None else shape.tile
theirs = other.exact if other.exact is not None else other.tile
if overlaps_2d(mine, theirs):
return shape
continue
if shape.exact is None and other.exact is None:
if other.tile.overlaps(expanded): # byte-identical box path
return shape
+119 -19
View File
@@ -19,6 +19,7 @@ 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,
@@ -29,8 +30,8 @@ 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"}),
"grid": frozenset({"rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"exact": frozenset({"shove", "diagonal", "rip_up", "max_passes", "seed", "orders", "promote", "access", "rollback"}),
"room": frozenset({"shove", "pack"}),
}
@@ -41,6 +42,11 @@ _FLAG_NAMES = {
"diagonal": "--diagonal",
"rip_up": "--no-rip-up",
"max_passes": "-mp/--max-passes",
"seed": "--seed",
"orders": "--orders",
"promote": "--promote-failures",
"access": "--access-cost",
"rollback": "--no-rollback",
}
@@ -133,6 +139,56 @@ def build_parser() -> argparse.ArgumentParser:
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(
"--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
@@ -152,6 +208,11 @@ def main(argv: list[str] | None = None) -> int:
("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),
("rollback", not args.rollback),
)
if asked
]
@@ -173,29 +234,60 @@ def main(argv: list[str] | None = None) -> int:
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":
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":
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,
"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,
"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,
)
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,
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]]
@@ -205,6 +297,14 @@ def main(argv: list[str] | None = None) -> int:
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:",
+25
View File
@@ -27,6 +27,8 @@ __all__ = [
"NetPin",
"Net",
"NetClass",
"WiringVia",
"WiringWire",
"Keepout",
"DsnBoard",
]
@@ -186,6 +188,26 @@ class Keepout:
kind: str = "keepout" # keepout | via_keepout | wire_keepout | place_keepout
@dataclass
class WiringWire:
"""A track already on the board: ``(wire (path LAYER WIDTH x y ...) (net N))``."""
net: str
layer: str
width: float
coords: list[float] = field(default_factory=list)
@dataclass
class WiringVia:
"""A via already on the board: ``(via PADSTACK x y (net N))``."""
net: str
padstack: str
x: float
y: float
@dataclass
class DsnBoard:
"""The fully parsed DSN file."""
@@ -197,6 +219,9 @@ class DsnBoard:
layers: list[Layer] = field(default_factory=list)
#: copper zones, as planes of a net
planes: list[Plane] = field(default_factory=list)
#: copper already routed (the ``wiring`` scope), kept fixed
wires: list[WiringWire] = field(default_factory=list)
vias: list[WiringVia] = field(default_factory=list)
#: bounding-box boundary shape (layer ``pcb``), if present
boundary: Shape | None = None
#: outline shapes (layer ``signal``)
+54 -2
View File
@@ -9,6 +9,8 @@ tolerant ``skip_scope`` behaviour.
from __future__ import annotations
import re
from .model import (
ClearanceRule,
ComponentPlace,
@@ -25,6 +27,8 @@ from .model import (
Pin,
Resolution,
WidthRule,
WiringVia,
WiringWire,
)
from .sexp import SExp, parse
from .shapes import Shape, read_area, read_shape
@@ -481,12 +485,26 @@ def _read_network(node: SExp, board: DsnBoard) -> None:
# --- top level ---------------------------------------------------------------
_STRING_QUOTE = re.compile(r"\(\s*string_quote\s+(\S)\s*\)")
def _declared_quote(text: str) -> str:
"""The quote character the file declares in ``(string_quote X)``.
Only that character opens a string. Without a declaration both kinds are
accepted, as before: KiCad declares ``"`` and then writes a bare ``'``
where a value is a single quote mark, e.g. a keycap part number.
"""
found = _STRING_QUOTE.search(text[:4096])
return found.group(1) if found else "\"'"
def parse_dsn(text: str) -> DsnBoard:
"""Parse DSN ``text`` into a :class:`DsnBoard`.
Raises :class:`DsnParseError` if the top-level scope is not ``(pcb ...)``.
"""
top = parse(text)
top = parse(text, _declared_quote(text))
if top.head != "pcb":
raise DsnParseError(
f"not a Specctra DSN file: expected top-level '(pcb ...)', got '({top.head} ...)'"
@@ -515,11 +533,45 @@ def parse_dsn(text: str) -> DsnBoard:
_read_placement(it, board)
elif head == "network":
_read_network(it, board)
# `wiring` and unknown scopes are intentionally ignored for now.
elif head == "wiring":
_read_wiring(it, board)
# unknown scopes are ignored
return board
def _read_wiring(node: SExp, board: DsnBoard) -> None:
"""Tracks and vias already on the board. They stay where they are: the
router treats them as copper of their net that other nets keep clear of."""
for it in node.items:
if not isinstance(it, SExp):
continue
net_scope = it.child("net")
net = _first_value(net_scope) if net_scope is not None else None
if not net:
continue # netless copper would need a keepout, not a wire
if it.head == "wire":
path = it.child("path") or it.child("polyline_path")
vals = [v.text for v in path.values()] if path is not None else []
if len(vals) < 6:
continue
try:
width = float(vals[1])
coords = [float(v) for v in vals[2:]]
except ValueError:
continue
board.wires.append(WiringWire(net=net, layer=vals[0], width=width, coords=coords))
elif it.head == "via":
vals = [v.text for v in it.values()]
if len(vals) < 3:
continue
try:
x, y = float(vals[1]), float(vals[2])
except ValueError:
continue
board.vias.append(WiringVia(net=net, padstack=vals[0], x=x, y=y))
def _read_plane(node: SExp):
"""``(plane <net> (polygon <layer> <width> x y ...))`` -> :class:`Plane`."""
from .model import Plane
+83 -7
View File
@@ -31,17 +31,19 @@ from dataclasses import dataclass, field
import math
from freeroute.board.board import BasicBoard
from freeroute.board.items import Pin
from freeroute.board.items import Pin, Via
from freeroute.board.search_tree import ShapeSearchTree, TreeShape
from freeroute.geometry import (
IntBox,
IntPoint,
Polyline,
PolylineShape,
Simplex,
segment_box,
segment_octagon,
shove_segment,
)
from freeroute.geometry.polyline import corner_box
from .grid_router import RouteResult, route_board
@@ -94,6 +96,13 @@ def route_board_exact(
edge_clearance: int | None = None,
resolution: int = 1,
heuristic_weight: float = 1.0,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
rollback: bool = True,
net_widths: dict[int, int] | None = None,
via_drill: int | None = None,
hole_to_hole: int = 0,
) -> ExactRouteResult:
"""Route ``board`` orthogonally and verify the result with exact geometry.
@@ -122,6 +131,13 @@ def route_board_exact(
edge_clearance=edge_clearance,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
net_widths=net_widths,
via_drill=via_drill,
hole_to_hole=hole_to_hole,
)
edge = 0 if edge_clearance is None else edge_clearance
outline = board.outline
@@ -139,7 +155,13 @@ def route_board_exact(
net = pin.net_nos[0] if pin.net_nos else -1
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
# a rotated pad is checked against its outline, not its box
exact = pin.shape if _is_rotated(pin.shape) else None
tree.insert(TreeShape(next(owner), net, pin_layers, box, exact=exact))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
@@ -151,35 +173,54 @@ def route_board_exact(
box = tile.bounding_box()
if not box.is_empty():
tree.insert(
TreeShape(next(owner), -1, frozenset({obstacle.layer}), box, blocks=blocks)
TreeShape(
next(owner), -1, frozenset({obstacle.layer}), box,
blocks=blocks, clearance=obstacle.clearance,
)
)
via_layers = frozenset(signal_layers)
accepted: dict[int, _Accepted] = {}
fixed_vias = {
(item.net_nos[0], item.location)
for item in board.get_items()
if isinstance(item, Via) and item.fixed and item.net_nos
}
# Verify connection by connection, so one conflicting segment of a
# 25-pin supply net costs that one connection, not the whole net.
rejected: list[tuple[int, str, str]] = []
ends_list = result.connection_ends or [("?", "?", "")] * len(result.connections)
# each net is checked at the width it will be written at: its class's
# when all of its connections went in at that, else the default
net_half = dict(result.net_half)
for (net_no, wires, vias), ends in sorted(
zip(result.connections, ends_list, strict=False), key=lambda c: c[0][0]
):
boxes = _copper_boxes(wires, vias, half_width, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], half_width, via_layers, via_half))
wire_half = net_half.get(net_no, half_width)
boxes = _copper_boxes(wires, vias, wire_half, via_layers, via_half)
wire_count = len(_copper_boxes(wires, [], wire_half, via_layers, via_half))
if any(
tree.clearance_conflict(
box, net_no, layer, clearance, "wire" if i < wire_count else "via"
)
for i, (box, box_layers) in enumerate(boxes)
for layer in box_layers
) or (outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)):
) or _end_in_rule_area(tree, wires, wire_half) or (
outline is not None and not all(outline.box_clear(b, edge) for b, _ in boxes)
):
rejected.append((net_no, *ends))
continue # exact-clearance conflict -> drop (may be recovered by shove)
acc = accepted.get(net_no)
if acc is None:
acc = accepted[net_no] = _Accepted(wires=[], vias=[])
acc.wires.extend(wires)
acc.vias.extend(vias)
# connections of one net that end on the same plane cell share its
# via: write it once, and never on top of a via already on the board
acc.vias.extend(
v for v in dict.fromkeys(vias)
if v not in acc.vias and not _on_fixed_via(net_no, v, fixed_vias, via_half)
)
for box, box_layers in boxes:
oid = next(owner)
tree.insert(TreeShape(oid, net_no, box_layers, box, routed=True))
@@ -229,6 +270,17 @@ def route_board_exact(
for net_no, acc in accepted.items():
clean.wires[net_no] = acc.wires
clean.vias[net_no] = acc.vias
# shove and the diagonal pass lay copper at the default width: nets they
# recovered, and nets whose traces shove pushed aside
reshaped = (set(accepted) - accepted_before_recovery) | {
net for net, acc in accepted.items() if acc.shove_count
}
if diagonal:
reshaped = set(accepted)
clean.net_half = {
net: half for net, half in net_half.items() if net in accepted and net not in reshaped
}
clean.narrowed = list(result.narrowed)
drc_clean = tree.has_violation(clearance) is None
return ExactRouteResult(result=clean, tree=tree, drc_clean=drc_clean, shoves=shoves)
@@ -586,6 +638,26 @@ def _net_boxes(result, net_no, half_width, via_layers, via_half=None):
)
def _end_in_rule_area(tree, wires, half_width) -> bool:
"""A wire's tiles stop at its end points, but a track ends in a round cap
half its width beyond them. Against other copper the pad or via there
covers it; a rule area has no clearance to absorb it, so check it."""
for layer, points in wires:
for end in (points[0], points[-1]):
if tree.rule_area_overlap(corner_box(end, half_width), layer):
return True
return False
def _on_fixed_via(net_no, point, fixed_vias, via_half) -> bool:
"""A routed via that reuses a via already on the board: the grid put it
on the fixed via's cell, a little off its centre, and it is not written."""
return any(
net == net_no and abs(loc.x - point.x) <= via_half and abs(loc.y - point.y) <= via_half
for net, loc in fixed_vias
)
def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
via_half = via_half or half_width
boxes: list[tuple[IntBox, frozenset[int]]] = []
@@ -598,6 +670,10 @@ def _copper_boxes(wires, vias, half_width, via_layers, via_half=None):
return boxes
def _is_rotated(shape) -> bool:
return isinstance(shape, Simplex) and not shape.is_int_box()
def _net_pins(board: BasicBoard, net_no: int, signal_layers) -> list[Pin]:
return [
pin
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
"""Route one board in several connection orders at once and keep the best.
Which connection routes first moves the result on a crowded board by several
connections either way, and no single order wins everywhere. Routing a few
orders in parallel processes and keeping the one that leaves the fewest
connections unrouted costs cores, not wall time.
Without a seed the candidates are the default order and seeds 0 to N-2, so the
result is never worse than a plain run. With seed S they are seeds S to S+N-1.
Ties go to the earlier candidate, so the choice is deterministic.
"""
from __future__ import annotations
from concurrent.futures import ProcessPoolExecutor
import os
from freeroute.dsn import parse_dsn
from freeroute.ses import RoutingResult
from .pipeline import build_exact_routing_result, build_routing_result
def candidate_seeds(orders: int, seed: int | None = None) -> list[int | None]:
orders = max(int(orders), 1)
if seed is None:
return [None, *range(orders - 1)]
return list(range(seed, seed + orders))
def score(result: RoutingResult) -> tuple[int, int]:
"""Connections left open, then those left to a zone fill: lower is better."""
missing = sum(1 for u in result.unrouted if not u[3])
return missing, len(result.unrouted) - missing
def route_once(dsn_text: str, engine: str, options: dict, seed: int | None) -> RoutingResult:
dsn = parse_dsn(dsn_text)
if engine == "exact":
return build_exact_routing_result(dsn, seed=seed, **options)
if engine == "grid":
return build_routing_result(dsn, seed=seed, **options)
raise ValueError(f"routing orders are not supported by the {engine} engine")
def best_of_orders(
dsn_text: str,
engine: str,
options: dict,
*,
orders: int,
seed: int | None = None,
jobs: int | None = None,
) -> tuple[RoutingResult, int | None, list[tuple[int | None, tuple[int, int]]]]:
"""Route ``orders`` orders and return the best result, the seed that made
it (None for the default order), and every candidate's seed and score."""
seeds = candidate_seeds(orders, seed)
if len(seeds) == 1:
result = route_once(dsn_text, engine, options, seeds[0])
return result, seeds[0], [(seeds[0], score(result))]
jobs = max(1, min(jobs or os.cpu_count() or 1, len(seeds)))
with ProcessPoolExecutor(jobs) as pool:
futures = [pool.submit(route_once, dsn_text, engine, options, s) for s in seeds]
results = [f.result() for f in futures]
scores = [score(r) for r in results]
best = min(range(len(seeds)), key=lambda i: (scores[i], i))
return results[best], seeds[best], list(zip(seeds, scores, strict=True))
def promote_failures(
dsn_text: str,
engine: str,
options: dict,
*,
rounds: int,
seed: int | None = None,
) -> tuple[RoutingResult, list[tuple[int, int]]]:
"""Route, then route again with every connection that has failed so far
moved to the front, keeping the rest in their order. Returns the best
round's result and every round's score.
The promoted set only grows, so a connection that failed once keeps its
early place and two groups cannot take turns failing. Stops early when a
round routes everything or fails nothing new."""
numbers = {n.name: i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets)}
first: frozenset = frozenset()
best: RoutingResult | None = None
scores: list[tuple[int, int]] = []
for _ in range(max(int(rounds), 1)):
result = route_once(dsn_text, engine, {**options, "route_first": first}, seed)
scores.append(score(result))
if best is None or scores[-1] < score(best):
best = result
failed = {(numbers.get(name), a, b) for name, a, b, zone in result.unrouted if not zone}
if not failed or failed <= first:
break
first = first | failed
return best, scores
+86 -2
View File
@@ -12,6 +12,8 @@ to DSN units (dividing by the resolution) for the SES ``(wire (path ...))`` and
from __future__ import annotations
import re
from freeroute.board import build_board
from freeroute.dsn import DsnBoard, parse_dsn
from freeroute.ses import RoutedVia, RoutedWire, RoutingResult, write_ses
@@ -34,6 +36,25 @@ def _rule_width_dsn(dsn: DsnBoard) -> float:
return rules[0].value if rules else _DEFAULT_WIDTH_DSN
def _net_widths_board(dsn: DsnBoard, scale: int) -> dict[int, int]:
"""Track width of every net whose class asks for more than the default,
in board units, by net number (build_board numbers nets in DSN order)."""
default = _rule_width_dsn(dsn)
by_name: dict[str, float] = {}
for net_class in dsn.net_classes:
if not net_class.width_rules:
continue
width = net_class.width_rules[0].value
if width > default:
for name in net_class.net_names:
by_name[name] = max(by_name.get(name, 0.0), width)
return {
i + 1: round(by_name[net.name] * scale)
for i, net in enumerate(dsn.nets)
if net.name in by_name
}
def _rule_clearance_dsn(dsn: DsnBoard) -> float:
for rule in dsn.structure_rules.clearance_rules:
if not rule.class_pairs: # the layer-wide default clearance
@@ -53,6 +74,20 @@ def _mm_to_board(dsn: DsnBoard, scale: int, value_mm: float | None) -> int | Non
return round(value_mm / unit_mm * scale)
def _via_drill_board(dsn: DsnBoard, scale: int) -> int | None:
"""The via's drill in board units, from KiCad's padstack name
(``Via[0-1]_600:300_um`` is a 600 um pad with a 300 um drill); None when
the name does not say, and the pad size stands in for it."""
match = re.search(r"_([\d.]+):([\d.]+)_um", _via_padstack(dsn))
if not match:
return None
return round(float(match.group(2)) * _dsn_units_per_um(dsn) * scale)
def _dsn_units_per_um(dsn: DsnBoard) -> float:
return {"um": 1.0, "mm": 0.001, "mil": 1 / 25.4, "inch": 1 / 25400}.get(dsn.unit, 1.0)
def _via_half_board(dsn: DsnBoard, scale: int) -> int | None:
"""Half the via pad's size in board units, from the DSN's via padstack.
@@ -80,6 +115,11 @@ def route_dsn_board(
layers: list[int] | None = None,
rip_up: bool = True,
max_passes: int = 10,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[RouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard`; return the board-unit result, the scale,
and the layer names. ``layers`` restricts routing to those signal-layer
@@ -100,6 +140,13 @@ def route_dsn_board(
layers=layers,
rip_up=rip_up,
max_passes=max_passes,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
)
return result, scale, [layer.name for layer in dsn.layers]
@@ -114,6 +161,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result = RoutingResult()
for net_no, segments in route.wires.items():
name = numbers_to_names.get(net_no, str(net_no))
# its class's width when every connection of the net went in at it
half = getattr(route, "net_half", {}).get(net_no)
net_width = 2 * half / scale if half else width_dsn
for layer_index, path in segments:
layer_name = layer_names[layer_index] if layer_index < len(layer_names) else "F.Cu"
coords: list[float] = []
@@ -121,7 +171,7 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
coords.append(point.x / scale)
coords.append(point.y / scale)
if len(coords) >= 4:
result.add_wire(name, RoutedWire(layer=layer_name, width=width_dsn, coords=coords))
result.add_wire(name, RoutedWire(layer=layer_name, width=net_width, coords=coords))
for net_no, locations in route.vias.items():
name = numbers_to_names.get(net_no, str(net_no))
for loc in locations:
@@ -129,6 +179,9 @@ def _to_routing_result(route, dsn: DsnBoard, scale: int, layer_names: list[str])
result.unrouted = [
(numbers_to_names.get(n, str(n)), a, b, zone) for n, a, b, zone in getattr(route, "failed", [])
]
result.narrowed = [
(numbers_to_names.get(n, str(n)), a, b) for n, a, b in getattr(route, "narrowed", [])
]
return result
@@ -140,11 +193,20 @@ def build_routing_result(
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (grid track) and convert to a DSN-unit RoutingResult."""
route, scale, layer_names = route_dsn_board(
dsn, layers=layers, rip_up=rip_up, max_passes=max_passes,
edge_clearance_mm=edge_clearance_mm, resolution=resolution,
edge_clearance_mm=edge_clearance_mm, resolution=resolution, seed=seed,
route_first=route_first,
access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
return _to_routing_result(route, dsn, scale, layer_names)
@@ -160,6 +222,11 @@ def route_dsn_board_exact(
rip_up: bool = True,
shove: bool = False,
diagonal: bool = False,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> tuple[ExactRouteResult, int, list[str]]:
"""Route a parsed :class:`DsnBoard` with the exact-geometry (orthogonal,
DRC-verified) track; return the exact result, the scale, and layer names.
@@ -184,6 +251,13 @@ def route_dsn_board_exact(
rip_up=rip_up,
shove=shove,
diagonal=diagonal,
seed=seed,
route_first=route_first,
access_cost=access_cost,
rollback=rollback,
net_widths=_net_widths_board(dsn, scale),
via_drill=_via_drill_board(dsn, scale),
hole_to_hole=_mm_to_board(dsn, scale, hole_to_hole_mm) or 0,
)
return exact, scale, [layer.name for layer in dsn.layers]
@@ -199,6 +273,11 @@ def build_exact_routing_result(
rip_up: bool = True,
max_passes: int = 10,
edge_clearance_mm: float | None = None,
seed: int | None = None,
route_first: frozenset | None = None,
access_cost: float = 0.0,
hole_to_hole_mm: float = 0.25,
rollback: bool = True,
) -> RoutingResult:
"""Route ``dsn`` (exact track) and convert to a DSN-unit RoutingResult."""
exact, scale, layer_names = route_dsn_board_exact(
@@ -211,6 +290,11 @@ def build_exact_routing_result(
edge_clearance_mm=edge_clearance_mm,
resolution=resolution,
heuristic_weight=heuristic_weight,
seed=seed,
route_first=route_first,
access_cost=access_cost,
hole_to_hole_mm=hole_to_hole_mm,
rollback=rollback,
)
return _to_routing_result(exact.result, dsn, scale, layer_names)
+4
View File
@@ -515,6 +515,10 @@ def route_board_rooms(
pin_layers = frozenset(layer for layer in pin.layers if layer in signal_layers)
if pin_layers:
tree.insert(TreeShape(next(owner), net, pin_layers, box))
for net, copper_layers, box in board.get_fixed_copper():
on = frozenset(layer for layer in copper_layers if layer in signal_layers)
if on and not box.is_empty():
tree.insert(TreeShape(next(owner), net, on, box))
for obstacle in board.get_obstacle_areas():
if obstacle.layer not in signal_layers:
continue
+3
View File
@@ -60,6 +60,9 @@ class RoutingResult:
#: connections the router could not make: (net name, one end, other end,
#: the zone both ends sit in or "") -- a zone's fill may join the latter
unrouted: list[tuple[str, str, str, str]] = field(default_factory=list)
#: connections of a wider net class that only fitted at the default width
#: (net name, one end, other end); their net is written at the default
narrowed: list[tuple[str, str, str]] = field(default_factory=list)
def net(self, name: str, *, create: bool = False) -> RoutedNet | None:
"""Return the :class:`RoutedNet` with ``name`` (creating it if asked)."""
+34
View File
@@ -254,3 +254,37 @@ def test_via_size_comes_from_the_via_padstack():
from freeroute.route.pipeline import _via_half_board
assert _via_half_board(parse_dsn(_PAD_DSN), 10) == 3000 # 0.6 mm via, board units
# --- rule areas: overlap only ------------------------------------------------
_CHANNEL_DSN = """(pcb channel.dsn
(parser (string_quote ")) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 40000 0 40000 -30000 0 -30000 0 0))
(keepout "{name}top" (polygon signal 0 18000 0 22000 0 22000 -14750 18000 -14750))
(keepout "{name}bottom" (polygon signal 0 18000 -15250 22000 -15250 22000 -30000 18000 -30000))
(rule (width 250) (clearance 150)))
(placement (component P (place P1 5000 -15000 front 0) (place P2 35000 -15000 front 0)))
(library (image P (pin pad 1 0 0)) (padstack pad (shape (circle F.Cu 600)) (attach off)))
(network (net N (pins P1-1 P2-1))))"""
def test_a_keepout_named_rule_area_takes_no_clearance():
named = _areas(_CHANNEL_DSN.format(name="rule_area_"))
plain = _areas(_CHANNEL_DSN.format(name=""))
assert named and not any(a.clearance for a in named)
assert plain and all(a.clearance for a in plain)
def _route_channel(name):
from freeroute.route.pipeline import build_exact_routing_result
return build_exact_routing_result(parse_dsn(_CHANNEL_DSN.format(name=name)), resolution=2)
def test_a_trace_may_run_beside_a_rule_area_closer_than_clearance():
# the 0.5 mm channel fits a 0.25 mm trace, but not with 0.15 mm each side
assert _route_channel("rule_area_").unrouted == []
assert _route_channel("").unrouted != []
+34
View File
@@ -62,3 +62,37 @@ def test_has_violation_ignores_static_pad_pairs():
# a routed trace of net 3 within clearance of pad net 1 -> violation
tree.insert(shape(2, 3, 0, IntBox(11, 0, 20, 10), routed=True))
assert tree.has_violation(clearance=20) is not None
def test_a_rule_area_conflicts_only_on_overlap():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
near = IntBox(0, 0, 95, 100) # 5 short of the area, well inside clearance 50
over = IntBox(0, 0, 105, 100)
assert not tree.clearance_conflict(near, 7, 0, 50)
assert tree.clearance_conflict(over, 7, 0, 50)
def test_an_ordinary_keepout_still_needs_clearance():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100)))
assert tree.clearance_conflict(IntBox(0, 0, 95, 100), 7, 0, 50)
def test_the_routed_board_check_treats_a_rule_area_the_same_way():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, 7, frozenset({0}), IntBox(0, 0, 95, 100), routed=True))
assert tree.has_violation(50) is None
tree.insert(TreeShape(3, 7, frozenset({0}), IntBox(150, 0, 160, 100), routed=True))
assert tree.has_violation(50) is not None
def test_rule_area_overlap_sees_only_rule_areas():
tree = ShapeSearchTree()
tree.insert(TreeShape(1, -1, frozenset({0}), IntBox(100, 0, 200, 100), clearance=False))
tree.insert(TreeShape(2, -1, frozenset({0}), IntBox(300, 0, 400, 100)))
assert tree.rule_area_overlap(IntBox(90, 40, 110, 60), 0)
assert not tree.rule_area_overlap(IntBox(290, 40, 310, 60), 0)
assert not tree.rule_area_overlap(IntBox(90, 40, 110, 60), 1)
+23
View File
@@ -156,3 +156,26 @@ def test_unplaced_component_has_no_coords():
place = board.placements[0].places[0]
assert place.name == "U9"
assert place.x is None and place.y is None
# --- string_quote ----------------------------------------------------------
_QUOTED = """(pcb q.dsn
(parser (string_quote {q}) (space_in_quoted_tokens on))
(resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (boundary (path pcb 0 0 0 1000 0 1000 1000 0 1000 0 0)))
(placement (component "SW1" (place SW1 100 100 front 0 (PN {pn}))))
(library)
(network)
)"""
def test_a_bare_apostrophe_is_an_atom_when_the_file_quotes_with_double_quotes():
# KiCad declares " and writes a keycap's part number as a bare '
board = parse_dsn(_QUOTED.format(q='"', pn="'"))
assert board.parser.string_quote == '"'
def test_the_declared_quote_character_is_the_one_that_opens_strings():
board = parse_dsn(_QUOTED.format(q="'", pn="'10k 1%'").replace('"SW1"', "'SW1'"))
assert board.parser.string_quote == "'"
+108
View File
@@ -0,0 +1,108 @@
"""Tracks and vias already on the board (the DSN's wiring) are fixed copper."""
from __future__ import annotations
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
_DSN = """(pcb wired (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0))
(via "Via[0-1]_600:300_um") (rule (width 250) (clearance 200)))
(placement (component S (place A1 5000 -10000 front 0) (place A2 25000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -400 -400 400 400)) (attach off))
(padstack "Via[0-1]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net SIG (pins A1-1 A2-1)) (net GND))
(wiring
(wire (path F.Cu 400 15000 0 15000 -20000) (net GND) (type route))
(via "Via[0-1]_600:300_um" 8000 -14000 (net GND) (type route))))"""
def test_wiring_is_read_with_its_nets():
dsn = parse_dsn(_DSN)
assert [(w.net, w.layer, w.width) for w in dsn.wires] == [("GND", "F.Cu", 400.0)]
assert [(v.net, v.x, v.y) for v in dsn.vias] == [("GND", 8000.0, -14000.0)]
def test_wiring_is_fixed_copper_and_not_a_connection_to_route():
board = build_board(parse_dsn(_DSN))
copper = board.get_fixed_copper()
gnd = next(n.net_number for n in board.nets if n.name == "GND")
assert copper and all(net == gnd for net, _, _ in copper)
assert not any(gnd in p.net_nos for p in board.get_pins())
def test_other_nets_route_around_a_fixed_track():
dsn = parse_dsn(_DSN)
exact, scale, names = route_dsn_board_exact(dsn, resolution=2)
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert sig in exact.result.routed_net_numbers
assert exact.drc_clean
wall = 15000 * scale
for layer, pts in exact.result.wires.get(sig, []):
if names[layer] != "F.Cu":
continue
for p, q in zip(pts, pts[1:], strict=False):
assert not (min(p.x, q.x) < wall < max(p.x, q.x)), "crossed the GND track on F.Cu"
# --- fixed wiring as connectivity ------------------------------------------
_FANOUT = """(pcb fanout (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -19500 500 -19500 500 -500))
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 5000 -10000 front 0) (place A 10000 -10000 front 0)
(place B 25000 -11500 back 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1)) (net SIG (pins A-1 B-1)))
(wiring
(wire (path F.Cu 150 5000 -10000 5000 -11500) (net GND) (type route))
(via "Via[0-3]_600:300_um" 5000 -11500 (net GND) (type route))
(wire (path F.Cu 150 10000 -10000 10000 -11500) (net SIG) (type route))
(via "Via[0-3]_600:300_um" 10000 -11500 (net SIG) (type route))
(wire (path F.Cu 300 2000 -2000 8000 -2000) (net GND) (type route))))"""
def test_a_pad_fanned_out_to_its_plane_needs_no_route():
dsn = parse_dsn(_FANOUT)
exact, _scale, _names = route_dsn_board_exact(dsn, resolution=2)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
assert exact.result.failed == []
assert not exact.result.vias.get(gnd) and not exact.result.wires.get(gnd)
def test_a_fanned_out_signal_is_routed_from_its_via():
# B is on the bottom face, level with A's fan-out via: starting from that
# via on B.Cu is the cheapest way, and needs no via of its own
dsn = parse_dsn(_FANOUT)
exact, scale, names = route_dsn_board_exact(dsn, resolution=2)
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert sig in exact.result.routed_net_numbers
assert not exact.result.vias.get(sig), "the fan-out via is already there"
assert {names[layer] for layer, _ in exact.result.wires[sig]} == {"B.Cu"}
def test_the_session_never_repeats_copper_already_on_the_board():
# the JAR writes the DSN's wiring back into its session, and an applier
# then adds it a second time on top of itself; freeroute writes only
# what it routed
from freeroute.route.pipeline import build_exact_routing_result
fixed_vias = {(5000.0, -11500.0), (10000.0, -11500.0)}
fixed_paths = {(5000.0, -10000.0, 5000.0, -11500.0), (10000.0, -10000.0, 10000.0, -11500.0),
(2000.0, -2000.0, 8000.0, -2000.0)}
result = build_exact_routing_result(parse_dsn(_FANOUT), resolution=2)
for net in result.nets:
assert not {(v.x, v.y) for v in net.vias} & fixed_vias
assert not {tuple(w.coords) for w in net.wires} & fixed_paths
+227
View File
@@ -0,0 +1,227 @@
"""The grid must not believe copper legal that the exact check then refuses.
Each case is one way the two used to disagree on roomba-ESP12E: a rotated oval
pad modelled as an inflated box (and opened to its own net), a via shared by
two connections of a net losing its footprint when one of them is ripped, and
a wider net class searched as if it were the default width.
"""
from __future__ import annotations
import math
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntBox, Simplex
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import (
_rule_clearance_dsn,
_rule_width_dsn,
_via_half_board,
build_exact_routing_result,
)
# --- rotated oval pads -------------------------------------------------------
def _socket_dsn(rotation: float, layout: str) -> str:
"""A 1x4 2.54 mm socket strip (rect pin 1, oval pins 2-4) at ``rotation``,
each pin its own net, routed to a small SMD pad 0.9 mm away."""
cx, cy = 100000, -100000
t = math.radians(rotation)
ax = (math.cos(t), math.sin(t))
perp = (-math.sin(t), math.cos(t))
places = []
for i in range(4):
px, py = cx + 2540 * i * ax[0], cy + 2540 * i * ax[1]
side = 1 if i % 2 == 0 else -1
if layout == "y":
tx, ty = px, py + side * 9000
else:
tx, ty = px + side * 8000 * perp[0], py + side * 8000 * perp[1]
places.append(f"(place T{i + 1} {round(tx)} {round(ty)} front 0)")
nets = " ".join(f"(net N{i} (pins P3-{i + 1} T{i + 1}-1))" for i in range(4))
return f"""(pcb socket (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 125000 -115000 85000 -115000 85000 -75000 125000 -75000
125000 -115000))
(via "Via[0-1]_800:400_um")
(rule (width 300) (clearance 250)))
(placement
(component S (place P3 {cx} {cy} front {rotation}))
(component T {" ".join(places)}))
(library
(image S (pin R 1 0 0) (pin O 2 2540 0) (pin O 3 5080 0) (pin O 4 7620 0))
(image T (pin t 1 0 0))
(padstack O (shape (path F.Cu 1727.2 0 -152.4 0 152.4))
(shape (path B.Cu 1727.2 0 -152.4 0 152.4)) (attach off))
(padstack R (shape (rect F.Cu -863.6 -1016 863.6 1016))
(shape (rect B.Cu -863.6 -1016 863.6 1016)) (attach off))
(padstack t (shape (rect F.Cu -500 -500 500 500)) (attach off))
(padstack "Via[0-1]_800:400_um" (shape (circle F.Cu 800)) (shape (circle B.Cu 800))
(attach off)))
(network {nets}
(class c N0 N1 N2 N3 (circuit (use_via "Via[0-1]_800:400_um"))
(rule (width 300) (clearance 250)))))"""
def test_a_rotated_oval_pad_is_its_outline_not_its_bounding_box():
board = build_board(parse_dsn(_socket_dsn(60, "y")))
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
assert isinstance(oval.shape, Simplex) and not oval.shape.is_int_box()
box = oval.shape.bounding_box()
assert oval.shape.contains(oval.location)
assert not oval.shape.contains(box.ll) and not oval.shape.contains(box.ur)
@pytest.mark.parametrize("layout", ["y", "perp"])
def test_routes_leaving_a_rotated_socket_pass_the_exact_check(layout):
result = build_exact_routing_result(
parse_dsn(_socket_dsn(60, layout)), resolution=2, shove=True
)
assert result.unrouted == []
# --- a via two connections share --------------------------------------------
_SHARED_VIA = """(pcb shared (parser) (resolution um 10) (unit um)
(structure
(layer Top (type signal)) (layer Bottom (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(via "Via[0-1]_800:400_um")
(rule (width 300) (clearance 250)))
(library
(padstack p (shape (rect Top -400 -400 400 400)) (attach off))
(padstack "Via[0-1]_800:400_um" (shape (circle Top 800)) (shape (circle Bottom 800))
(attach off))
(image PAD (pin p 1 0 0)))
(placement (component PAD
(place G1 3000 -5000 front 0) (place G2 3000 -15000 front 0)
(place G3 17000 -10000 front 0) (place S1 3000 -10000 front 0)
(place S2 17000 -3000 front 0)))
(network (net GND (pins G1-1 G2-1 G3-1)) (net SIG (pins S1-1 S2-1))))"""
def _line(a, b, layer):
(x, y), (x1, y1) = a, b
out = [(x, y, layer)]
while x != x1:
x += 1 if x1 > x else -1
out.append((x, y, layer))
while y != y1:
y += 1 if y1 > y else -1
out.append((x, y, layer))
return out
def test_a_shared_via_keeps_its_footprint_when_one_user_is_ripped():
dsn = parse_dsn(_SHARED_VIA)
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
via_half=_via_half_board(dsn, s),
)
conns = router._conns = router._build_connections()
gnd = [c for c in conns if len({c.a.component_ref, c.b.component_ref} & {"G1", "G2", "G3"})]
sig = next(c for c in conns if c not in gnd)
first, second = gnd
g3 = router._cell_of(next(p for p in router.board.get_pins() if p.component_ref == "G3").location)
via = (g3[0] - 10, g3[1])
def through_via(conn):
a, b = router._cell_of(conn.a.location), router._cell_of(conn.b.location)
return _line(a, via, 0) + _line(via, b, 1)
router._commit(first, through_via(first))
router._commit(second, through_via(second))
footprint = [c for c in router._via_cells(via) if c != via]
router._rip(first)
assert second.routed and via in second.via_cells
assert all(router._via_owner.get(c) == second.index for c in footprint)
# another net still keeps its distance from the via that is left
assert all(router._enter_cost_base(c, 0, sig.net_no, False, conns) is None for c in footprint)
# --- a wider net class --------------------------------------------------------
_WIDE = """(pcb wide (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
(rule (width 300) (clearance 250)))
(placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0)
(place Q1 10000 {ylo} front 0) (place Q2 10000 {yhi} front 0)
(place Q3 10000 -1000 front 0) (place Q4 10000 -9000 front 0)))
(library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off)))
(network (net PWR (pins P1-1 P2-1)) (net SIG (pins Q1-1 Q3-1)) (net SIG2 (pins Q2-1 Q4-1))
(class Power PWR (rule (width 500) (clearance 250)))))"""
@pytest.mark.parametrize("resolution", [1, 2])
@pytest.mark.parametrize("gap", [900, 1000, 1075])
def test_a_wide_net_past_pads_it_does_not_fit_between_is_not_refused(gap, resolution):
# 0.3 mm fits the gap between the two pads, 0.5 mm does not: the grid
# must see that, rather than lay it wide and have the exact check drop it
ylo, yhi = -5000 - gap // 2 - 600, -5000 + gap // 2 + 600
result = build_exact_routing_result(
parse_dsn(_WIDE.format(ylo=ylo, yhi=yhi)), resolution=resolution
)
assert result.unrouted == []
def _wide_router():
dsn = parse_dsn(_WIDE.format(ylo=-6050, yhi=-3950))
s = dsn.resolution.value
pwr = next(i for i, net in enumerate(dsn.nets, 1) if net.name == "PWR")
return GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
resolution=2,
net_widths={pwr: 5000},
), pwr
def test_a_wide_trace_keeps_clear_of_a_default_one_at_the_grid_diagonal():
router, _pwr = _wide_router()
# 0.5 mm against 0.3 mm needs 0.25 + 0.25 + 0.15 = 0.65 mm between centres,
# on either axis, since the exact check compares boxes
need = 2500 + router.clearance + router.half_width
for dx, dy in [(2, 2), (2, 1), (1, 2)]:
assert max(dx, dy) * router.step < need
assert (dx, dy) in router._halo_for(2500)
def test_a_wide_rip_up_route_counts_what_its_extra_width_crosses():
router, pwr_net = _wide_router()
conns = router._conns = router._build_connections()
pwr = next(c for c in conns if c.net_no == pwr_net)
other = next(c for c in conns if c.net_no != pwr_net)
# a default-width trace two cells from the wide route's path
x, y = 20, 5
router._commit(other, _line((x - 3, y + 2), (x + 3, y + 2), 0))
path = _line((x - 3, y), (x + 3, y), 0)
spread = router._spread_for(2500)
assert other.index not in router._crossed_owners(path, pwr.net_no, conns)
assert other.index in router._crossed_owners(path, pwr.net_no, conns, spread)
def test_a_pad_cell_check_uses_the_rotated_outline():
board = build_board(parse_dsn(_socket_dsn(60, "y")))
router = GridRouter(board, trace_width=3000, clearance=2500, resolution=2)
oval = next(p for p in board.get_pins() if p.component_ref == "P3" and p.name == "2")
box = oval.shape.bounding_box()
margin = router.clearance + router.half_width
exact = set(router._pad_cells(oval.shape, box, margin))
boxed = set(router._cells_in_box(box, margin))
assert exact < boxed
for cell in boxed - exact:
c = router._cell_center(cell)
square = IntBox(c.x - margin, c.y - margin, c.x + margin, c.y + margin)
assert oval.shape.intersection(square).dimension() != 2
+42
View File
@@ -0,0 +1,42 @@
"""Net classes wider than the default are routed at their width where it fits."""
from __future__ import annotations
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import build_exact_routing_result
_BOARD = """(pcb widths (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -10000 0 -10000 0 0))
{keepouts}
(rule (width 150) (clearance 150)))
(placement (component S (place P1 2000 -5000 front 0) (place P2 18000 -5000 front 0)))
(library (image S (pin pad 1 0 0)) (padstack pad (shape (rect F.Cu -600 -600 600 600)) (attach off)))
(network (net PWR (pins P1-1 P2-1))
(class Power PWR (rule (width 400) (clearance 150)))))"""
# two keepouts leave a 0.65 mm channel: room for 0.15 mm (0.45), not for 0.4 mm (0.7)
_CHANNEL = (
'(keepout "" (polygon F.Cu 0 9000 0 11000 0 11000 -4675 9000 -4675))\n'
' (keepout "" (polygon F.Cu 0 9000 -5325 11000 -5325 11000 -10000 9000 -10000))'
)
def _route(keepouts=""):
return build_exact_routing_result(parse_dsn(_BOARD.format(keepouts=keepouts)), resolution=2)
def test_a_wider_class_is_routed_and_written_at_its_width():
result = _route()
assert result.unrouted == [] and result.narrowed == []
widths = {w.width for net in result.nets for w in net.wires}
assert widths == {400.0}
def test_a_wider_class_that_does_not_fit_falls_back_to_the_default():
result = _route(_CHANNEL)
assert result.unrouted == []
assert [net for net, _a, _b in result.narrowed] == ["PWR"]
widths = {w.width for net in result.nets for w in net.wires}
assert widths == {150.0}
+78
View File
@@ -0,0 +1,78 @@
"""--orders: route several connection orders and keep the best."""
from __future__ import annotations
from pathlib import Path
from freeroute.cli import main
from freeroute.route.orders import (
best_of_orders,
candidate_seeds,
promote_failures,
route_once,
score,
)
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
# Greedy (no rip-up), the default order strands a net here and seeds 1 to 3 do not.
RIPUP = (FIXTURES / "ripup_needed.dsn").read_text()
GREEDY = {"rip_up": False}
def test_the_default_order_is_always_a_candidate_without_a_seed():
assert candidate_seeds(1) == [None]
assert candidate_seeds(4) == [None, 0, 1, 2]
assert candidate_seeds(3, seed=7) == [7, 8, 9]
def test_keeps_the_order_that_leaves_the_fewest_unrouted():
assert score(route_once(RIPUP, "grid", GREEDY, None)) == (1, 0)
result, kept, tried = best_of_orders(RIPUP, "grid", GREEDY, orders=3, jobs=2)
assert kept == 1
assert score(result) == (0, 0)
assert tried == [(None, (1, 0)), (0, (1, 0)), (1, (0, 0))]
def test_ties_go_to_the_earlier_candidate():
# every order routes this board fully, so the default order is kept
_, kept, tried = best_of_orders(RIPUP, "grid", {}, orders=3, jobs=3)
assert kept is None
assert all(s == (0, 0) for _, s in tried)
def test_cli_orders_writes_the_best_session(tmp_path, capsys):
out = tmp_path / "best.ses"
rc = main([str(FIXTURES / "ripup_needed.dsn"), "--no-rip-up", "--orders", "3", "-do", str(out)])
assert rc == 0
err = capsys.readouterr().err
assert "tried 3 orders, kept --seed 1" in err
assert "not routed" not in err
assert out.read_text().startswith("(session")
def test_promoting_the_failed_connection_routes_it():
best, rounds = promote_failures(RIPUP, "grid", GREEDY, rounds=3)
assert rounds == [(1, 0), (0, 0)] # stops once nothing fails
assert score(best) == (0, 0)
def test_nothing_promoted_is_the_plain_order():
plain = route_once(RIPUP, "grid", {}, None)
empty = route_once(RIPUP, "grid", {"route_first": frozenset()}, None)
assert plain == empty
def test_cli_promote_failures(tmp_path, capsys):
out = tmp_path / "p.ses"
rc = main(
[
str(FIXTURES / "ripup_needed.dsn"),
"--no-rip-up",
"--promote-failures",
"3",
"-do",
str(out),
]
)
assert rc == 0
assert "2 rounds, unrouted 1, 0" in capsys.readouterr().err
+93 -5
View File
@@ -51,19 +51,22 @@ def test_routes_keep_edge_clearance_at_a_rounded_corner():
assert outline.edge_distance(p) >= half + 500 * scale - 1
@pytest.mark.parametrize(("resolution", "escape"), [(1, 2), (4, 5)])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution, escape):
@pytest.mark.parametrize("resolution", [1, 4])
def test_every_pin_of_a_half_millimetre_pitch_row_escapes(resolution):
# Five 0.3 mm pads at 0.5 mm pitch, each wired to its own pad 8 mm away.
# Between two neighbours a trace's centre has a legal band only 0.15 mm
# wide. At resolution 1 the 0.4 mm grid misses most bands; at 4 its
# 0.1 mm columns cannot, and every pin gets out, as the JAR manages.
# wide. At resolution 4 the 0.1 mm columns cannot miss it. At resolution
# 1 the 0.4 mm grid misses most bands, but a stub leaves its pin straight
# along the band and jogs to the column only past the pads, so every pin
# gets out there too, as the JAR manages (only 2 did before).
places = " ".join(f"(place R{i} {6000 + 500 * i} -6000 front 0)" for i in range(5))
places += " " + " ".join(f"(place F{i} {6000 + 500 * i} -14000 front 0)" for i in range(5))
nets = " ".join(f"(net N{i} (pins R{i}-1 F{i}-1))" for i in range(5))
result, _, _ = route_dsn_board_exact(
parse_dsn(_dsn(f"(component P {places})", nets)), resolution=resolution
)
assert len(result.routed_net_numbers) == escape
assert len(result.routed_net_numbers) == 5
assert result.drc_clean
def test_resolution_one_has_no_halo_so_the_grid_is_unchanged():
@@ -164,3 +167,88 @@ def test_cli_names_the_connections_it_could_not_route(tmp_path, capsys):
assert main([str(dsn), "--engine", "exact", "-do", str(tmp_path / "out.ses")]) == 0
err = capsys.readouterr().err
assert "1 connection(s) not routed" in err and "N: A-1 to B-1" in err
# --- inner plane layers (a 4-layer board with a GND plane on In1.Cu) --------
def _four_layer_dsn():
return """(pcb four (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power))
(layer In2.Cu (type power)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -30000 0 -30000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))
(via "Via[0-3]_600:300_um") (rule (width 250) (clearance 150)))
(placement
(component S (place G1 5000 -5000 front 0) (place G2 25000 -5000 front 0)
(place G3 15000 -25000 front 0) (place A 5000 -15000 front 0) (place B 25000 -15000 front 0))
(component T (place GT 15000 -15000 front 0)))
(library
(image S (pin smd 1 0 0))
(image T (pin tht 1 0 0))
(padstack smd (shape (rect F.Cu -300 -300 300 300)) (attach off))
(padstack tht (shape (circle F.Cu 1000)) (shape (circle B.Cu 1000)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 GT-1)) (net SIG (pins A-1 B-1))))"""
def test_smd_pads_reach_an_inner_plane_by_their_own_via():
dsn = parse_dsn(_four_layer_dsn())
result, scale, names = route_dsn_board_exact(dsn)
gnd = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "GND")
sig = next(i + 1 for i, n in enumerate(dsn.nets) if n.name == "SIG")
assert len(result.result.vias.get(gnd, [])) == 3 # one per SMD pad, none for the THT pad
for _layer, pts in result.result.wires.get(gnd, []): # stubs, not tracks across the board
length = sum(abs(q.x - p.x) + abs(q.y - p.y) for p, q in zip(pts, pts[1:], strict=False))
assert length < 3000 * scale # under 3 mm: a stub to its via
assert sig in result.result.routed_net_numbers # signals still route on the outer layers
assert all(names[layer] in ("F.Cu", "B.Cu") for segs in result.result.wires.values() for layer, _ in segs)
def test_a_pad_outside_the_plane_routes_to_it():
# G1 sits left of a plane that covers only the right half, so its route
# has to reach the plane's cells: those ends are the plane, not a pin
dsn_text = _four_layer_dsn().replace(
"(plane GND (polygon In1.Cu 0 500 -500 29500 -500 29500 -29500 500 -29500 500 -500))",
"(plane GND (polygon In1.Cu 0 15000 -500 29500 -500 29500 -29500 15000 -29500 15000 -500))",
)
result, _scale, _names = route_dsn_board_exact(parse_dsn(dsn_text))
gnd = next(i + 1 for i, n in enumerate(parse_dsn(dsn_text).nets) if n.name == "GND")
assert gnd in result.result.routed_net_numbers
def test_a_through_hole_pad_inside_a_plane_needs_no_route():
from freeroute.route.grid_router import _connected_groups
board = build_board(parse_dsn(_four_layer_dsn()))
plane = [z for z in board.get_conduction_areas() if z.name == "GND"]
gnd_pins = [p for p in board.get_pins() if p.component_ref in ("G1", "G2", "G3", "GT")]
groups = _connected_groups(gnd_pins, plane, frozenset(id(z) for z in plane))
hub = next(g for g in groups if g[1])
assert [p.component_ref for p in hub[0]] == ["GT"]
def test_a_pad_inside_a_via_keepout_reaches_the_plane_by_a_via_outside_it():
# a sensor's GND pad: vias are forbidden under the body, so the route
# has to leave on the top layer and drop to the plane past the keepout
dsn = parse_dsn("""(pcb repro (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
(via_keepout "" (polygon signal 0 8500 -8500 11500 -8500 11500 -11500 8500 -11500 8500 -8500))
(via "Via[0-3]_600:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 10000 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -175 -300 175 300)) (attach off))
(padstack "Via[0-3]_600:300_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600)) (attach off)))
(network (net GND (pins G1-1))))""")
exact, scale, _names = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
(via,) = exact.result.vias[1]
half = 300 * scale # the via's radius
outside = (via.x + half <= 8500 * scale or via.x - half >= 11500 * scale
or via.y - half >= -8500 * scale or via.y + half <= -11500 * scale)
assert outside, f"via at {via} is inside the via keepout"
+43
View File
@@ -0,0 +1,43 @@
"""--rollback: a rip-up that loses connections is undone, leaving no trace."""
from __future__ import annotations
from pathlib import Path
import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn, route_dsn_board
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
def _routed_router(name):
dsn = parse_dsn((FIXTURES / name).read_text())
s = dsn.resolution.value
router = GridRouter(
build_board(dsn),
trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s),
rollback=True,
)
return router, router.route()
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_occupancy_only_holds_routed_connections(name):
router, _ = _routed_router(name)
conns = router._conns
assert all(conns[i].routed for i in router._cell_owner.values())
assert all(conns[i].routed for i in router._via_owner.values())
assert all(conns[i].routed for claims in router._halo.values() for i in claims)
@pytest.mark.parametrize("name", ["ripup_needed.dsn", "true_density.dsn", "kicad_routable.dsn"])
def test_rollback_never_routes_fewer_than_plain_rip_up(name):
text = (FIXTURES / name).read_text()
plain, _, _ = route_dsn_board(parse_dsn(text), rollback=False)
undo, _, _ = route_dsn_board(parse_dsn(text))
assert undo.unrouted <= plain.unrouted
+13
View File
@@ -264,6 +264,19 @@ def test_larger_board_still_connects_its_multipin_nets():
assert len(result.routed_net_numbers) == multi_pin == 4
def test_a_seed_is_repeatable_and_does_change_the_order():
# The benchmark samples seeds to measure how much a result owes to the
# routing order. A seed that changed nothing would make every sample the
# same run and report no spread at all.
text = (FIXTURES / "kicad_routable.dsn").read_text()
default = _wire_key(route_dsn_board(parse_dsn(text))[0])
runs = {s: route_dsn_board(parse_dsn(text), seed=s)[0] for s in range(4)}
again = route_dsn_board(parse_dsn(text), seed=1)[0]
assert _wire_key(again) == _wire_key(runs[1])
assert any(_wire_key(r) != default for r in runs.values())
assert all(r.unrouted == 0 for r in runs.values())
# --- oracle parity ----------------------------------------------------------
+58
View File
@@ -0,0 +1,58 @@
"""Pins of a 1.27 mm pitch row routed at 1.0 mm (the RN2483 module on the
TTN Enschede mote). A trace centre has to stay within 0.06 mm of a pin's
centreline to pass its neighbours, which no grid column hits, so a route
leaves each pin straight along that line and only jogs to its column once
past the pads. FreeRouting routes this row in full the same way."""
from __future__ import annotations
import pytest
from freeroute.board import build_board
from freeroute.board.items import Pin
from freeroute.dsn import parse_dsn
from freeroute.geometry import IntPoint
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import route_dsn_board_exact
def _row(x0: int, pins: int = 6, skew: int = 3000) -> str:
places = " ".join(f"(place R{i} {x0 + 1270 * i} -5000 front 0)" for i in range(pins))
places += " " + " ".join(
f"(place T{i} {x0 + 1270 * i + skew} -15000 front 0)" for i in range(pins)
)
nets = " ".join(f"(net N{i} (pins R{i}-1 T{i}-1))" for i in range(pins))
return f"""(pcb t (parser) (resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 30000 0 30000 -20000 0 -20000 0 0))
(via "Via[0-1]_600:400_um") (rule (width 1000) (clearance 200)))
(placement (component P {places}))
(library (image P (pin pad 1 0 0))
(padstack pad (shape (rect F.Cu -508 -762 508 762)) (attach off))
(padstack "Via[0-1]_600:400_um" (shape (circle F.Cu 600)) (shape (circle B.Cu 600))
(attach off)))
(network {nets}))"""
@pytest.mark.parametrize("x0", [5000, 5150, 5300])
def test_every_pin_of_a_row_leaves_straight_and_routes(x0):
# three offsets of the row against the grid; before, 2 or 3 of 6 routed
result, _, _ = route_dsn_board_exact(parse_dsn(_row(x0)), resolution=2)
assert len(result.routed_net_numbers) == 6
assert result.drc_clean
def test_a_walled_in_pin_may_escape_to_the_column_either_side():
# two pins side by side leaving the same way can then jog apart
board = build_board(parse_dsn(_row(5000)))
router = GridRouter(board, trace_width=10000, clearance=2000, orthogonal=True, resolution=2)
router._conns = router._build_connections()
pin = next(
p for p in board.get_items()
if isinstance(p, Pin) and p.location == IntPoint(50000 + 2 * 12700, -50000)
)
net = pin.net_nos[0]
cx, cy = router._cell_of(pin.location)
nodes = router._escape_nodes(pin, net, router._conns, [0], (cx, cy))
columns = {x for x, y, _ in nodes if y != cy}
assert {cx - 1, cx, cx + 1} <= columns
+136
View File
@@ -0,0 +1,136 @@
"""Vias keep hole-to-hole spacing, even within one net."""
from __future__ import annotations
import itertools
import math
from freeroute.dsn import parse_dsn
from freeroute.route.pipeline import route_dsn_board_exact
# four GND pads 0.5 mm apart over a GND plane: each drops a via to the plane
_DSN = """(pcb holes (parser) (resolution um 10) (unit um)
(structure
(layer F.Cu (type signal)) (layer In1.Cu (type power)) (layer In2.Cu (type power))
(layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(plane GND (polygon In1.Cu 0 500 -500 19500 -500 19500 -19500 500 -19500 500 -500))
(via "Via[0-3]_500:300_um") (rule (width 150) (clearance 150)))
(placement (component S (place G1 9000 -10000 front 0) (place G2 9500 -10000 front 0)
(place G3 10000 -10000 front 0) (place G4 10500 -10000 front 0)))
(library
(image S (pin smd 1 0 0))
(padstack smd (shape (rect F.Cu -150 -300 150 300)) (attach off))
(padstack "Via[0-3]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off)))
(network (net GND (pins G1-1 G2-1 G3-1 G4-1))))"""
def test_a_nets_own_vias_keep_hole_to_hole_spacing():
dsn = parse_dsn(_DSN)
exact, scale, _ = route_dsn_board_exact(dsn, resolution=2)
assert exact.result.failed == []
vias = exact.result.vias[1]
assert len(set(vias)) == len(vias), "a via written twice"
need = (300 + 250) * scale # drill + 0.25 mm, centre to centre
for a, b in itertools.combinations(vias, 2):
assert math.hypot(a.x - b.x, a.y - b.y) >= need, f"vias at {a} and {b} too close"
def test_connections_sharing_a_plane_via_write_it_once():
# eight GND pads 0.4 mm apart: the hole spacing makes several of them
# end on the same plane via, which must come out once, not once each
pads = " ".join(f"(place G{i} {9000 + 400 * i} -10000 front 0)" for i in range(8))
pins = " ".join(f"G{i}-1" for i in range(8))
dsn = parse_dsn(
_DSN.replace(
"(place G1 9000 -10000 front 0) (place G2 9500 -10000 front 0)\n"
" (place G3 10000 -10000 front 0) (place G4 10500 -10000 front 0)",
pads,
)
.replace("(pins G1-1 G2-1 G3-1 G4-1)", f"(pins {pins})")
.replace("(rect F.Cu -150 -300 150 300)", "(rect F.Cu -125 -300 125 300)")
)
exact, _scale, _ = route_dsn_board_exact(dsn, resolution=2)
vias = exact.result.vias[1]
assert len(vias) < 8, "no via is shared, so this board no longer tests sharing"
assert len(set(vias)) == len(vias), "a shared via written more than once"
def _router(dsn_text, via_drill_um=300):
from freeroute.board import build_board
from freeroute.route.grid_router import GridRouter
from freeroute.route.pipeline import _rule_clearance_dsn, _rule_width_dsn
dsn = parse_dsn(dsn_text)
s = dsn.resolution.value
return GridRouter(
build_board(dsn), trace_width=round(_rule_width_dsn(dsn) * s),
clearance=round(_rule_clearance_dsn(dsn) * s), resolution=2, orthogonal=True,
via_drill=via_drill_um * s, hole_to_hole=250 * s,
), s
_THT = """(pcb tht (parser) (resolution um 10) (unit um)
(structure (layer F.Cu (type signal)) (layer B.Cu (type signal))
(boundary (path pcb 0 0 0 20000 0 20000 -20000 0 -20000 0 0))
(via "Via[0-1]_500:300_um") (rule (width 150) (clearance 150)))
(placement (component T (place J1 10000 -10000 front 0)))
(library (image T (pin tht 1 0 0))
(padstack tht (shape (circle F.Cu 1700)) (shape (circle B.Cu 1700)) (attach off))
(padstack "Via[0-1]_500:300_um" (shape (circle F.Cu 500)) (shape (circle B.Cu 500)) (attach off)))
(network (net SIG (pins J1-1))))"""
def test_no_via_goes_near_a_through_hole_pads_hole_even_of_its_own_net():
router, s = _router(_THT)
pin = router._cell_of(router.board.get_pins()[0].location)
assert not router._holes_clear(pin, 1) # own net, right on the pad
far = (pin[0] + 40, pin[1])
assert router._holes_clear(far, 1)
def test_a_via_in_a_pad_keeps_hole_spacing_from_a_fixed_via():
from freeroute.geometry import IntPoint
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)))\n"
" (wiring (via \"Via[0-1]_500:300_um\" 3000 -3000 (net SIG) (type route))))")
router, s = _router(text)
assert not router._hole_clear_at(IntPoint(3400 * s, -3000 * s)) # 0.4 mm < 0.55 mm
assert router._hole_clear_at(IntPoint(3600 * s, -3000 * s))
def test_hole_spacing_from_a_fixed_via_is_measured_from_where_it_really_is():
# a fixed via off the grid with its own 0.25 mm drill, and the router's
# via a smaller 0.2 mm one (the flight controller's case): a new via needs
# its centre 0.1 + 0.125 + 0.25 = 0.475 mm away, measured exactly, where
# one rule for all vias gave 0.2 + 0.25 = 0.45 from the cell centres
text = _THT.replace("(network (net SIG (pins J1-1))))",
"(network (net SIG (pins J1-1)) (net GND))\n"
" (wiring (via \"Via[0-1]_500:250_um\" 3030 -3030 (net GND) (type route))))")
text = text.replace(
'(attach off)))\n (network',
'(attach off))\n (padstack "Via[0-1]_500:250_um" (shape (circle F.Cu 500)) '
'(shape (circle B.Cu 500)) (attach off)))\n (network',
)
router, s = _router(text, via_drill_um=200)
via = next(v for v in router.board.get_items() if getattr(v, "drill", 0))
assert via.drill == 250 * s
loc = via.location
for cell in [(x, y) for x in range(router.cols) for y in range(router.rows)]:
c = router._cell_center(cell)
d = math.hypot(c.x - loc.x, c.y - loc.y)
if router.step * 1.5 < d < 470 * s:
assert not router._holes_clear(cell, 1), f"{d / s:.0f} um from the fixed via"
elif 560 * s < d < 700 * s and cell not in router._tht_block:
assert router._holes_clear(cell, 1)
def test_a_route_keeps_its_own_vias_apart():
# a route that changed layer at (10, 10) may not change layer again one
# cell away; the same cell is the same through via, and far is fine
router, _s = _router(_THT)
came_from = {(10, 10, 1): (10, 10, 0), (11, 10, 1): (10, 10, 1), (12, 10, 1): (11, 10, 1)}
assert router._near_path_via((12, 10, 1), (11, 10), came_from)
assert not router._near_path_via((12, 10, 1), (10, 10), came_from)
assert not router._near_path_via((12, 10, 1), (40, 10), came_from)
+5
View File
@@ -154,6 +154,11 @@ def test_cli_supported_combinations_are_accepted(tmp_path):
"diagonal": ["--diagonal"],
"rip_up": ["--no-rip-up"],
"max_passes": ["-mp", "4"],
"seed": ["--seed", "3"],
"orders": ["--orders", "2"],
"promote": ["--promote-failures", "2"],
"access": ["--access-cost", "5"],
"rollback": ["--no-rollback"],
}
for engine, options in ENGINE_OPTIONS.items():
for option in options:
Generated
+1 -1
View File
@@ -128,7 +128,7 @@ wheels = [
[[package]]
name = "freeroute"
version = "0.1.0"
version = "2026.7.14"
source = { editable = "." }
[package.dev-dependencies]