app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
Delete reference
26 files changed
−4942
reference/python/__init__.pyreference/python/__pycache__/copper.cpython-312.pycreference/python/__pycache__/escboard.cpython-312.pycreference/python/__pycache__/fixture.cpython-312.pycreference/python/__pycache__/movefp.cpython-312.pycreference/python/__pycache__/pack.cpython-312.pycreference/python/__pycache__/planes.cpython-312.pycreference/python/__pycache__/pours.cpython-312.pycreference/python/__pycache__/router.cpython-312.pycreference/python/analyze.py−146reference/python/capture.py−51reference/python/cli.py−430reference/python/copper.py−481reference/python/escboard.py−532reference/python/fixture.py−688reference/python/land_moves.py−46reference/python/land_pours.py−81reference/python/land_route.py−95reference/python/movefp.py−115reference/python/pack.py−136reference/python/planes.py−121reference/python/pourplan.py−157reference/python/pours.py−167reference/python/pours_esc_example.py−182reference/python/router.py−1420reference/python/runlog.py−94reference/python/__init__.pydeletedreference/python/__pycache__/copper.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/escboard.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/fixture.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/movefp.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/pack.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/planes.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/pours.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/__pycache__/router.cpython-312.pycdeleted⋯ 1 unchanged line ⋯
reference/python/analyze.pydeleted−146@@ -1,146 +0,0 @@-"""Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.--0.1 is deliberately conservative and says so in its hints: it judges the tracks and vias by-IPC-2221's external-layer curve and the pours by presence, connection style and via count, not-by a field solve. A PASS here means "no obvious under-sized conductor and every hot tab has-copper"; a FAIL names the net or the part and what to change. The 0.2 pass (cross-sections-through the filled polygons, IR drop per net) replaces the pour heuristics.-"""-import json-import math-import os-import sys--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board # noqa: E402--MIL2_PER_MM2 = 1550.0031---def ipc2221_amps(width_mm, thickness_um, rise_c, external=True):- """IPC-2221 track current for a temperature rise: I = k * dT^0.44 * A^0.725 (A in mil^2)."""- k = 0.048 if external else 0.024- a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2- return k * (rise_c ** 0.44) * (a_mil2 ** 0.725)---def rise_for(width_mm, thickness_um, amps, external=True):- k = 0.048 if external else 0.024- a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2- if a_mil2 <= 0 or amps <= 0:- return 0.0- return (amps / (k * a_mil2 ** 0.725)) ** (1 / 0.44)---def plan_widths(plan):- """Per net: the widths used, the via count and the number of entries, from a routing plan."""- out = {}- for e in plan.get("nets", []):- d = out.setdefault(e["net"], {"widths": set(), "vias": 0, "viaSize": e.get("viaSize"), "viaDrill": e.get("viaDrill"), "kelvin": 0})- d["widths"].add(float(e["width"]))- if e.get("kelvin"):- d["kelvin"] += 1- for path in e.get("paths", []):- d["vias"] += sum(1 for w in path if isinstance(w, dict) and "layer" in w and "x" in w)- return out---def analyze_current(plan, spec, zone_state=None):- """spec.loads: {net: {"amps": continuous A, "peakAmps"?: A, "maxRiseC"?: C}}; spec.copperUm outer thickness (35)."""- loads = spec.get("loads", {})- t_um = float(spec.get("copperUm", 35))- max_rise_default = float(spec.get("maxRiseC", 10))- widths = plan_widths(plan)- pours = {}- for z in (zone_state or {}).get("zones", []):- if z.get("type") == "copper" and z.get("net"):- pours.setdefault(z["net"], []).append(z)- results = []- ok = True- for net, ld in loads.items():- amps = float(ld.get("amps", 0))- max_rise = float(ld.get("maxRiseC", max_rise_default))- w = widths.get(net)- r = {"net": net, "amps": amps, "maxRiseC": max_rise}- if w is None:- r.update({"status": "no-copper", "hint": "net %s carries %.1f A but the routing plan has no copper for it" % (net, amps)})- ok = False- results.append(r)- continue- signal_widths = sorted(x for x in w["widths"] if x > 0.2)- narrowest = min(signal_widths) if signal_widths else min(w["widths"])- widest = max(w["widths"])- rise_narrow = rise_for(narrowest, t_um, amps)- rise_wide = rise_for(widest, t_um, amps)- pour_layers = sorted({l for z in pours.get(net, []) for l in z.get("layers", [])})- pour_area = round(sum(f.get("areaMm2", 0) for z in pours.get(net, []) for f in z.get("fills", [])), 1)- vias = w["vias"]- via_amps = 1.5 if (w.get("viaDrill") or 0.3) >= 0.4 else 1.0- via_capacity = vias * via_amps- r.update({"narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": round(rise_narrow, 1), "riseAtWidestC": round(rise_wide, 1),- "vias": vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area})- # judgement: a track alone must not be the only path unless it is within the rise; a pour on both outer- # layers carries the load if it exists and the vias between layers can carry it- if rise_wide <= max_rise:- r["status"] = "pass"; r["how"] = "the widest track carries the load within %.0f C" % max_rise- elif pour_layers and pour_area > 0:- if len(pour_layers) >= 2 and via_capacity < amps:- r["status"] = "fail"; r["hint"] = "%s is carried by pours on %s but only %d vias (%.0f A) join the layers for %.1f A: add stitching vias (0.8/0.4 mm, about 1.5 A each)" % (net, "+".join(pour_layers), vias, via_capacity, amps)- ok = False- else:- r["status"] = "pass"; r["how"] = "pour on %s (%.0f mm2 filled) carries the load; tracks are %.2f mm at most" % ("+".join(pour_layers), pour_area, widest)- else:- r["status"] = "fail"- need = None- for wmm in (0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0):- if rise_for(wmm, t_um, amps) <= max_rise:- need = wmm; break- r["hint"] = "%s carries %.1f A on a %.2f mm track (%.0f C rise, limit %.0f C) and has no pour: pour it on the outer layers or widen to %s mm" % (net, amps, widest, rise_wide, max_rise, need or ">6")- ok = False- results.append(r)- return ok, results---def analyze_thermal(plan, spec, zone_state=None, board=None):- """spec.hot: {ref: {"watts": W, "tabNet": net, "tabPad"?: name, "maxThetaCPerW"?: C/W}}. 0.1: copper presence and vias."""- hot = spec.get("hot", {})- widths = plan_widths(plan)- pours = {}- for z in (zone_state or {}).get("zones", []):- if z.get("type") == "copper" and z.get("net"):- pours.setdefault(z["net"], []).append(z)- results = []- ok = True- for ref, h in hot.items():- net = h.get("tabNet")- watts = float(h.get("watts", 0))- r = {"ref": ref, "watts": watts, "tabNet": net}- zs = pours.get(net, [])- area = round(sum(f.get("areaMm2", 0) for z in zs for f in z.get("fills", [])), 1)- layers = sorted({l for z in zs for l in z.get("layers", [])})- solid = any("solid" in (z.get("name") or "").lower() or z.get("connection") == "solid" for z in zs)- vias = widths.get(net, {}).get("vias", 0)- # one square inch of 35 um copper is roughly 40 C/W in still air; scale by area, floor at the package's own theta- theta = 40.0 / max(area / 645.16, 0.05)- limit = float(h.get("maxThetaCPerW", spec.get("maxThetaCPerW", 60)))- r.update({"pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": round(theta, 1), "limitCPerW": limit, "riseEstimateC": round(theta * watts, 1)})- if not zs:- r["status"] = "fail"; r["hint"] = "%s dissipates %.1f W into %s but %s has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab" % (ref, watts, net, net)- ok = False- elif theta > limit:- r["status"] = "fail"; r["hint"] = "%s: %.0f mm2 of copper on %s gives about %.0f C/W (limit %.0f): widen the pour or add the other outer layer with vias" % (ref, area, net, theta, limit)- ok = False- elif vias == 0 and len(layers) >= 2:- r["status"] = "fail"; r["hint"] = "%s: the %s pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab" % (ref, net)- ok = False- else:- r["status"] = "pass"; r["how"] = "%.0f mm2 of %s copper on %s, about %.0f C/W, %d vias" % (area, net, "+".join(layers), theta, vias)- results.append(r)- return ok, results---if __name__ == "__main__":- plan = json.load(open(sys.argv[1])); spec = json.load(open(sys.argv[2]))- zs = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None- ok, res = analyze_current(plan, spec, zs)- print(json.dumps({"pass": ok, "results": res}, indent=1))
reference/python/capture.pydeleted−51@@ -1,51 +0,0 @@-"""The capture recipe: the same picture for every engine, with stage markers.--open: the board on the desktop's KiCad through the bridge, foreground OpenGL on a test box we own- (the etiquette pushes a new window back once, so the restore is repeated), maximised, zoom to fit.-start: ab's native monitor recorder; stop: stops it and pulls the MP4 into the run directory.-mark: writes a labelled timestamp into the run manifest; the split-screen cut aligns engines on them.-"""-import json-import subprocess-import time---def bridge(ai_thread, target, verb, args, timeout=300):- cmd = ["adom-bridge", "--ai-thread", ai_thread, "--target", target, verb, json.dumps(args)]- out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout- try:- return json.loads(out)- except Exception:- return {"status": "parse_error", "raw": out[:400]}---def open_board(ai_thread, target, remote_board, reason):- r = bridge(ai_thread, target, "kicad_open_board", {"filePath": remote_board, "foreground": True, "foregroundReason": reason})- hwnd = r.get("hwnd")- if not hwnd:- return None, r- bridge(ai_thread, target, "desktop_ui_window", {"hwnd": hwnd, "action": "maximize", "reason": "boardflow capture: fill the monitor"})- time.sleep(40)- bridge(ai_thread, target, "desktop_bring_to_front", {"hwnd": hwnd, "reason": "boardflow capture: keep the PCB editor on screen on the test box"})- time.sleep(2)- bridge(ai_thread, target, "kicad_send_key", {"hwnd": hwnd, "key": "ctrl+home"})- time.sleep(3)- return hwnd, r---def record_start(ai_thread, target, reason, max_ms=1_800_000):- r = bridge(ai_thread, target, "desktop_record_start", {"monitor": 0, "audio": False, "fps": 30, "reason": reason, "maxDurationMs": max_ms})- return r.get("recordingId") or r.get("id"), r---def record_stop(ai_thread, target, recording_id, save_to):- r = bridge(ai_thread, target, "desktop_record_stop", {"recordingId": recording_id})- p = r.get("filePath") or r.get("path")- if not p and r.get("output"):- try:- p = json.loads(r["output"]).get("data", {}).get("path")- except Exception:- p = None- p = (p or "").replace("\\", "/")- pulled = bridge(ai_thread, target, "pull_file", {"filePaths": [p], "saveTo": save_to}, timeout=900) if p else {}- return p, r, pulled
reference/python/cli.pydeleted−430@@ -1,430 +0,0 @@-#!/usr/bin/env python3-"""adom-boardflow: the board loop as one command, with the clock, the negotiation and the finish line.--Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and this run.-See SKILL.md for the flow. Every state-changing command needs --ai-thread.-"""-import argparse-import json-import os-import subprocess-import sys-import time-from pathlib import Path--HERE = Path(__file__).resolve().parent-sys.path.insert(0, str(HERE))-from runlog import Run, now, STAGES # noqa: E402--VERSION = "0.1.0"---def ok(msg, *hints):- print("OK: " + msg)- for h in hints:- if h:- print("Hint: " + h)---def err(msg, *hints, code=2):- print("ERROR: " + msg)- for h in hints:- if h:- print("Hint: " + h)- sys.exit(code)---def sh(args, cwd=None, timeout=7200):- r = subprocess.run([sys.executable] + args if args and args[0].endswith(".py") else args, cwd=cwd, capture_output=True, text=True, timeout=timeout)- return r.returncode, r.stdout, r.stderr---def need_thread(a):- if not a.ai_thread:- err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", "Re-run with --ai-thread \"<your thread name>\".", code=3)---def load_run(a, must=True):- r = Run(a.run)- if must and not r.exists():- err("no run at %s" % a.run, "Start one: adom-boardflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>")- return r---def spec_of(r):- return json.load(open(r.data["spec"], encoding="utf-8"))---# ----------------------------------------------------------------------------------------------def cmd_start(a):- need_thread(a)- if not Path(a.board).is_file():- err("board not found: %s" % a.board)- if not Path(a.spec).is_file():- err("spec not found: %s" % a.spec, "docs/spec-example.json on the adom-boardflow page is the shape.")- r = Run(a.run)- if r.exists() and not a.force:- err("a run already exists at %s (engine %s, prompt time %s)" % (a.run, r.data["engine"], r.data["clock"]["promptTime"]), "Pick another --run directory, or --force to start over.")- Path(a.run).mkdir(parents=True, exist_ok=True)- board_copy = Path(a.run) / "board-0.kicad_pcb"- board_copy.write_bytes(Path(a.board).read_bytes())- prompt_time = a.prompt_time or now()- r = Run.create(a.run, board_copy, str(Path(a.spec).resolve()), a.engine, prompt_time, a.ai_thread, a.target, a.remote_board)- ok("run started for engine %s at %s; the clock started at the prompt, %s" % (a.engine, a.run, prompt_time),- "Next: adom-boardflow plan --run %s (the stages this board needs and who can take each)." % a.run,- "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.")---def cmd_plan(a):- r = load_run(a)- spec = spec_of(r)- sys.path.insert(0, str(HERE))- from escboard import Board- b = Board(r.current_board())- parked = 0- x0, y0, x1, y1 = b.bbox- for ref, fp in b.footprints.items():- if fp["x"] > x1:- parked += 1- fixed = len(spec.get("fixedRefs", []))- stages = {- "place": {"engines": ["ai"], "default": "ai", "why": "%d parts parked outside the outline, %d fixed by the interface; the AI places (kicad_placement_state, adom-boardflow place pack/check/land help), the binary checks" % (parked, fixed)},- "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says --take route=ai"},- "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},- "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},- "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},- "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},- "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},- "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},- "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},- }- r.data["plan"] = {"at": now(), "stages": stages, "board": {"footprints": len(b.footprints), "nets": len(b.nets), "parked": parked, "layers": b.copper_layers}}- r.save()- lines = ["%-16s %-12s %s" % (n, "/".join(s["engines"]), s["why"]) for n, s in stages.items()]- ok("plan for %s: %d footprints, %d nets, %d copper layers, %d parked\n%s" % (r.data["engine"], len(b.footprints), len(b.nets), len(b.copper_layers), parked, "\n".join(lines)),- "Take a stage yourself with: adom-boardflow take route=ai --run %s (default: binary where it is offered)." % a.run,- "Every stage stamps the clock; for a stage you take yourself call `adom-boardflow stage start <name>` and `stage end <name>` around your own work.")---def cmd_take(a):- need_thread(a)- r = load_run(a)- for kv in a.decision:- stage, engine = kv.split("=", 1)- if stage not in STAGES:- err("unknown stage %s" % stage, "Stages: " + ", ".join(STAGES))- r.data["decisions"][stage] = engine- r.save()- ok("decisions recorded: %s" % json.dumps(r.data["decisions"]), "They are part of the run manifest and the comparison chart.")---def cmd_stage(a):- need_thread(a)- r = load_run(a)- if a.action == "start":- r.stage_start(a.name, engine=r.engine_for(a.name), note=a.note)- ok("stage %s started at %s by %s" % (a.name, now(), r.engine_for(a.name)))- else:- result = json.loads(a.result) if a.result else None- r.stage_end(a.name, result=result, note=a.note)- ok("stage %s ended at %s" % (a.name, now()), "Minutes so far per stage: %s; %.1f min since the prompt." % (json.dumps(r.stage_minutes()), r.elapsed_minutes()))---# -- place --------------------------------------------------------------------------------------def cmd_place(a):- r = load_run(a)- board = r.current_board()- if a.what == "pack":- need_thread(a)- code, out, e = sh([str(HERE / "pack.py"), board, "--wish", a.wish, "--out", a.out, "--edge", "0.5", "--gap", "0.05"])- if code:- err("pack failed: %s" % (e or out)[-400:])- ok("legal spots found:\n" + out.strip(), "Land them live with `adom-boardflow place land --moves %s` or check offline first with `place check --moves %s`." % (a.out, a.out))- elif a.what == "check":- need_thread(a)- n = len(r.data["boards"])- out_board = str(Path(a.run) / ("board-%d.kicad_pcb" % n))- code, out, e = sh([str(HERE / "movefp.py"), board, "--out", out_board, "--json", a.moves, "--check"])- if code and "check:" not in out:- err("move failed: %s" % (e or out)[-400:])- drc = sh(["service-kicad", "pcb", "drc", "--format", "json", "--out", out_board + ".drc.json", out_board])- try:- v = json.load(open(out_board + ".drc.json")).get("violations", [])- except Exception:- v = []- errs = [x for x in v if x.get("severity") == "error"]- base = int((spec_of(r).get("inheritedErrors") or 0))- types = {}- for x in errs:- types[x["type"]] = types.get(x["type"], 0) + 1- if len(errs) > base:- err("the moved board has %d DRC errors (baseline %d): %s" % (len(errs), base, json.dumps(types)), "Courtyard overlaps or edge violations from the moves; change the wishes and pack again. The board copy stays at %s." % out_board)- r.add_board(out_board)- ok("moves applied offline to %s; DRC %d errors, all inherited (%s)" % (out_board, len(errs), json.dumps(types)), "This is now the run's current board. Land the same moves live with `place land`.")- elif a.what == "land":- need_thread(a)- if not r.data.get("target"):- err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")- r.stage_start("place-land")- code, out, e = sh([str(HERE / "land_moves.py"), "--target", r.data["target"], "--board", r.data["remoteBoard"], "--moves", a.moves, "--thread", a.ai_thread, "--log", str(Path(a.run) / "place-land-log.json")])- r.stage_end("place-land", result={"ok": code == 0})- if code:- err("live placement refused: %s" % out[-600:], "The bridge checks courtyards and the outline exactly; read offendingRefs and pack again.")- ok("placement landed live as one undo step:\n" + out.strip())---# -- route --------------------------------------------------------------------------------------def cmd_route(a):- need_thread(a)- r = load_run(a)- spec = spec_of(r)- board = r.current_board()- if r.engine_for("route") == "ai":- err("route is taken by the AI in this run (take route=ai)", "Write your plan in the plan JSON shape and run `adom-boardflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.")- cfg = Path(a.run) / "router-config.json"- json.dump({"wide": spec.get("wideNets", {}), "mid": spec.get("midNets", {}), "planes": spec.get("planes", {}), "kelvin": spec.get("kelvin", {})}, open(cfg, "w"), indent=1)- plan = str(Path(a.run) / "plan-route.json"); report = str(Path(a.run) / "plan-route-report.json"); log = Path(a.run) / "router.log"- r.stage_start("route", engine="binary", note="passes=%d" % a.passes)- with open(log, "w") as lf:- proc = subprocess.run([sys.executable, str(HERE / "router.py"), board, "--out", plan, "--report", report, "--passes", str(a.passes), "--config", str(cfg)], stdout=lf, stderr=subprocess.STDOUT, text=True)- rep = json.load(open(report)) if Path(report).is_file() else {"unrouted": [("?", ("?", "router did not write a report"))]}- unrouted = rep.get("unrouted", [])- r.stage_end("route", result={"unrouted": len(unrouted), "passes": a.passes, "plan": plan})- r.data["outcomes"]["route"] = {"plan": plan, "unrouted": len(unrouted), "detail": unrouted[:20]}- r.save()- if unrouted:- blockers = sorted({b for _, (_, why) in unrouted for b in why.replace("blocked by ", "").strip("[]").replace("'", "").split(", ") if why.startswith("blocked")})- err("routing closed %d connection(s) short after %d passes: %s" % (len(unrouted), a.passes, "; ".join("%s at %s (%s)" % (n, p, why) for n, (p, why) in unrouted[:6])),- "More passes: --passes %d." % (a.passes + 4),- "Or go back to placement: the blockers are %s; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again." % (blockers[:8] or "listed in the report"),- "Or take the stage yourself: `adom-boardflow take route=ai`.")- ok("routing closed: 0 unrouted after %d passes; plan %s" % (a.passes, plan), "Next: adom-boardflow gate (KiCad DRC offline on the plan).")---# -- pour ---------------------------------------------------------------------------------------def cmd_pour(a):- need_thread(a)- r = load_run(a)- plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")- out = str(Path(a.run) / "plan-pours.json"); vias = str(Path(a.run) / "plan-vias.json")- r.stage_start("pour", engine="binary")- code, o, e = sh([str(HERE / "pourplan.py"), r.current_board(), "--spec", r.data["spec"], "--out", out, "--vias", vias] + (["--plan", plan] if plan else []))- r.stage_end("pour", result={"ok": code == 0, "pours": out, "vias": vias})- if code:- err("pour plan failed: %s" % (e or o)[-400:], "spec.pours entries need net, layers and one of outline, polygon or around.")- r.data["outcomes"]["pour"] = {"pours": out, "vias": vias}- r.save()- ok("pours planned:\n" + o.strip(), "Gate them live: pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `adom-boardflow land pours` does it.")---# -- gate ---------------------------------------------------------------------------------------def cmd_gate(a):- need_thread(a)- r = load_run(a)- plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")- if not plan:- err("no routing plan to gate", "Run `adom-boardflow route` or pass --plan yourplan.json.")- routed = str(Path(a.run) / "board-routed.kicad_pcb"); report = str(Path(a.run) / "gate.json")- r.stage_start("gate", engine="binary")- code, o, e = sh([str(HERE / "copper.py"), r.current_board(), plan, "--out", routed, "--drc", "--baseline", r.current_board(), "--report", report])- g = json.load(open(report)) if Path(report).is_file() else {}- r.stage_end("gate", result={k: g.get(k) for k in ("newErrors", "inheritedErrors", "unconnected")})- r.data["outcomes"]["gate"] = {"report": report, "newErrors": g.get("newErrors"), "unconnected": g.get("unconnected"), "routedBoard": routed}- r.save()- if g.get("newErrors") or g.get("unconnected"):- err("gate failed: %s new DRC errors, %s unconnected (inherited %s)" % (g.get("newErrors"), g.get("unconnected"), g.get("inheritedErrors")),- "New errors: %s" % json.dumps(g.get("newErrorTypes")), "Unconnected: %s" % json.dumps((g.get("unconnectedList") or [])[:6]),- "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.")- ok("gate passed: 0 new DRC errors (%s inherited), 0 unconnected; copper %s" % (g.get("inheritedErrors"), json.dumps(g.get("copper"))), "Next: adom-boardflow land route (then land vias, land pours).")---# -- land ---------------------------------------------------------------------------------------def cmd_land(a):- need_thread(a)- r = load_run(a)- if not r.data.get("target"):- err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")- T, B = r.data["target"], r.data["remoteBoard"]- if a.what == "route":- plan = r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan- if not r.data.get("outcomes", {}).get("gate", {}).get("newErrors") == 0:- err("the gate has not passed for this plan", "Run `adom-boardflow gate` first; the finish line needs it.")- r.stage_start("land-route")- code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", plan, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-route-log.json")])- r.stage_end("land-route", result={"ok": code == 0})- tail = "\n".join(o.strip().splitlines()[-3:])- if code:- err("live routing stopped: %s" % tail, "A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.")- r.data["outcomes"]["land-route"] = tail; r.save()- ok("routing landed live, one undo step per net:\n" + tail)- elif a.what == "vias":- vias = r.data.get("outcomes", {}).get("pour", {}).get("vias")- r.stage_start("land-vias")- code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", vias, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-vias-log.json")])- r.stage_end("land-vias", result={"ok": code == 0})- ok("thermal and stitching vias landed:\n" + "\n".join(o.strip().splitlines()[-2:]))- elif a.what == "pours":- pours = r.data.get("outcomes", {}).get("pour", {}).get("pours")- r.stage_start("land-pours")- code, o, e = sh([str(HERE / "land_pours.py"), "--target", T, "--board", B, "--pours", pours, "--local", r.current_board(), "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-pours-log.json")])- r.stage_end("land-pours", result={"ok": code == 0})- log = Path(a.run) / "land-pours-log.json"- L = json.load(open(log)) if log.is_file() else {}- failed = [c["net"] for c in L.get("calls", []) if c["reply"].get("success") is not True]- r.data["outcomes"]["land-pours"] = {"before": L.get("before"), "after": L.get("after", {}).get("filledAreaByLayerMm2") if isinstance(L.get("after"), dict) else None, "failed": failed}- r.save()- if failed:- first = next(c for c in L["calls"] if c["reply"].get("success") is not True)- v = first["reply"].get("violations") or []- types = {}- for x in v:- types[x.get("type")] = types.get(x.get("type"), 0) + 1- err("pour for %s refused by the preflight: %s" % (failed[0], json.dumps(types)),- "starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.")- ok("pours landed: copper per layer before %s, after %s" % (json.dumps(L.get("before")), json.dumps(r.data["outcomes"]["land-pours"]["after"])), "Next: adom-boardflow measure, then analyze current and analyze thermal, then finish.")---# -- measure / analyze / finish -------------------------------------------------------------------def zone_state(r, ai_thread, refill=True):- from capture import bridge- return bridge(ai_thread, r.data["target"], "kicad_zone_state", {"filePath": r.data["remoteBoard"], "refill": refill})---def cmd_measure(a):- need_thread(a)- r = load_run(a)- if not r.data.get("target"):- err("measure reads the live board", "Start the run with --target and --remote-board.")- r.stage_start("measure")- zs = zone_state(r, a.ai_thread)- r.stage_end("measure", result={"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2")})- json.dump(zs, open(Path(a.run) / "zone-state.json", "w"), indent=1)- r.data["outcomes"]["measure"] = {"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2"), "coveragePercent": zs.get("filledCoverageByLayerPercent"), "zones": zs.get("zoneCount")}- r.save()- ok("copper kept per layer (KiCad's fills): %s; coverage %s" % (json.dumps(zs.get("filledAreaByLayerMm2")), json.dumps(zs.get("filledCoverageByLayerPercent"))), "Next: adom-boardflow analyze current, analyze thermal.")---def cmd_analyze(a):- need_thread(a)- r = load_run(a)- from analyze import analyze_current, analyze_thermal- plan = json.load(open(r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan))- spec = spec_of(r)- zs_path = Path(a.run) / "zone-state.json"- zs = json.load(open(zs_path)) if zs_path.is_file() else None- name = "analyze-" + a.what- r.stage_start(name)- passed, res = (analyze_current if a.what == "current" else analyze_thermal)(plan, spec, zs)- r.stage_end(name, result={"pass": passed})- r.data["outcomes"][name] = {"pass": passed, "results": res}- r.save()- lines = ["%s %s: %s" % (x.get("net") or x.get("ref"), x["status"], x.get("how") or x.get("hint")) for x in res]- if not passed:- err("%s analysis failed:\n%s" % (a.what, "\n".join(lines)), "Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.")- ok("%s analysis passed:\n%s" % (a.what, "\n".join(lines)), "0.1 judges tracks by IPC-2221 and pours by presence and vias; the 0.2 pass computes cross-sections through the filled copper.")---def cmd_finish(a):- need_thread(a)- r = load_run(a)- o = r.data.get("outcomes", {})- missing = []- if (o.get("gate") or {}).get("newErrors") != 0 or (o.get("gate") or {}).get("unconnected") != 0:- missing.append("gate: 0 new DRC errors and 0 unconnected (run gate)")- if "land-route" not in o:- missing.append("land route")- if not o.get("land-pours") or o["land-pours"].get("failed"):- missing.append("land pours (every planned pour accepted)")- if not o.get("measure"):- missing.append("measure")- for k in ("analyze-current", "analyze-thermal"):- if not (o.get(k) or {}).get("pass"):- missing.append(k + " (pass)")- live = None- if r.data.get("target"):- from capture import bridge- live = bridge(a.ai_thread, r.data["target"], "kicad_routing_validate", {"filePath": r.data["remoteBoard"]})- inherited = int(spec_of(r).get("inheritedErrors") or 0)- if live.get("unconnected") != 0 or (live.get("errors") or 0) > inherited:- missing.append("live validate: %s unconnected, %s errors (inherited %d)" % (live.get("unconnected"), live.get("errors"), inherited))- if missing:- err("not finished: " + "; ".join(missing), "A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.")- r.data["clock"]["finishTime"] = now()- r.data["summary"] = {"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "stageMinutes": r.stage_minutes(), "decisions": r.data["decisions"],- "copper": o.get("measure"), "gate": o.get("gate"), "live": {k: live.get(k) for k in ("errors", "unconnected", "warnings")} if live else None,- "current": (o.get("analyze-current") or {}).get("pass"), "thermal": (o.get("analyze-thermal") or {}).get("pass")}- r.save()- ok("finished: %s from the prompt to a qualified board, %.1f min; stages %s" % (r.data["engine"], r.elapsed_minutes(), json.dumps(r.stage_minutes())),- "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.",- "Cut the comparison with gang-takes split mode from the captures and markers in run.json.")---# -- capture ------------------------------------------------------------------------------------def cmd_capture(a):- need_thread(a)- r = load_run(a)- from capture import open_board, record_start, record_stop- if not r.data.get("target"):- err("capture needs a desktop target", "Start the run with --target and --remote-board.")- if a.action == "open":- hwnd, rep = open_board(a.ai_thread, r.data["target"], r.data["remoteBoard"], a.reason or "boardflow run %s on the test box" % r.data["engine"])- if not hwnd:- err("could not open the board: %s" % json.dumps(rep)[:300])- r.data["captures"].append({"opened": now(), "hwnd": hwnd}); r.save()- ok("board open, maximised, zoomed to fit (hwnd %s)" % hwnd, "Next: adom-boardflow capture start.")- elif a.action == "start":- rid, rep = record_start(a.ai_thread, r.data["target"], a.reason or "boardflow run %s" % r.data["engine"])- if not rid:- err("recorder did not start: %s" % json.dumps(rep)[:300])- r.data["captures"].append({"recordingId": rid, "start": now()}); r.mark("capture-start"); r.save()- ok("recording %s started at %s" % (rid, now()), "Every stage command writes markers; `capture mark <label>` adds your own.")- elif a.action == "mark":- r.mark(a.label); r.save()- ok("marker %s at %s" % (a.label, now()))- elif a.action == "stop":- cap = next((c for c in reversed(r.data["captures"]) if c.get("recordingId") and not c.get("file")), None)- if not cap:- err("no running recording in this run")- p, rep, pulled = record_stop(a.ai_thread, r.data["target"], cap["recordingId"], str(Path(a.run).resolve()))- cap["stop"] = now(); cap["remoteFile"] = p; cap["file"] = str(Path(a.run) / Path(p).name) if p else None- r.mark("capture-stop"); r.save()- ok("recording stopped; %s pulled to %s" % (p, cap["file"]), "Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.")---def cmd_status(a):- r = load_run(a)- d = r.data- o = d.get("outcomes", {})- ok("run %s, engine %s, %.1f min since the prompt (%s)\nstages (min): %s\ndecisions: %s\nroute: %s unrouted; gate: %s new / %s unconnected; pours: %s; measure: %s; current: %s; thermal: %s; finished: %s" % (- a.run, d["engine"], r.elapsed_minutes(), d["clock"]["promptTime"], json.dumps(r.stage_minutes()), json.dumps(d["decisions"]),- (o.get("route") or {}).get("unrouted"), (o.get("gate") or {}).get("newErrors"), (o.get("gate") or {}).get("unconnected"),- "landed" if o.get("land-pours") and not o["land-pours"].get("failed") else "no", json.dumps((o.get("measure") or {}).get("filledAreaByLayerMm2")),- (o.get("analyze-current") or {}).get("pass"), (o.get("analyze-thermal") or {}).get("pass"), d["clock"]["finishTime"]))---def main(argv=None):- ap = argparse.ArgumentParser(prog="adom-boardflow", description=__doc__.split("\n\n")[0])- ap.add_argument("--ai-thread", help="the name of the AI conversation calling (required for state-changing commands)")- ap.add_argument("--run", default=os.environ.get("BOARDFLOW_RUN", "./boardflow-run"), help="run directory (default ./boardflow-run or $BOARDFLOW_RUN)")- ap.add_argument("--version", action="version", version="adom-boardflow " + VERSION)- sub = ap.add_subparsers(dest="cmd", required=True)- s = sub.add_parser("start", help="start a run: copies the board, stamps the prompt time"); s.add_argument("--board", required=True); s.add_argument("--spec", required=True); s.add_argument("--engine", required=True); s.add_argument("--prompt-time"); s.add_argument("--target"); s.add_argument("--remote-board"); s.add_argument("--force", action="store_true"); s.set_defaults(f=cmd_start)- s = sub.add_parser("plan", help="the stages this board needs and who can take each"); s.set_defaults(f=cmd_plan)- s = sub.add_parser("take", help="record who takes a stage: route=ai pour=binary"); s.add_argument("decision", nargs="+"); s.set_defaults(f=cmd_take)- s = sub.add_parser("stage", help="stamp a stage you do yourself"); s.add_argument("action", choices=["start", "end"]); s.add_argument("name"); s.add_argument("--note"); s.add_argument("--result"); s.set_defaults(f=cmd_stage)- s = sub.add_parser("place", help="placement helpers: pack, check, land"); s.add_argument("what", choices=["pack", "check", "land"]); s.add_argument("--wish"); s.add_argument("--out", default="moves.json"); s.add_argument("--moves"); s.set_defaults(f=cmd_place)- s = sub.add_parser("route", help="the grid router on the current board"); s.add_argument("--passes", type=int, default=5); s.set_defaults(f=cmd_route)- s = sub.add_parser("pour", help="pours and vias from the spec"); s.add_argument("--plan"); s.set_defaults(f=cmd_pour)- s = sub.add_parser("gate", help="KiCad DRC offline on the routing plan"); s.add_argument("--plan"); s.set_defaults(f=cmd_gate)- s = sub.add_parser("land", help="replay live through the bridge"); s.add_argument("what", choices=["route", "vias", "pours"]); s.add_argument("--plan"); s.set_defaults(f=cmd_land)- s = sub.add_parser("measure", help="filled copper per layer from the live board"); s.set_defaults(f=cmd_measure)- s = sub.add_parser("analyze", help="current or thermal"); s.add_argument("what", choices=["current", "thermal"]); s.add_argument("--plan"); s.set_defaults(f=cmd_analyze)- s = sub.add_parser("finish", help="the finish line; refuses until every check passes"); s.set_defaults(f=cmd_finish)- s = sub.add_parser("capture", help="open, start, mark, stop"); s.add_argument("action", choices=["open", "start", "mark", "stop"]); s.add_argument("label", nargs="?"); s.add_argument("--reason"); s.set_defaults(f=cmd_capture)- s = sub.add_parser("status", help="where the run stands"); s.set_defaults(f=cmd_status)- a = ap.parse_args(argv)- a.f(a)---if __name__ == "__main__":- main()
reference/python/copper.pydeleted−481@@ -1,481 +0,0 @@-#!/usr/bin/env python3-"""Apply a routing plan OFFLINE into a copy of the board and gate it with the shared KiCad DRC.-- python3 demo/routing/esc/write_copper.py BOARD-planes.kicad_pcb PLAN.json [--out FILE]- [--no-fill-planes] [--drc] [--baseline BOARD.kicad_pcb]- [--report drc-report.json]--The plan is the list of `kicad_route_net` calls the router wrote (see ai_router.py): each entry-is {"net", "width", "points": [...]} or {"net", "width", "paths": [[...], ...]} plus optional-"viaSize" / "viaDrill". Waypoints are "REF.PAD" (the pad centre), [x, y], or {"x", "y",-"layer": "B.Cu"} / {"pad": "REF.PAD", "layer": ...}; a layer marker means a through via AT that-point and every following segment on the new layer, exactly as rust/crates/kicad-core/src/pcb.rs-`plan` reads it. Segments and vias are written as KiCad 10 s-expressions with the board's net-reference, a fresh uuid each, and spliced in before the closing paren.--Plane zones (GND on In1.Cu, +3V3 on In2.Cu from add_planes.py) have no fill in the file and the-shared `service-kicad pcb drc` does not refill zones, so by default this script also writes a-raster fill for every zone: overlapping 0.1 mm strips covering the outline (0.5 mm edge clearance)-minus every other-net through pad, via and hole expanded by the 0.2 mm clearance and the grid-slop. KiCad's own fill on the live board replaces it (thermal reliefs and all); here it lets the-DRC count each plane-net via as connected. --no-fill-planes leaves the zones unfilled.--items and the violation types. With --baseline, errors already present on the unrouted board-(same type, description and item positions) are listed separately as inherited, the way the-bridge's route_net compares before/after; the gate is zero NEW errors and zero unconnected.-"""-import argparse-import json-import math-import os-import subprocess-import sys-import tempfile-import uuid-from collections import Counter-from pathlib import Path--import numpy as np--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board, Grid, fmt # noqa: E402--CLEARANCE = 0.2-EDGE_CLEARANCE = 0.5-SLOP = 0.1---# ----------------------------------------------------------------------------- plan -> copper--def _point(spec, board):- """(x, y, pad, explicit_layer) for one waypoint, matching pcb.rs resolve_point."""- layer = None- pad = None- if isinstance(spec, str):- pad = board.pad_by_key(spec)- if pad is None:- raise ValueError("unknown pad %r" % spec)- return pad.x, pad.y, pad, None- if isinstance(spec, dict):- layer = spec.get("layer")- if spec.get("pad"):- pad = board.pad_by_key(str(spec["pad"]))- if pad is None:- raise ValueError("unknown pad %r" % spec["pad"])- return pad.x, pad.y, pad, layer- return float(spec["x"]), float(spec["y"]), None, layer- if isinstance(spec, (list, tuple)) and len(spec) == 2:- return float(spec[0]), float(spec[1]), None, None- raise ValueError("bad waypoint %r" % (spec,))---def plan_to_copper(board, entry):- """One plan entry -> (segments, vias): segments (x0,y0,x1,y1,layer,width), vias (x,y,size,drill)."""- net = entry["net"]- if net not in board.nets:- raise ValueError("unknown net %r" % net)- width = float(entry.get("width", 0.25))- size = float(entry.get("viaSize", 0.6))- drill = float(entry.get("viaDrill", 0.3))- paths = entry.get("paths") or [entry["points"]]- segments, vias = [], []- for path in paths:- if not 2 <= len(path) <= 512:- raise ValueError("path needs 2..512 waypoints (%s)" % net)- resolved = [_point(p, board) for p in path]- for x, y, pad, _ in resolved:- if pad is not None and pad.net != net:- raise ValueError("pad %s is on %s, not %s" % (pad.key, pad.net, net))- layer = entry.get("layer") or resolved[0][3] or "F.Cu"- for i, (x, y, pad, explicit) in enumerate(resolved):- nxt = explicit or layer- if i > 0:- px, py = resolved[i - 1][0], resolved[i - 1][1]- if (px, py) != (x, y):- segments.append((px, py, x, y, layer, width))- if nxt != layer:- vias.append((x, y, size, drill))- layer = nxt- if pad is not None and layer not in pad.layers:- raise ValueError("pad %s has no copper on %s" % (pad.key, layer))- return segments, vias---def segment_sexpr(board, s, net):- x0, y0, x1, y1, layer, w = s- return ("\t(segment\n\t\t(start %s %s)\n\t\t(end %s %s)\n\t\t(width %s)\n\t\t(layer \"%s\")\n%s\t\t(uuid \"%s\")\n\t)"- % (fmt(x0), fmt(y0), fmt(x1), fmt(y1), fmt(w), layer, board.net_ref_sexpr(net), uuid.uuid4()))---def via_sexpr(board, v, net):- x, y, size, drill = v- return ("\t(via\n\t\t(at %s %s)\n\t\t(size %s)\n\t\t(drill %s)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n%s\t\t(uuid \"%s\")\n\t)"- % (fmt(x), fmt(y), fmt(size), fmt(drill), board.net_ref_sexpr(net), uuid.uuid4()))---def splice(text, blocks):- trimmed = text.rstrip()- idx = trimmed.rfind(")")- body = text[:idx].rstrip()- nl = "\r\n" if "\r\n" in text[:2000] else "\n"- return body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl---# ----------------------------------------------------------------------------- plane raster fill--def zone_fill_polygons(board, zone_net, zone_layer, all_vias_by_net, grid=None):- """ONE fractured polygon per connected region of the plane (KiCad's own fill format: the outer- contour with every hole spliced in through a zero-width slit). KiCad does not connect- overlapping sub-polygons of one zone to each other, so a fill made of strips would be a set- of islands; a single polygon is one island the way a real fill is.-- all_vias_by_net: {net: [(x, y, size, drill), ...]} for every via the plan adds."""- grid = grid or Grid(board.bbox)- free = grid.outline_inside(board.outline, EDGE_CLEARANCE + SLOP)- block = np.zeros((grid.H, grid.W), dtype=bool)- by_clearance = {} # pads with a local clearance override are dilated by their own value- for p in board.pads:- if not p.thru or p.net == zone_net:- continue- target = block if p.clearance <= CLEARANCE else by_clearance.setdefault(p.clearance, np.zeros((grid.H, grid.W), dtype=bool))- for poly in p.polys:- grid.poly_mask(poly, target)- if p.drill:- jj, ii = grid.disk_cells((p.x, p.y), p.drill / 2.0)- target[jj, ii] = True- for net, vias in all_vias_by_net.items():- if net == zone_net:- continue- for x, y, size, drill in vias:- jj, ii = grid.disk_cells((x, y), size / 2.0)- block[jj, ii] = True- block = grid.dilate(block, (CLEARANCE + SLOP) / grid.res)- for clr, mask in by_clearance.items():- block |= grid.dilate(mask, (clr + SLOP) / grid.res)- free &= ~block- loops = _trace_boundaries(free)- outers = [lp for lp in loops if _signed_area(lp) > 0]- holes = [lp for lp in loops if _signed_area(lp) < 0]- polys = []- for outer in outers:- ob = _bbox(outer)- mine = []- for h in holes:- hb = _bbox(h)- if hb[0] < ob[0] or hb[1] < ob[1] or hb[2] > ob[2] or hb[3] > ob[3]:- continue- if _point_in_poly(h[0][0] + 1e-6, h[0][1] + 1e-6, outer):- mine.append(h)- poly = _fracture(outer, mine)- polys.append([(grid.x0 + x * grid.res, grid.y0 + y * grid.res) for x, y in poly])- return polys---def _trace_boundaries(free):- """Boundary loops of a binary raster as lattice polygons (cell-corner coordinates, in cells).- Region on the right of the direction of travel; outer loops and holes get opposite signed areas."""- H, W = free.shape- padded = np.zeros((H + 2, W + 2), dtype=bool)- padded[1:-1, 1:-1] = free- f = padded- # directed edges around free cells whose neighbour across that side is not free- edges = []- jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[0:-2, 1:-1]) # top side: TL -> TR- edges += [((i - 0.5, j - 0.5), (i + 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]- jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 2:]) # right side: TR -> BR- edges += [((i + 0.5, j - 0.5), (i + 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]- jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[2:, 1:-1]) # bottom side: BR -> BL- edges += [((i + 0.5, j + 0.5), (i - 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]- jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 0:-2]) # left side: BL -> TL- edges += [((i - 0.5, j + 0.5), (i - 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]- out = {}- for a, b in edges:- out.setdefault(a, []).append(b)- loops = []- while out:- a = next(iter(out))- b = out[a].pop()- if not out[a]:- del out[a]- loop = [a, b]- prev = (b[0] - a[0], b[1] - a[1])- while True:- cur = loop[-1]- cands = out.get(cur)- if not cands:- break- if len(cands) == 1:- nxt = cands.pop()- else:- # a pinch point: prefer the right turn (toward the region), then straight, then left- right = (-prev[1], prev[0])- pick = None- for want in (right, prev, (prev[1], -prev[0])):- for c in cands:- if (c[0] - cur[0], c[1] - cur[1]) == want:- pick = c- break- if pick is not None:- break- nxt = pick if pick is not None else cands[-1]- cands.remove(nxt)- if not cands:- del out[cur]- prev = (nxt[0] - cur[0], nxt[1] - cur[1])- if nxt == loop[0]:- break- loop.append(nxt)- # drop collinear vertices- simp = []- n = len(loop)- for k in range(n):- p0, p1, p2 = loop[k - 1], loop[k], loop[(k + 1) % n]- if (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) != 0:- simp.append(p1)- if len(simp) >= 4:- loops.append(simp)- return loops---def _signed_area(poly):- a = 0.0- for k in range(len(poly)):- x0, y0 = poly[k]- x1, y1 = poly[(k + 1) % len(poly)]- a += x0 * y1 - x1 * y0- return a / 2.0---def _bbox(poly):- xs = [p[0] for p in poly]- ys = [p[1] for p in poly]- return min(xs), min(ys), max(xs), max(ys)---def _point_in_poly(x, y, poly):- inside = False- n = len(poly)- for k in range(n):- ax, ay = poly[k]- bx, by = poly[(k + 1) % n]- if (ay > y) != (by > y):- if x < ax + (y - ay) * (bx - ax) / (by - ay):- inside = not inside- return inside---def _fracture(outer, holes):- """Splice every hole into the outer contour through a horizontal zero-width slit to the left,- holes in order of their left edge so a slit only ever meets contour that is already part of- the polygon. The slit leaves the hole from the middle of its leftmost vertical edge, so it never- hits a lattice vertex."""- poly = list(outer)-- def leftmost_edge(h):- best = None- n = len(h)- for k in range(n):- a, b = h[k], h[(k + 1) % n]- if a[0] == b[0] and (best is None or a[0] < best[0]):- best = (a[0], k)- return best-- order = sorted(range(len(holes)), key=lambda k: leftmost_edge(holes[k])[0])- for hk in order:- h = holes[hk]- x_h, k = leftmost_edge(h)- a, b = h[k], h[(k + 1) % len(h)]- ym = (a[1] + b[1]) / 2.0- if abs(b[1] - a[1]) >= 2:- ym = a[1] + (0.5 if b[1] > a[1] else -0.5) # a whole cell from the vertex: never a lattice vertex- start = (x_h, ym)- # hole contour starting at the slit point- hole = [start] + h[k + 1:] + h[:k + 1] + [start]- # nearest vertical edge of the polygon to the left of the slit point at height ym- hit = None- for e in range(len(poly)):- p0, p1 = poly[e], poly[(e + 1) % len(poly)]- if p0[0] == p1[0] and p0[0] < x_h and min(p0[1], p1[1]) < ym < max(p0[1], p1[1]):- if hit is None or p0[0] > hit[0]:- hit = (p0[0], e)- if hit is None:- continue # nothing to the left inside this outer: leave the hole out (cannot happen for a hole)- xe, e = hit- joint = (xe, ym)- poly = poly[:e + 1] + [joint] + hole + [joint] + poly[e + 1:]- return poly---def zones_of(board):- """[(node, net, layer)] for every top-level zone."""- out = []- for z in board.root.find_all("zone"):- n = z.find("net")- net = n.value() if n is not None else ""- if board.net_format == "number" and n is not None:- try:- net = board.net_table.get(int(n.atom(1)), net)- except (TypeError, ValueError):- pass- nn = z.find("net_name")- if nn is not None:- net = nn.value()- layer = z.find("layer").value() if z.find("layer") is not None else None- out.append((z, net, layer))- return out---def fill_zones(board, text, vias_by_net):- """Return the board text with a filled_polygon list appended inside every zone."""- grid = Grid(board.bbox)- edits = []- stats = {}- for node, net, layer in zones_of(board):- if not net or not layer:- continue- polys = zone_fill_polygons(board, net, layer, vias_by_net, grid)- blocks = []- for poly in polys:- pts = " ".join("(xy %s %s)" % (fmt(x), fmt(y)) for x, y in poly)- blocks.append("\t\t(filled_polygon\n\t\t\t(layer \"%s\")\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % (layer, pts))- # insert before the zone's closing paren- edits.append((node.end - 1, node.end - 1, "".join(blocks) + "\t"))- # a raster fill has no thermal spokes: connect pads solidly in this gate copy so the DRC- # does not report starved thermals on the through pads inside the plane- cp = node.find("connect_pads")- if cp is not None and cp.atom(1) not in ("yes", "no", "thru_hole_only"):- edits.append((cp.start, cp.start + len("(connect_pads"), "(connect_pads yes"))- stats[net] = len(polys)- edits.sort()- out = []- pos = 0- for at, end, ins in edits:- out.append(text[pos:at])- out.append(ins)- pos = end- out.append(text[pos:])- return "".join(out), stats---# ----------------------------------------------------------------------------- DRC--def run_drc(path):- with tempfile.TemporaryDirectory() as tmp:- report = Path(tmp) / "drc.json"- proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],- capture_output=True, text=True, timeout=900)- if proc.returncode or not report.is_file():- raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-600:], proc.stderr[-600:]))- return json.loads(report.read_text(encoding="utf-8"))---def fingerprint(v):- return json.dumps([v.get("type"), v.get("severity"), v.get("description"),- sorted(json.dumps(i.get("pos"), sort_keys=True) for i in v.get("items", []))], sort_keys=True)---def summarize(data, baseline=None):- viol = data.get("violations", [])- errors = [v for v in viol if v.get("severity") == "error"]- warnings = [v for v in viol if v.get("severity") == "warning"]- inherited = []- new = errors- if baseline is not None:- existing = Counter(fingerprint(v) for v in baseline.get("violations", []) if v.get("severity") == "error")- new, inherited = [], []- for v in errors:- k = fingerprint(v)- if existing.get(k, 0) > 0:- existing[k] -= 1- inherited.append(v)- else:- new.append(v)- unconnected = data.get("unconnected_items", [])- # isolated_copper warnings on a raster-filled plane are the strips themselves (each filled- # polygon that touches no pad is an "island" to KiCad); the live fill has none of them- fill_artifacts = [v for v in warnings if v.get("type") == "isolated_copper"]- warnings = [v for v in warnings if v.get("type") != "isolated_copper"]- return {- "errors": len(errors), "newErrors": len(new), "inheritedErrors": len(inherited),- "warnings": len(warnings), "unconnected": len(unconnected),- "rasterFillIslandWarnings": len(fill_artifacts),- "errorTypes": dict(Counter(v.get("type") for v in errors)),- "newErrorTypes": dict(Counter(v.get("type") for v in new)),- "warningTypes": dict(Counter(v.get("type") for v in warnings)),- "newErrorList": [{"type": v.get("type"), "description": v.get("description"),- "items": [i.get("description") for i in v.get("items", [])],- "pos": [i.get("pos") for i in v.get("items", [])]} for v in new],- "unconnectedList": [[i.get("description") for i in u.get("items", [])] for u in unconnected],- "kicadVersion": data.get("kicad_version"),- }---# ----------------------------------------------------------------------------- main--def apply(board, plan, fill_planes=True):- """Return (text, stats) for the board text with the plan's copper (and optional fills)."""- entries = plan["nets"] if isinstance(plan, dict) else plan- blocks = []- vias_by_net = {}- n_seg = n_via = 0- length = 0.0- widths = Counter()- for entry in entries:- segs, vias = plan_to_copper(board, entry)- for s in segs:- blocks.append(segment_sexpr(board, s, entry["net"]))- length += math.hypot(s[2] - s[0], s[3] - s[1])- widths[s[5]] += 1- for v in vias:- blocks.append(via_sexpr(board, v, entry["net"]))- vias_by_net.setdefault(entry["net"], []).append(v)- n_seg += len(segs)- n_via += len(vias)- text = splice(board.text, blocks)- stats = {"entries": len(entries), "nets": len({e["net"] for e in entries}), "segments": n_seg, "vias": n_via,- "copperLengthMm": round(length, 2), "segmentsByWidth": {str(k): v for k, v in sorted(widths.items())}}- if fill_planes:- text, fills = fill_zones(board, text, vias_by_net)- stats["zoneFillPolygons"] = fills- return text, stats---def main(argv=None):- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board", help="the board WITH planes (add_planes.py output)")- ap.add_argument("plan", help="ai_router.py plan JSON")- ap.add_argument("--out", help="output .kicad_pcb (default: <plan>-routed.kicad_pcb beside the plan)")- ap.add_argument("--no-fill-planes", action="store_true")- ap.add_argument("--drc", action="store_true")- ap.add_argument("--baseline", help="unrouted board whose DRC errors are inherited (default: the input board)")- ap.add_argument("--report", help="write the DRC summary JSON here")- a = ap.parse_args(argv)- board = Board(a.board)- plan = json.load(open(a.plan, encoding="utf-8"))- text, stats = apply(board, plan, fill_planes=not a.no_fill_planes)- out = Path(a.out) if a.out else Path(a.plan).with_name(Path(a.plan).stem + "-routed.kicad_pcb")- out.write_text(text, encoding="utf-8", newline="")- Board(str(out))- print("%s: %s" % (out, json.dumps(stats)))- if a.drc:- base = run_drc(a.baseline or a.board)- data = run_drc(out)- s = summarize(data, base)- s["copper"] = stats- s["file"] = str(out)- print("drc: %d errors (%d new, %d inherited), %d warnings (+%d raster-fill island warnings), %d unconnected; new error types %s; warning types %s; KiCad %s" % (- s["errors"], s["newErrors"], s["inheritedErrors"], s["warnings"], s["rasterFillIslandWarnings"], s["unconnected"],- json.dumps(s["newErrorTypes"], sort_keys=True), json.dumps(s["warningTypes"], sort_keys=True), s["kicadVersion"]))- for v in s["newErrorList"][:40]:- print(" NEW %s: %s %s" % (v["type"], v["description"], v["items"]))- for u in s["unconnectedList"][:40]:- print(" UNCONNECTED %s" % (u,))- if a.report:- Path(a.report).write_text(json.dumps(s, indent=1) + "\n", encoding="utf-8")- return 0 if (s["newErrors"] == 0 and s["unconnected"] == 0) else 2- return 0---if __name__ == "__main__":- sys.exit(main())
reference/python/escboard.pydeleted−532@@ -1,532 +0,0 @@-#!/usr/bin/env python3-"""Board model, pad geometry and grid rasterisation shared by the ESC routing scripts.--Reads a KiCad 10 `.kicad_pcb` with the span-keeping tokenizer from-tools/make_placement_fixture.py and turns it into what a grid router needs:-- * every copper pad in BOARD coordinates (footprint position + rotation, KiCad's- y-down convention: x' = x*cos + y*sin, y' = -x*sin + y*cos), with its outline as- one or more polygons (rect/roundrect as the rectangle, oval as a stadium, circle as- a 32-gon, custom pads as their anchor plus every gr_poly/gr_rect/gr_circle primitive)- * the Edge.Cuts outline as one closed polygon (arcs sampled)- * the net format (KiCad 10 boards carry `(net "NAME")` on pads and no net table)- * a 0.1 mm grid with conservative rasterisers (a cell is copper if any part of the- shape touches the cell square) and a disk dilation for clearance maps--Python 3 plus numpy. Nothing here writes a file.-"""-import math-import os-import sys--import numpy as np--_TOOLS = os.path.dirname(os.path.abspath(__file__))-sys.path.insert(0, os.path.abspath(_TOOLS))-from fixture import parse, reference_of, num # noqa: E402--RES = 0.1 # mm per grid cell-F_CU, B_CU = 0, 1-LAYER_NAMES = {F_CU: "F.Cu", B_CU: "B.Cu"}-LAYER_IDS = {"F.Cu": F_CU, "B.Cu": B_CU}---# ----------------------------------------------------------------------------- geometry--def rot_kicad(px, py, deg):- """KiCad's board rotation of a local offset (y-down, positive angle = counterclockwise on screen)."""- a = math.radians(deg)- c, s = math.cos(a), math.sin(a)- return px * c + py * s, -px * s + py * c---def circle_poly(cx, cy, r, n=32):- return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]---def stadium_poly(sx, sy, n=10):- """Oval pad of size sx x sy centred at 0 (local frame)."""- if sx >= sy:- r = sy / 2.0- d = (sx - sy) / 2.0- pts = []- for k in range(n + 1):- a = -math.pi / 2 + math.pi * k / n- pts.append((d + r * math.cos(a), r * math.sin(a)))- for k in range(n + 1):- a = math.pi / 2 + math.pi * k / n- pts.append((-d + r * math.cos(a), r * math.sin(a)))- return pts- pts = stadium_poly(sy, sx, n)- return [(y, x) for x, y in pts]---def arc_points(p0, pm, p1, max_step=0.35):- """Sample a KiCad three-point arc (start, mid, end) into a polyline including both ends."""- (x0, y0), (xm, ym), (x1, y1) = p0, pm, p1- ax, ay = xm - x0, ym - y0- bx, by = x1 - x0, y1 - y0- d = 2.0 * (ax * by - ay * bx)- if abs(d) < 1e-9:- return [p0, p1]- a2 = ax * ax + ay * ay- b2 = bx * bx + by * by- ux = (by * a2 - ay * b2) / d- uy = (ax * b2 - bx * a2) / d- cx, cy = x0 + ux, y0 + uy- r = math.hypot(ux, uy)- t0 = math.atan2(y0 - cy, x0 - cx)- tm = math.atan2(ym - cy, xm - cx)- t1 = math.atan2(y1 - cy, x1 - cx)-- def ccw(a, b):- return (b - a) % (2 * math.pi)-- if ccw(t0, tm) <= ccw(t0, t1):- sweep = ccw(t0, t1)- else:- sweep = -ccw(t1, t0)- n = max(2, int(math.ceil(abs(sweep) * r / max_step)))- return [(cx + r * math.cos(t0 + sweep * k / n), cy + r * math.sin(t0 + sweep * k / n)) for k in range(n + 1)]---def chain_polylines(pieces, tol=0.01):- """Join open polylines end to end into one closed loop (the board outline)."""- pieces = [list(p) for p in pieces if len(p) >= 2]- if not pieces:- return []- loop = pieces.pop(0)- while pieces:- end = loop[-1]- best = None- for idx, p in enumerate(pieces):- if math.dist(end, p[0]) < tol:- best = (idx, False)- break- if math.dist(end, p[-1]) < tol:- best = (idx, True)- break- if best is None:- raise ValueError("Edge.Cuts outline is not one closed loop (gap at %s)" % (end,))- idx, rev = best- p = pieces.pop(idx)- if rev:- p.reverse()- loop.extend(p[1:])- if math.dist(loop[0], loop[-1]) < tol:- loop.pop()- return loop---def polygon_area(poly):- a = 0.0- for i in range(len(poly)):- x0, y0 = poly[i]- x1, y1 = poly[(i + 1) % len(poly)]- a += x0 * y1 - x1 * y0- return a / 2.0---# ----------------------------------------------------------------------------- pads--class Pad:- __slots__ = ("ref", "name", "net", "x", "y", "angle", "shape", "size", "drill", "thru", "layers",- "polys", "key", "fp_x", "fp_y", "long_axis", "half_long", "half_short", "uuid", "polys_centre", "clearance")-- def __init__(self):- self.polys = []-- def __repr__(self):- return "Pad(%s %s at %.3f,%.3f)" % (self.key, self.net, self.x, self.y)-- @property- def min_dim(self):- return 2.0 * self.half_short-- def local_extent(self):- """Extents of the pad copper in its own (rotated) frame: (half_long, half_short, axis unit vector)."""- return self.half_long, self.half_short, self.long_axis---def _pad_local_polys(pad_node, shape, sx, sy):- """Polygons of the pad shape in the pad-local unrotated frame, centred on the pad origin."""- polys = []- if shape in ("rect", "roundrect", "trapezoid", "chamfer"):- polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])- elif shape == "oval":- polys.append(stadium_poly(sx, sy))- elif shape == "circle":- polys.append(circle_poly(0.0, 0.0, sx / 2.0))- elif shape == "custom":- opts = pad_node.find("options")- anchor = "rect"- if opts is not None and opts.find("anchor") is not None:- anchor = opts.find("anchor").value()- if anchor == "circle":- polys.append(circle_poly(0.0, 0.0, sx / 2.0))- else:- polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])- prims = pad_node.find("primitives")- if prims is not None:- for prim in prims.items:- if not hasattr(prim, "tag"):- continue- if prim.tag == "gr_poly":- pts = prim.find("pts")- if pts is not None:- poly = [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]- w = prim.find("width")- wv = w.number(1) if w is not None else 0.0- if wv and wv > 0:- # stroked outline: widen by half the stroke (conservative bbox growth)- poly = _grow_poly(poly, wv / 2.0)- polys.append(poly)- elif prim.tag == "gr_rect":- s, e = prim.find("start"), prim.find("end")- if s is not None and e is not None:- x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)- polys.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])- elif prim.tag == "gr_circle":- c, e = prim.find("center"), prim.find("end")- if c is not None and e is not None:- r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))- w = prim.find("width")- r += (w.number(1) if w is not None else 0.0) / 2.0- polys.append(circle_poly(c.number(1), c.number(2), r))- elif prim.tag == "gr_line":- s, e = prim.find("start"), prim.find("end")- w = prim.find("width")- if s is not None and e is not None:- hw = (w.number(1) if w is not None else 0.1) / 2.0- polys.append(_thick_line_poly((s.number(1), s.number(2)), (e.number(1), e.number(2)), hw))- else:- polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])- return polys---def _grow_poly(poly, d):- """Cheap outward growth of a polygon: push every vertex away from the centroid by d."""- cx = sum(p[0] for p in poly) / len(poly)- cy = sum(p[1] for p in poly) / len(poly)- out = []- for x, y in poly:- vx, vy = x - cx, y - cy- n = math.hypot(vx, vy) or 1.0- out.append((x + vx / n * d, y + vy / n * d))- return out---def _thick_line_poly(a, b, hw):- dx, dy = b[0] - a[0], b[1] - a[1]- n = math.hypot(dx, dy) or 1.0- nx, ny = -dy / n * hw, dx / n * hw- return [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny), (b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]---def _oriented_extent(polys):- xs = [p[0] for poly in polys for p in poly]- ys = [p[1] for poly in polys for p in poly]- return min(xs), min(ys), max(xs), max(ys)---# ----------------------------------------------------------------------------- board--class Board:- def __init__(self, path):- self.path = path- with open(path, "r", encoding="utf-8", newline="") as f:- self.text = f.read()- self.root = parse(self.text)- if self.root.tag != "kicad_pcb":- raise ValueError("%s is not a kicad_pcb" % path)- self.net_table = {} # number -> name when the file has a (net N "NAME") table- for n in self.root.find_all("net"):- try:- self.net_table[int(n.atom(1))] = n.atom(2) or ""- except (TypeError, ValueError):- pass- self.net_format = "number" if self.net_table else "name"- self.pads = []- self.footprints = {}- self._read_footprints()- self.outline = self._read_outline()- xs = [p[0] for p in self.outline]- ys = [p[1] for p in self.outline]- self.bbox = (min(xs), min(ys), max(xs), max(ys))- self.nets = {}- for p in self.pads:- if p.net:- self.nets.setdefault(p.net, []).append(p)- self.copper_layers = [a.atom(1) for a in self.root.find("layers").items[1:] if a.atom(2) == "signal" or a.atom(2) == "power" or a.atom(2) == "mixed"]-- # -- footprints and pads- def _read_footprints(self):- for fp in self.root.find_all("footprint"):- ref = reference_of(fp)- at = fp.find("at")- fx, fy = at.number(1), at.number(2)- rot = at.number(3) or 0.0- layer = fp.find("layer").value() if fp.find("layer") is not None else "F.Cu"- fp_clr = fp.find("clearance")- fp_clearance = fp_clr.number(1) if fp_clr is not None else 0.0- self.footprints[ref] = {"x": fx, "y": fy, "rot": rot, "layer": layer, "lib": fp.atom(1), "node": fp}- for pn in fp.find_all("pad"):- name = pn.value()- ptype = pn.atom(2)- shape = pn.atom(3)- layers_node = pn.find("layers")- layers = [a.value for a in layers_node.items[1:]] if layers_node is not None else []- copper = set()- for l in layers:- if l == "*.Cu":- copper.update(("F.Cu", "B.Cu"))- elif l.endswith(".Cu"):- copper.add(l)- if not copper or not name:- continue # paste-only apertures and unnamed helper pads carry no copper- pat = pn.find("at")- px, py = pat.number(1), pat.number(2)- pang = pat.number(3) if pat.atom(3) is not None else rot- size = pn.find("size")- sx, sy = size.number(1), size.number(2)- dr = pn.find("drill")- pad = Pad()- pad.ref, pad.name, pad.key = ref, name, "%s.%s" % (ref, name)- nn = pn.find("net")- pad.net = nn.value() if nn is not None else ""- if self.net_format == "number" and nn is not None:- try:- pad.net = self.net_table.get(int(nn.atom(1)), pad.net)- except (TypeError, ValueError):- pass- ox, oy = rot_kicad(px, py, rot)- pad.x, pad.y = fx + ox, fy + oy- pad.angle = pang- pad.shape, pad.size = shape, (sx, sy)- pad.drill = dr.number(1) if dr is not None else None- pad.thru = ptype == "thru_hole" or ptype == "np_thru_hole"- pad.layers = frozenset(l for l in copper if l in ("F.Cu", "B.Cu"))- pad.fp_x, pad.fp_y = fx, fy- u = pn.find("uuid")- pad.uuid = u.value() if u is not None else ""- pclr = pn.find("clearance")- # KiCad: the effective clearance between two items is the largest of the rule and both local overrides- pad.clearance = max(fp_clearance, pclr.number(1) if pclr is not None else 0.0)- local = _pad_local_polys(pn, shape, sx, sy)- x0, y0, x1, y1 = _oriented_extent(local)- lx, ly = (x1 - x0) / 2.0, (y1 - y0) / 2.0- # centre of the copper in the local frame (custom pads are often offset from the origin)- lcx, lcy = (x0 + x1) / 2.0, (y0 + y1) / 2.0- if lx >= ly:- pad.half_long, pad.half_short = lx, ly- axis = rot_kicad(1.0, 0.0, pang)- else:- pad.half_long, pad.half_short = ly, lx- axis = rot_kicad(0.0, 1.0, pang)- pad.long_axis = axis- for poly in local:- pad.polys.append([(pad.x + rot_kicad(x, y, pang)[0], pad.y + rot_kicad(x, y, pang)[1]) for x, y in poly])- # keep the copper centre for escapes (custom pads)- ccx, ccy = rot_kicad(lcx, lcy, pang)- pad.polys_centre = (pad.x + ccx, pad.y + ccy)- self.pads.append(pad)-- # -- outline- def _read_outline(self):- pieces = []- for node in self.root.items:- if not hasattr(node, "tag"):- continue- layer = node.find("layer") if node.tag and node.tag.startswith("gr_") else None- if layer is None or layer.value() != "Edge.Cuts":- continue- if node.tag == "gr_line":- s, e = node.find("start"), node.find("end")- pieces.append([(s.number(1), s.number(2)), (e.number(1), e.number(2))])- elif node.tag == "gr_arc":- s, m, e = node.find("start"), node.find("mid"), node.find("end")- pieces.append(arc_points((s.number(1), s.number(2)), (m.number(1), m.number(2)), (e.number(1), e.number(2))))- elif node.tag == "gr_rect":- s, e = node.find("start"), node.find("end")- x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)- return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]- elif node.tag == "gr_circle":- c, e = node.find("center"), node.find("end")- r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))- return circle_poly(c.number(1), c.number(2), r, 64)- elif node.tag == "gr_poly":- pts = node.find("pts")- return [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]- if not pieces:- raise ValueError("no Edge.Cuts outline")- return chain_polylines(pieces)-- def net_ref_sexpr(self, net_name):- """The `(net ...)` line for copper on this board, matching the bridge's net_sexpr."""- if self.net_format == "number":- number = next((n for n, name in self.net_table.items() if name == net_name), None)- if number is None:- raise KeyError("net %r is not in the net table" % net_name)- return "\t\t(net %d)\n" % number- return "\t\t(net %s)\n" % _quote(net_name)-- def pad_by_key(self, key):- for p in self.pads:- if p.key == key:- return p- return None---def _quote(s):- return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'---# ----------------------------------------------------------------------------- grid--class Grid:- """A 0.1 mm cell grid covering the outline bbox. Cell (i, j) is centred on (x0 + i*RES, y0 + j*RES)."""-- def __init__(self, bbox, res=RES):- self.res = res- self.x0, self.y0 = bbox[0], bbox[1]- self.W = int(round((bbox[2] - bbox[0]) / res)) + 1- self.H = int(round((bbox[3] - bbox[1]) / res)) + 1-- def to_cell(self, x, y):- # round half UP (not Python's half-to-even) so pads at a 0.5 mm pitch on .x5 coordinates- # snap to cells a consistent 5 apart- # 1e-3 cell (0.1 um) of tolerance: KiCad coordinates carry nanometre noise (123.849998)- return int(math.floor((x - self.x0) / self.res + 0.5 + 1e-3)), int(math.floor((y - self.y0) / self.res + 0.5 + 1e-3))-- def to_xy(self, i, j):- return round(self.x0 + i * self.res, 4), round(self.y0 + j * self.res, 4)-- def in_bounds(self, i, j):- return 0 <= i < self.W and 0 <= j < self.H-- # -- rasterisers (all conservative: any touch of the cell square marks the cell)- def poly_mask(self, poly, mask=None):- if mask is None:- mask = np.zeros((self.H, self.W), dtype=bool)- xs = np.array([p[0] for p in poly])- ys = np.array([p[1] for p in poly])- i0 = max(0, int(math.floor((xs.min() - self.x0) / self.res)) - 1)- i1 = min(self.W - 1, int(math.ceil((xs.max() - self.x0) / self.res)) + 1)- j0 = max(0, int(math.floor((ys.min() - self.y0) / self.res)) - 1)- j1 = min(self.H - 1, int(math.ceil((ys.max() - self.y0) / self.res)) + 1)- if i1 < i0 or j1 < j0:- return mask- ci = np.arange(i0, i1 + 1)- cj = np.arange(j0, j1 + 1)- cx = self.x0 + ci * self.res- cy = self.y0 + cj * self.res- CX, CY = np.meshgrid(cx, cy)- inside = np.zeros(CX.shape, dtype=bool)- n = len(poly)- for k in range(n):- xa, ya = poly[k]- xb, yb = poly[(k + 1) % n]- if ya == yb:- continue- cond = (ya > CY) != (yb > CY)- xint = xa + (CY - ya) * (xb - xa) / (yb - ya)- inside ^= cond & (CX < xint)- # boundary supercover: sample each edge finely and mark the containing cells- for k in range(n):- xa, ya = poly[k]- xb, yb = poly[(k + 1) % n]- L = math.hypot(xb - xa, yb - ya)- steps = max(1, int(math.ceil(L / 0.025)))- t = np.linspace(0.0, 1.0, steps + 1)- ex = xa + (xb - xa) * t- ey = ya + (yb - ya) * t- ii = np.round((ex - self.x0) / self.res).astype(int)- jj = np.round((ey - self.y0) / self.res).astype(int)- ok = (ii >= i0) & (ii <= i1) & (jj >= j0) & (jj <= j1)- inside[jj[ok] - j0, ii[ok] - i0] = True- mask[j0:j1 + 1, i0:i1 + 1] |= inside- return mask-- def segment_cells(self, a, b, half_width, slack=None):- """Cells whose centre is within half_width + slack of segment a-b. Returns (jj, ii) index arrays."""- if slack is None:- slack = self.res / 2.0- r = half_width + slack- xa, ya = a- xb, yb = b- i0 = max(0, int(math.floor((min(xa, xb) - r - self.x0) / self.res)))- i1 = min(self.W - 1, int(math.ceil((max(xa, xb) + r - self.x0) / self.res)))- j0 = max(0, int(math.floor((min(ya, yb) - r - self.y0) / self.res)))- j1 = min(self.H - 1, int(math.ceil((max(ya, yb) + r - self.y0) / self.res)))- if i1 < i0 or j1 < j0:- return np.array([], dtype=int), np.array([], dtype=int)- ci = np.arange(i0, i1 + 1)- cj = np.arange(j0, j1 + 1)- CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)- dx, dy = xb - xa, yb - ya- L2 = dx * dx + dy * dy- if L2 < 1e-12:- t = np.zeros_like(CX)- else:- t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)- px = xa + t * dx- py = ya + t * dy- d = np.hypot(CX - px, CY - py)- jj, ii = np.nonzero(d <= r + 1e-9)- return jj + j0, ii + i0-- def disk_cells(self, c, radius, slack=None):- return self.segment_cells(c, c, radius, slack)-- def dilate(self, mask, r_cells):- """Binary dilation by a disk of radius r_cells (float, in cells)."""- if r_cells <= 0:- return mask.copy()- R = int(math.floor(r_cells))- H, W = mask.shape- padded = np.zeros((H + 2 * R, W + 2 * R), dtype=bool)- padded[R:R + H, R:R + W] = mask- out = np.zeros_like(mask)- r2 = r_cells * r_cells + 1e-9- for dy in range(-R, R + 1):- for dx in range(-R, R + 1):- if dx * dx + dy * dy <= r2:- out |= padded[R + dy:R + dy + H, R + dx:R + dx + W]- return out-- def outline_inside(self, outline, margin):- """Cells whose centre is inside the outline polygon and at least `margin` from every edge."""- inside, dmin = self.outline_distance(outline)- return inside & (dmin >= margin)-- def outline_distance(self, outline):- """(inside mask, distance of every cell centre to the nearest outline edge)."""- inside = np.zeros((self.H, self.W), dtype=bool)- ci = np.arange(self.W)- cj = np.arange(self.H)- CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)- n = len(outline)- for k in range(n):- xa, ya = outline[k]- xb, yb = outline[(k + 1) % n]- if ya == yb:- continue- cond = (ya > CY) != (yb > CY)- xint = xa + (CY - ya) * (xb - xa) / (yb - ya)- inside ^= cond & (CX < xint)- dmin = np.full(CX.shape, np.inf)- for k in range(n):- xa, ya = outline[k]- xb, yb = outline[(k + 1) % n]- dx, dy = xb - xa, yb - ya- L2 = dx * dx + dy * dy- if L2 < 1e-12:- continue- t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)- d = np.hypot(CX - (xa + t * dx), CY - (ya + t * dy))- dmin = np.minimum(dmin, d)- return inside, dmin---def fmt(v):- return num(v)
reference/python/fixture.pydeleted−688@@ -1,688 +0,0 @@-#!/usr/bin/env python3-"""Build the placement demo fixture from a routed, placed KiCad 10 board.-- python3 tools/make_placement_fixture.py BOARD.kicad_pcb [--out DIR] [--gap 2] [--margin 5]--Writes two copies beside each other (default: next to the input):-- <name>-unplaced.kicad_pcb every footprint moved out of the board outline into a parking- grid to the right of it (rows sorted by reference, 2 mm gaps,- rotation 0, side kept), every segment, arc and via deleted,- zones kept with their fills dropped- <name>-unrouted.kicad_pcb the original placement, only the copper and the fills stripped--The file is edited in place at the byte level: a small s-expression tokenizer records where-every node starts and ends, and only the nodes that change are rewritten, so everything-else (indentation, number formatting, uuids, the setup block) is byte for byte the input.-Teardrop zones (KiCad's generated copper around pads and vias, `(attr (teardrop ...))`)-belong to the copper that is removed and are dropped too unless --keep-teardrops.--Refuses a board with no Edge.Cuts outline: there is nowhere to park relative to.-Python 3, standard library only. `--self-test` runs the built-in checks.-"""-import argparse-import json-import math-import re-import sys-from pathlib import Path--GAP_MM = 2.0-MARGIN_MM = 5.0-COURTYARD_FALLBACK_MM = 0.25-COPPER_TAGS = ("segment", "via", "arc")-ANGLED_CHILDREN = ("pad", "property", "fp_text")---# ----------------------------------------------------------------------------- s-expressions--class Node:- """A list node: tag, children (Node or Atom), and its [start, end) span in the text."""- __slots__ = ("start", "end", "items")-- def __init__(self, start):- self.start = start- self.end = None- self.items = []-- @property- def tag(self):- first = self.items[0] if self.items else None- return first.text if isinstance(first, Atom) else None-- def find(self, tag):- for c in self.items:- if isinstance(c, Node) and c.tag == tag:- return c- return None-- def find_all(self, tag):- return [c for c in self.items if isinstance(c, Node) and c.tag == tag]-- def atom(self, i):- c = self.items[i] if i < len(self.items) else None- return c.value if isinstance(c, Atom) else None-- def value(self):- return self.atom(1)-- def number(self, i):- v = self.atom(i)- try:- return float(v)- except (TypeError, ValueError):- return None---class Atom:- __slots__ = ("text", "start", "end")-- def __init__(self, text, start, end):- self.text, self.start, self.end = text, start, end-- @property- def value(self):- """The atom without quotes, escapes resolved (only what KiCad emits)."""- if self.text.startswith('"') and self.text.endswith('"') and len(self.text) >= 2:- return self.text[1:-1].replace('\\"', '"').replace("\\\\", "\\")- return self.text---def parse(text):- """Parse the first top-level list. Every node keeps its exact span in `text`."""- n = len(text)- i = 0- root = None- stack = []- while i < n:- c = text[i]- if c == "(":- node = Node(i)- if stack:- stack[-1].items.append(node)- stack.append(node)- i += 1- elif c == ")":- if not stack:- raise ValueError("unbalanced ')' at offset %d" % i)- node = stack.pop()- node.end = i + 1- if not stack:- root = node- break- i += 1- elif c == '"':- j = i + 1- while j < n:- if text[j] == "\\":- j += 2- continue- if text[j] == '"':- break- j += 1- if j >= n:- raise ValueError("unterminated string at offset %d" % i)- stack[-1].items.append(Atom(text[i:j + 1], i, j + 1))- i = j + 1- elif c.isspace():- i += 1- else:- j = i- while j < n and not text[j].isspace() and text[j] not in "()":- j += 1- if not stack:- raise ValueError("atom outside any list at offset %d" % i)- stack[-1].items.append(Atom(text[i:j], i, j))- i = j- if root is None:- raise ValueError("no complete top-level list")- return root---def num(v):- """KiCad-style trimmed float: 1.5, not 1.500000; -0 is 0."""- s = ("%.6f" % v).rstrip("0").rstrip(".")- if s in ("", "-", "-0"):- return "0"- return s---def normalize_deg(d):- r = math.fmod(d, 360.0)- if r <= -180.0:- r += 360.0- if r > 180.0:- r -= 360.0- return 0.0 if r == 0 else r---def ref_key(ref):- letters = re.match(r"[^0-9]*", ref).group(0)- rest = ref[len(letters):]- digits = re.match(r"[0-9]*", rest).group(0)- return (letters.upper(), int(digits) if digits else 0, rest[len(digits):])---def line_span(text, node):- """The span to cut for a whole-line node: from the start of its line through its newline."""- s = node.start- ls = text.rfind("\n", 0, s) + 1- whole_line = not text[ls:s].strip()- if not whole_line:- ls = s # something else on the line before it: cut the node only- e = node.end- if whole_line:- if text[e:e + 2] == "\r\n":- e += 2- elif text[e:e + 1] == "\n":- e += 1- return ls, e---# ----------------------------------------------------------------------------- geometry--def points_of(node, coords=("start", "mid", "end", "center")):- pts = []- for tag in coords:- c = node.find(tag)- if c is not None:- x, y = c.number(1), c.number(2)- if x is not None and y is not None:- pts.append((x, y))- p = node.find("pts")- if p is not None:- for xy in p.find_all("xy"):- x, y = xy.number(1), xy.number(2)- if x is not None and y is not None:- pts.append((x, y))- return pts---def shape_extent(node):- """Vertices that bound one graphic shape (rect corners, circle extremes, arc points)."""- tag = node.tag or ""- if tag.endswith("rect"):- a, b = node.find("start"), node.find("end")- if a is None or b is None:- return []- ax, ay, bx, by = a.number(1), a.number(2), b.number(1), b.number(2)- return [(ax, ay), (bx, ay), (bx, by), (ax, by)]- if tag.endswith("circle"):- c, e = node.find("center"), node.find("end")- if c is None or e is None:- return []- cx, cy = c.number(1), c.number(2)- r = math.hypot(e.number(1) - cx, e.number(2) - cy)- return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)]- return points_of(node)---def bbox(points):- if not points:- return None- xs = [p[0] for p in points]- ys = [p[1] for p in points]- return (min(xs), min(ys), max(xs), max(ys))---def layer_of(node):- l = node.find("layer")- return l.value() if l is not None else None---def outline_bbox(root):- pts = []- for tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly"):- for node in root.find_all(tag):- if layer_of(node) == "Edge.Cuts":- pts.extend(shape_extent(node))- for fp in root.find_all("footprint"):- at = fp.find("at")- fx, fy = (at.number(1), at.number(2)) if at is not None else (0.0, 0.0)- for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):- for node in fp.find_all(tag):- if layer_of(node) == "Edge.Cuts":- pts.extend((x + fx, y + fy) for x, y in shape_extent(node))- return bbox(pts)---def courtyard_local(fp):- """The footprint's courtyard box in local coordinates at rotation 0, and its source."""- pts = []- for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):- for node in fp.find_all(tag):- if layer_of(node) in ("F.CrtYd", "B.CrtYd"):- pts.extend(shape_extent(node))- b = bbox(pts)- if b:- return b, "courtyard"- corners = []- for pad in fp.find_all("pad"):- at, size = pad.find("at"), pad.find("size")- if at is None:- continue- x, y = at.number(1), at.number(2)- w = (size.number(1) if size is not None else 0.0) / 2.0- h = (size.number(2) if size is not None else 0.0) / 2.0- corners.extend([(x - w, y - h), (x + w, y + h)])- b = bbox(corners)- if b:- m = COURTYARD_FALLBACK_MM- return (b[0] - m, b[1] - m, b[2] + m, b[3] + m), "pads+0.25mm"- return (-0.5, -0.5, 0.5, 0.5), "position"---def reference_of(fp):- for p in fp.find_all("property"):- if p.value() == "Reference":- return p.atom(2) or ""- return ""---# ----------------------------------------------------------------------------- the edit--def parking_grid(footprints, outline, gap=GAP_MM, margin=MARGIN_MM):- """Rows of footprints to the right of the outline, sorted by reference.-- footprints: list of (ref, courtyard_local_bbox). Returns {ref: (x, y)} footprint- origins and the parking bbox. Row width grows with the total area so the block stays- roughly square rather than one long strip."""- cells = sorted(footprints, key=lambda f: ref_key(f[0]))- area = sum((c[2] - c[0] + gap) * (c[3] - c[1] + gap) for _, c in cells)- row_width = max(40.0, math.ceil(math.sqrt(area) * 1.15))- x0 = outline[2] + margin- y0 = outline[1]- cursor_x, cursor_y, row_h = x0, y0, 0.0- origins = {}- pts = []- for ref, c in cells:- w, h = c[2] - c[0], c[3] - c[1]- if cursor_x > x0 and cursor_x + w > x0 + row_width:- cursor_x = x0- cursor_y += row_h + gap- row_h = 0.0- origins[ref] = (cursor_x - c[0], cursor_y - c[1])- pts.extend([(cursor_x, cursor_y), (cursor_x + w, cursor_y + h)])- cursor_x += w + gap- row_h = max(row_h, h)- return origins, bbox(pts)---def at_edit(at_node, x, y, angle, keep_angle_atom):- """Rewrite one (at ...) node. angle None keeps the node's own angle handling."""- parts = ["at", num(x), num(y)]- if angle is not None and (keep_angle_atom or angle != 0.0):- parts.append(num(angle))- return (at_node.start, at_node.end, "(" + " ".join(parts) + ")")---def plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones=False, strip_graphics=False, keep_refs=None):- """All edits for one copy. Returns (edits, summary)."""- edits = []- removed = {"segments": 0, "vias": 0, "arcs": 0, "filledPolygons": 0, "teardropZones": 0, "groupsDropped": 0, "groupMembersDropped": 0}- deleted_ids = set()- deleted_nodes = set()-- def uuid_of(node):- u = node.find("uuid")- return u.value() if u is not None else None-- for node in root.items:- if not isinstance(node, Node):- continue- if node.tag in COPPER_TAGS:- edits.append((*line_span(text, node), ""))- removed[{"segment": "segments", "via": "vias", "arc": "arcs"}[node.tag]] += 1- if uuid_of(node):- deleted_ids.add(uuid_of(node))- elif node.tag == "zone" and strip_zones:- edits.append((*line_span(text, node), ""))- deleted_nodes.add(id(node))- removed["zonesStripped"] = removed.get("zonesStripped", 0) + 1- if uuid_of(node):- deleted_ids.add(uuid_of(node))- continue- elif strip_graphics and node.tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly", "gr_text", "gr_text_box", "dimension", "image", "table") and layer_of(node) != "Edge.Cuts":- edits.append((*line_span(text, node), ""))- deleted_nodes.add(id(node))- removed["graphicsStripped"] = removed.get("graphicsStripped", 0) + 1- if uuid_of(node):- deleted_ids.add(uuid_of(node))- continue- elif node.tag == "zone" and not keep_teardrops:- attr = node.find("attr")- if attr is not None and attr.find("teardrop") is not None:- edits.append((*line_span(text, node), ""))- deleted_nodes.add(id(node))- removed["teardropZones"] += 1- if uuid_of(node):- deleted_ids.add(uuid_of(node))- continue-- def strip_fills(node):- for c in node.items:- if not isinstance(c, Node) or id(c) in deleted_nodes:- continue- if c.tag == "filled_polygon":- edits.append((*line_span(text, c), ""))- removed["filledPolygons"] += 1- elif c.tag in ("zone", "footprint"):- strip_fills(c)- strip_fills(root)-- outline = outline_bbox(root)- parked = 0- parking = None- fps = root.find_all("footprint")- import re as _re- keep_re = _re.compile(keep_refs) if keep_refs else None- kept_refs = []- if keep_re:- for fp in fps:- ref = reference_of(fp)- if keep_re.fullmatch(ref or ""):- kept_refs.append(ref)- if fp.find("locked") is None:- # insert (locked yes) right after the layer line of the footprint- layer = fp.find("layer")- if layer is not None:- st, en = line_span(text, layer)- edits.append((en, en, "\t\t(locked yes)\n"))- fps = [fp for fp in fps if not keep_re.fullmatch(reference_of(fp) or "")]- if park:- cells = []- for fp in fps:- cells.append((reference_of(fp), courtyard_local(fp)[0]))- origins, parking = parking_grid(cells, outline, gap, margin)- for fp in fps:- ref = reference_of(fp)- at = fp.find("at")- if at is None:- continue- rot = at.number(3) or 0.0- x, y = origins[ref]- edits.append(at_edit(at, x, y, None, False))- if rot != 0.0:- # Child angles are stored absolute (footprint angle included): keep each- # child's angle relative to its footprint when the footprint goes to 0.- for child in fp.items:- if isinstance(child, Node) and child.tag in ANGLED_CHILDREN:- cat = child.find("at")- if cat is None:- continue- has_angle = cat.atom(3) is not None- old = cat.number(3) or 0.0- edits.append(at_edit(cat, cat.number(1), cat.number(2), normalize_deg(old - rot), has_angle))- parked += 1-- # Groups that referenced removed copper: drop the dead members, drop empty groups.- for g in root.find_all("group"):- members = g.find("members")- if members is None:- continue- ids = [a for a in members.items[1:] if isinstance(a, Atom)]- dead = [a for a in ids if a.value in deleted_ids]- if not dead:- continue- if len(dead) == len(ids):- edits.append((*line_span(text, g), ""))- removed["groupsDropped"] += 1- else:- keep = [a.text for a in ids if a.value not in deleted_ids]- edits.append((members.start, members.end, "(members " + " ".join(keep) + ")"))- removed["groupMembersDropped"] += len(dead)-- summary = {- "footprints": len(fps), "footprintsParked": parked, "removed": removed,- "outline": {"minX": outline[0], "minY": outline[1], "maxX": outline[2], "maxY": outline[3]},-- "parking": None if parking is None else {"minX": round(parking[0], 3), "minY": round(parking[1], 3), "maxX": round(parking[2], 3), "maxY": round(parking[3], 3)},- }- summary["keptInPlace"] = kept_refs- return edits, summary---def apply_edits(text, edits):- edits = sorted(edits, key=lambda e: (e[0], e[1]))- for i in range(1, len(edits)):- if edits[i][0] < edits[i - 1][1]:- raise ValueError("overlapping edits at %d" % edits[i][0])- out = []- pos = 0- for s, e, rep in edits:- out.append(text[pos:s])- out.append(rep)- pos = e- out.append(text[pos:])- return "".join(out)---def make(text, park, gap=GAP_MM, margin=MARGIN_MM, keep_teardrops=False, strip_zones=False, strip_graphics=False, keep_refs=None):- root = parse(text)- if root.tag != "kicad_pcb":- raise ValueError("not a kicad_pcb file")- if outline_bbox(root) is None:- raise ValueError("no Edge.Cuts outline found; refusing (nothing to park relative to)")- edits, summary = plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones, strip_graphics, keep_refs)- return apply_edits(text, edits), summary---def main(argv=None):- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board", nargs="?", help="a .kicad_pcb (KiCad 10 s-expression)")- ap.add_argument("--out", help="output directory (default: beside the input)")- ap.add_argument("--gap", type=float, default=GAP_MM, help="gap between parked footprints, mm")- ap.add_argument("--margin", type=float, default=MARGIN_MM, help="gap between the outline and the parking grid, mm")- ap.add_argument("--keep-teardrops", action="store_true", help="keep teardrop zones (generated copper) instead of dropping them")- ap.add_argument("--strip-zones", action="store_true", help="drop every zone (the demo board starts with no copper pours at all)")- ap.add_argument("--keep-refs", help="regex of references that stay where they are and get locked (a molecule's machine pins and contacts, e.g. 'M[CP]\\d+')")- ap.add_argument("--strip-graphics", action="store_true", help="drop board-level graphics and text on every layer except Edge.Cuts (labels and logos that describe the original layout)")- ap.add_argument("--self-test", action="store_true")- a = ap.parse_args(argv)- if a.self_test:- return self_test()- if not a.board:- ap.error("board is required")- src = Path(a.board)- # newline="" keeps the file's own line endings (KiCad on Windows writes CRLF).- with open(src, "r", encoding="utf-8", newline="") as f:- text = f.read()- out_dir = Path(a.out) if a.out else src.parent- out_dir.mkdir(parents=True, exist_ok=True)- stem = src.name[: -len(".kicad_pcb")] if src.name.endswith(".kicad_pcb") else src.stem- report = {"source": str(src), "outputs": {}}- for kind, park in (("unplaced", True), ("unrouted", False)):- new_text, summary = make(text, park, a.gap, a.margin, a.keep_teardrops, a.strip_zones, a.strip_graphics, a.keep_refs)- dest = out_dir / ("%s-%s.kicad_pcb" % (stem, kind))- with open(dest, "w", encoding="utf-8", newline="") as f:- f.write(new_text)- parse(new_text) # the output must still be one balanced document- summary["path"] = str(dest)- summary["bytes"] = len(new_text.encode("utf-8"))- report["outputs"][kind] = summary- r = summary["removed"]- line = "%s: %d footprints" % (dest.name, summary["footprints"])- if park:- p = summary["parking"]- line += ", %d parked in x %s..%s y %s..%s" % (summary["footprintsParked"], num(p["minX"]), num(p["maxX"]), num(p["minY"]), num(p["maxY"]))- line += "; removed %d segments, %d vias, %d arcs, %d filled polygons, %d teardrop zones" % (- r["segments"], r["vias"], r["arcs"], r["filledPolygons"], r["teardropZones"])- if r.get("zonesStripped") or r.get("graphicsStripped"):- line += ", %d zones stripped, %d graphics stripped" % (r.get("zonesStripped", 0), r.get("graphicsStripped", 0))- if r["groupsDropped"] or r["groupMembersDropped"]:- line += ", %d groups dropped, %d group members dropped" % (r["groupsDropped"], r["groupMembersDropped"])- print(line)- o = report["outputs"]["unplaced"]["outline"]- print("outline bbox: x %s..%s y %s..%s (%s x %s mm); source %d bytes" % (- num(o["minX"]), num(o["maxX"]), num(o["minY"]), num(o["maxY"]), num(o["maxX"] - o["minX"]), num(o["maxY"] - o["minY"]), len(text.encode("utf-8"))))- (out_dir / ("%s-placement-fixture.json" % stem)).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")- return 0---# ----------------------------------------------------------------------------- self-test--SAMPLE = """(kicad_pcb-\t(version 20260206)-\t(generator "pcbnew")-\t(layers-\t\t(0 "F.Cu" signal)-\t\t(2 "B.Cu" signal)-\t)-\t(footprint "T:R2"-\t\t(layer "F.Cu")-\t\t(uuid "r2")-\t\t(at 120 110 90)-\t\t(property "Reference" "R2"-\t\t\t(at 0 -1.17 90)-\t\t\t(layer "F.SilkS")-\t\t)-\t\t(fp_rect-\t\t\t(start -1 -0.5)-\t\t\t(end 1 0.5)-\t\t\t(layer "F.CrtYd")-\t\t)-\t\t(pad "1" smd rect-\t\t\t(at -0.5 0 90)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "F.Cu")-\t\t\t(net "A")-\t\t)-\t\t(pad "2" smd rect-\t\t\t(at 0.5 0)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "F.Cu")-\t\t\t(net "B")-\t\t)-\t)-\t(footprint "T:R10"-\t\t(layer "B.Cu")-\t\t(uuid "r10")-\t\t(at 130 110)-\t\t(property "Reference" "R10"-\t\t\t(at 0 -1.17 0)-\t\t)-\t\t(pad "1" smd rect-\t\t\t(at -0.5 0)-\t\t\t(size 0.6 0.5)-\t\t\t(layers "B.Cu")-\t\t\t(net "A")-\t\t)-\t)-\t(gr_rect-\t\t(start 100 100)-\t\t(end 150 130)-\t\t(layer "Edge.Cuts")-\t\t(uuid "edge")-\t)-\t(segment-\t\t(start 119.5 110)-\t\t(end 129.5 110)-\t\t(width 0.2)-\t\t(layer "F.Cu")-\t\t(net "A")-\t\t(uuid "seg1")-\t)-\t(via-\t\t(at 125 110)-\t\t(size 0.6)-\t\t(drill 0.3)-\t\t(layers "F.Cu" "B.Cu")-\t\t(net "A")-\t\t(uuid "via1")-\t)-\t(zone-\t\t(net "GND")-\t\t(layer "F.Cu")-\t\t(uuid "z1")-\t\t(polygon-\t\t\t(pts-\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130) (xy 100 130)-\t\t\t)-\t\t)-\t\t(filled_polygon-\t\t\t(layer "F.Cu")-\t\t\t(pts-\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130)-\t\t\t)-\t\t)-\t)-\t(zone-\t\t(net "A")-\t\t(layer "F.Cu")-\t\t(uuid "td1")-\t\t(attr-\t\t\t(teardrop-\t\t\t\t(type padvia)-\t\t\t)-\t\t)-\t\t(filled_polygon-\t\t\t(layer "F.Cu")-\t\t\t(pts-\t\t\t\t(xy 1 1) (xy 2 2) (xy 3 3)-\t\t\t)-\t\t)-\t)-\t(group ""-\t\t(uuid "g1")-\t\t(members "seg1" "edge")-\t)-\t(group ""-\t\t(uuid "g2")-\t\t(members "seg1" "via1")-\t)-\t(embedded_fonts no)-)-"""---def self_test():- unplaced, s = make(SAMPLE, True)- root = parse(unplaced)- assert s["removed"] == {"segments": 1, "vias": 1, "arcs": 0, "filledPolygons": 1, "teardropZones": 1, "groupsDropped": 1, "groupMembersDropped": 1}, s- assert s["footprintsParked"] == 2- assert s["outline"] == {"minX": 100.0, "minY": 100.0, "maxX": 150.0, "maxY": 130.0}- assert not root.find_all("segment") and not root.find_all("via")- zones = root.find_all("zone")- assert len(zones) == 1 and zones[0].find("uuid").value() == "z1", "user zone kept, teardrop dropped"- assert not zones[0].find("filled_polygon") and zones[0].find("polygon") is not None- groups = root.find_all("group")- assert len(groups) == 1 and [a.value for a in groups[0].find("members").items[1:]] == ["edge"]- fps = {reference_of(fp): fp for fp in root.find_all("footprint")}- # R2 sorts before R10; both start at outline.maxX + 5 on the outline's top row.- r2, r10 = fps["R2"], fps["R10"]- assert r2.find("at").atom(3) is None, "rotation zeroed"- assert r2.find("at").number(1) == 156.0 and r2.find("at").number(2) == 100.5, unplaced- assert r10.find("at").number(1) > r2.find("at").number(1) + 2.0- assert layer_of(r10) == "B.Cu", "side kept"- # Child angles stay relative: the 90 absolute became 0, the missing angle became -90.- pads = r2.find_all("pad")- assert pads[0].find("at").atom(3) == "0", pads[0].find("at").atom(3)- assert pads[1].find("at").atom(3) == "-90", pads[1].find("at").atom(3)- assert r2.find_all("property")[0].find("at").atom(3) == "0"- assert r10.find_all("pad")[0].find("at").atom(3) is None, "untouched at rotation 0"- # Everything else is byte for byte the input.- assert "(generator \"pcbnew\")" in unplaced and "(embedded_fonts no)" in unplaced- assert unplaced.count("\t(footprint") == 2 and unplaced.endswith(")\n")- unrouted, s2 = make(SAMPLE, False)- assert s2["footprintsParked"] == 0 and s2["parking"] is None- assert "(at 120 110 90)" in unrouted and "(at 130 110)" in unrouted- assert "(segment" not in unrouted and "(via" not in unrouted and "filled_polygon" not in unrouted- kept, s3 = make(SAMPLE, False, keep_teardrops=True)- assert s3["removed"]["teardropZones"] == 0 and kept.count("(zone") == 2- try:- make("(kicad_pcb (version 20260206) (footprint \"X\" (at 1 1)))", True)- raise AssertionError("no outline must refuse")- except ValueError as e:- assert "Edge.Cuts" in str(e)- crlf, s4 = make(SAMPLE.replace("\n", "\r\n"), True)- assert "\n" not in crlf.replace("\r\n", "") and s4["removed"]["segments"] == 1, "CRLF files keep CRLF and lose no line"- assert crlf.count("\r\n") == unplaced.count("\n"), (crlf.count("\r\n"), unplaced.count("\n"))- assert num(1.5) == "1.5" and num(100.0) == "100" and num(-0.0) == "0" and num(0.1234567) == "0.123457"- assert normalize_deg(270) == -90 and normalize_deg(-270) == 90 and normalize_deg(180) == 180 and normalize_deg(360) == 0- assert ref_key("C2") < ref_key("C10") < ref_key("R1")- print("self-test ok")- return 0---if __name__ == "__main__":- sys.exit(main())
reference/python/land_moves.pydeleted−46@@ -1,46 +0,0 @@-#!/usr/bin/env python3-"""Land a placement adjustment on the live board: one kicad_move_footprint batch, one native undo step.--Usage: adjust_live.py --target ConfRoomROG --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" --moves moves.json- [--thread kicad-bridge-esc-routing] [--log adjust-log.json] [--dry-run]--moves.json is {REF: {"x", "y", "rotation"}} (what tools/pack_moves.py writes and tools/move_footprints.py-takes), so the offline fixture and the live board end up identical. The bridge refuses courtyard-overlaps and positions outside the outline and verifies every pose by read-back.-"""-import argparse, json, subprocess, sys, time--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--moves", required=True)-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="adjust-log.json")-ap.add_argument("--dry-run", action="store_true")-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]--def call(verb, args, timeout=240):- t0 = time.time()- out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout- try:- r = json.loads(out)- except Exception:- r = {"status": "parse_error", "raw": out[:400]}- r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))- return r--moves = json.load(open(a.moves))-st = call("kicad_placement_state", {"filePath": a.board})-if st.get("status") != "ok":- print("placement_state failed", json.dumps(st)[:600]); sys.exit(1)-rev = st["revision"]-refs = [{"ref": r, "x": m["x"], "y": m["y"], "rotation": m.get("rotation", 0)} for r, m in moves.items()]-args = {"filePath": a.board, "expectedRevision": rev, "refs": refs}-if a.dry_run:- args["dryRun"] = True-r = call("kicad_move_footprint", args)-print("move_footprint:", r.get("status"), r.get("errorCode") or "", "revision", str(r.get("revision", ""))[:8], r["_dt"], "s")-if r.get("status") != "ok":- print(json.dumps(r)[:1500])-v = call("kicad_placement_validate", {"filePath": a.board}) if not a.dry_run else {}-print("validate:", {k: v.get(k) for k in ("status", "overlaps", "outside", "ratsnestMm", "errors")})-json.dump({"start": {k: x for k, x in st.items() if k != "footprints"}, "moves": moves, "reply": r, "validate": v}, open(a.log, "w"), indent=1)-sys.exit(0 if r.get("status") == "ok" else 2)
reference/python/land_pours.pydeleted−81@@ -1,81 +0,0 @@-#!/usr/bin/env python3-"""Land a pours plan on the live board through kicad_add_zone, one undo step per group, then measure.--Usage: pours_live.py --target ConfRoomROG --board "C:/.../board.kicad_pcb" --pours pours.json- [--thread kicad-bridge-esc-routing] [--log pours-log.json] [--dry-run] [--pause 1.0]--The JSON is the tools/add_pours.py shape ({"zones": [...]}); "outline": true zones get the board-outline (inset by "inset" mm) resolved here from the local copy of the board given with --local,-because the verb takes explicit polygons. Zones are grouped by net into one kicad_add_zone call-each (one native undo step per net), lowest priority first. kicad_zone_state {refill:true} is read-before and after, so the log carries the copper kept per layer, the number this round is about.-"""-import argparse, json, subprocess, sys, time, os--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--pours", required=True)-ap.add_argument("--local", help="local copy of the board, for outline: true zones")-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="pours-log.json")-ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--pause", type=float, default=1.0)-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]---def call(verb, args, timeout=300):- t0 = time.time()- out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout- try:- r = json.loads(out)- except Exception:- r = {"status": "parse_error", "raw": out[:400]}- r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))- return r---spec = json.load(open(a.pours, encoding="utf-8"))-zones = spec["zones"] if isinstance(spec, dict) else spec-outline = None-if any(z.get("outline") for z in zones):- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))- - from escboard import Board, polygon_area- from pours import inset_polygon- b = Board(a.local)- outline = list(b.outline)- if polygon_area(outline) < 0:- outline.reverse()-groups = {}-for z in zones:- zz = {k: v for k, v in z.items() if k not in ("outline", "inset")}- if z.get("outline"):- zz["polygon"] = [[round(x, 3), round(y, 3)] for x, y in inset_polygon(outline, float(z.get("inset", 0.5)))]- groups.setdefault(z["net"], []).append(zz)-order = sorted(groups, key=lambda n: min(z.get("priority", 0) for z in groups[n]))--before = call("kicad_zone_state", {"filePath": a.board, "refill": True})-print("before:", before.get("filledAreaByLayerMm2"), before.get("filledCoverageByLayerPercent"))-st = call("kicad_routing_state", {"filePath": a.board})-rev = st.get("revision")-if not rev:- print("routing_state failed", json.dumps(st)[:600]); sys.exit(1)-log = {"before": {k: before.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount")}, "calls": []}-t_start = time.time()-for net in order:- args = {"filePath": a.board, "expectedRevision": rev, "zones": groups[net]}- if a.dry_run:- args["dryRun"] = True- r = call("kicad_add_zone", args)- ok = r.get("success") is True- print("%-10s %d zone(s): %s %s ids %s refilled %s %.1fs %s" % (net, len(groups[net]), "ok" if ok else "FAILED", r.get("errorCode") or "", len(r.get("itemIds") or []), r.get("refilled"), r["_dt"], (r.get("postCommitError") or "")[:80]))- log["calls"].append({"net": net, "zones": groups[net], "reply": {k: v for k, v in r.items() if k not in ("_hint",)}})- if not ok:- print(json.dumps(r)[:1500]); break- rev = r.get("revision") or rev- time.sleep(a.pause)-after = call("kicad_zone_state", {"filePath": a.board, "refill": True})-print("after:", after.get("filledAreaByLayerMm2"), after.get("filledCoverageByLayerPercent"))-v = call("kicad_routing_validate", {"filePath": a.board}) if not a.dry_run else {}-print("validate:", {k: v.get(k) for k in ("errors", "warnings", "unconnected", "clean", "zonesRefilled")})-log["after"] = {k: after.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount", "zones")}-log["validate"] = v; log["wall_seconds"] = round(time.time() - t_start, 1)-json.dump(log, open(a.log, "w"), indent=1)
reference/python/land_route.pydeleted−95@@ -1,95 +0,0 @@-#!/usr/bin/env python3-"""Replay a routing plan on the live board through the bridge, one kicad_route_net per entry.--Usage: route_live.py --target arav-rog --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" \\- --plan fable-esc-routing-plan.json [--pause 0.6] [--thread "kicad-bridge-esc-routing"] \\- [--log route-log.json] [--dry-run] [--start N] [--save]--Mirrors demo/placement/fable/place_live.py: kicad_routing_state gives the revision, every-plan entry becomes one kicad_route_net call (one native Undo step, DRC-checked by the bridge-against a snapshot) with expectedRevision from the previous reply, and the reply's revision-feeds the next call. Every reply is logged with timestamps. A drc_rejected or stale_board-reply stops the run and is printed in full; kicad_routing_validate closes the run and the-whole log is written as JSON. --dry-run sends every call with dryRun:true (nothing lands, the-revision never changes). --start N skips the first N entries (resume after a stop).-"""-import argparse-import json-import subprocess-import sys-import time--ap = argparse.ArgumentParser()-ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--plan", required=True)-ap.add_argument("--pause", type=float, default=0.6)-ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="route-log.json")-ap.add_argument("--dry-run", action="store_true")-ap.add_argument("--start", type=int, default=0, help="skip the first N plan entries")-ap.add_argument("--save", action="store_true", help="ask the bridge to save the board after the last entry")-a = ap.parse_args()-T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]---def call(verb, args, timeout=240):- t0 = time.time()- out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout- try:- r = json.loads(out)- except Exception:- r = {"status": "parse_error", "raw": out[:400]}- r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))- return r---plan = json.load(open(a.plan, encoding="utf-8"))-entries = plan["nets"] if isinstance(plan, dict) else plan-entries = entries[a.start:]--st = call("kicad_routing_state", {"filePath": a.board})-if st.get("success") is False or st.get("status") == "error" or not st.get("revision"):- print("routing_state failed", json.dumps(st)[:800]); sys.exit(1)-rev = st["revision"]-print(f"start: revision {rev[:8]}, {len(st.get('pads', []))} pads, {len(st.get('segments', []))} segments, {len(st.get('vias', []))} vias on the board, "- f"{len(st.get('netsRemaining', []))} nets remaining; {len(entries)} plan entries{' (dry run)' if a.dry_run else ''}")-log = {"start": {k: v for k, v in st.items() if k not in ("pads",)}, "plan": a.plan, "board": a.board, "target": a.target,- "dryRun": a.dry_run, "entries": []}-t_start = time.time()-ok_count = 0-for i, e in enumerate(entries, 1):- args = {"filePath": a.board, "expectedRevision": rev, "net": e["net"], "width": e["width"]}- for k in ("viaSize", "viaDrill", "layer"):- if k in e:- args[k] = e[k]- if "paths" in e:- args["paths"] = e["paths"]- else:- args["points"] = e["points"]- if a.dry_run:- args["dryRun"] = True- if a.save and i == len(entries) and not a.dry_run:- args["save"] = True- r = call("kicad_route_net", args)- ok = r.get("success", True) is not False and r.get("status") != "error" and r.get("status") != "parse_error"- code = r.get("errorCode") or ""- drc = r.get("drc") or {}- n_items = len(r.get("itemIds") or []) if not a.dry_run else len(r.get("segments") or []) + len(r.get("vias") or [])- paths = len(e.get("paths") or [1])- print(f"{i}/{len(entries)} {e['net']:<26} w{e['width']:<5} {paths} path(s): {'ok' if ok else 'FAIL'} {code} "- f"items {n_items} drc errors {drc.get('errors')} unconnected {drc.get('unconnected')} rev {str(r.get('revision', ''))[:8]} {r['_dt']}s")- log["entries"].append({"index": i + a.start, "net": e["net"], "width": e["width"], "reply": {k: v for k, v in r.items() if k not in ("_hint", "_timeoutHint")}})- if not ok:- print(json.dumps(r)[:2000])- if code in ("drc_rejected", "stale_board") or not a.dry_run:- break- else:- ok_count += 1- if not a.dry_run:- rev = r.get("revision") or rev- time.sleep(a.pause)-v = call("kicad_routing_validate", {"filePath": a.board}, timeout=600)-log["validate"] = {k: val for k, val in v.items() if k not in ("_hint",)}-log["wall_seconds"] = round(time.time() - t_start, 1)-log["ok_entries"] = ok_count-print("validate:", {k: v.get(k) for k in ("status", "success", "errors", "warnings", "unconnected", "clean", "stale", "zonesRefilled")}, "wall", log["wall_seconds"], "s")-json.dump(log, open(a.log, "w"), indent=1)-print("log:", a.log)
reference/python/movefp.pydeleted−115@@ -1,115 +0,0 @@-#!/usr/bin/env python3-"""Move footprints in a .kicad_pcb file offline (the placement-adjustment step of the place-route loop).-- python3 tools/move_footprints.py BOARD.kicad_pcb --out MOVED.kicad_pcb --move REF=x,y[,rot] [--move ...]- [--json moves.json] [--check]--A footprint's pads, courtyard and text ride along with its `(at x y rot)`, so moving the anchor moves-the part. --json takes {"REF": {"x":..,"y":..,"rotation":..}, ...} (the shape kicad_move_footprint takes).-file's own geometry, so the plan can be gated before it goes anywhere near the live board (the bridge's-kicad_move_footprint does the exact check when the plan is landed).-"""-import argparse, json, math, re, sys--def footprint_blocks(text):- """Yield (start, end, ref) for every top-level (footprint ...) block."""- for m in re.finditer(r'\n\t\(footprint ', text):- s = m.start() + 1- depth = 0; i = s- while True:- c = text[i]- if c == '(':- depth += 1- elif c == ')':- depth -= 1- if depth == 0:- break- i += 1- block = text[s:i + 1]- r = re.search(r'\(property "Reference" "([^"]+)"', block)- yield s, i + 1, (r.group(1) if r else None)--def move_block(block, x, y, rot):- m = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', block)- if not m:- raise SystemExit("no footprint (at ...) found")- ox, oy, orot = float(m.group(1)), float(m.group(2)), float(m.group(3) or 0)- nrot = orot if rot is None else rot- drot = nrot - orot- new = "\n\t\t(at %s %s%s)" % (fmt(x), fmt(y), "" if nrot == 0 else " " + fmt(nrot))- block = block[:m.start()] + new + block[m.end():]- if abs(drot) > 1e-9:- # pads and text carry the footprint rotation in their own (at ...): rotate those too- def rot_at(mm):- a = float(mm.group(4) or 0) + drot- a = ((a + 180) % 360) - 180- return "(at %s %s%s)" % (mm.group(2), mm.group(3), "" if abs(a) < 1e-9 else " " + fmt(a))- # only the children (pads, properties, graphics: three tabs or deeper), never the footprint's own (at ...)- block = re.sub(r'(\n\t\t\t+)\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', lambda mm: mm.group(1) + rot_at(mm), block)- return block, (ox, oy, orot)--def fmt(v):- s = "%.6f" % v- s = s.rstrip("0").rstrip(".")- return s if s not in ("", "-0") else "0"--def main():- ap = argparse.ArgumentParser()- ap.add_argument("board"); ap.add_argument("--out", required=True)- ap.add_argument("--move", action="append", default=[], help="REF=x,y[,rot]")- ap.add_argument("--json", help="moves as {REF: {x, y, rotation}}")- ap.add_argument("--check", action="store_true")- a = ap.parse_args()- moves = {}- if a.json:- for ref, m in json.load(open(a.json)).items():- moves[ref] = (float(m["x"]), float(m["y"]), None if m.get("rotation") is None else float(m["rotation"]))- for mv in a.move:- ref, rest = mv.split("=", 1)- parts = [float(v) for v in rest.split(",")]- moves[ref] = (parts[0], parts[1], parts[2] if len(parts) > 2 else None)- text = open(a.board, encoding="utf-8").read()- out = []; last = 0; done = {}- for s, e, ref in footprint_blocks(text):- if ref in moves:- block, old = move_block(text[s:e], *moves[ref])- out.append(text[last:s]); out.append(block); last = e- done[ref] = old- out.append(text[last:])- text = "".join(out)- missing = sorted(set(moves) - set(done))- if missing:- raise SystemExit("not found: %s" % missing)- open(a.out, "w", encoding="utf-8").write(text)- for ref, (ox, oy, orot) in done.items():- x, y, rot = moves[ref]- print("moved %-6s (%.2f, %.2f, %g) -> (%.2f, %.2f, %s)" % (ref, ox, oy, orot, x, y, "same" if rot is None else "%g" % rot))- if a.check:- sys.path.insert(0, __import__("os").path.dirname(__import__("os").path.abspath(__file__)))- from escboard import Board- b = Board(a.out)- boxes = {}- for p in b.pads:- xs = [q[0] for poly in p.polys for q in poly]; ys = [q[1] for poly in p.polys for q in poly]- bx = boxes.setdefault(p.ref, [1e9, 1e9, -1e9, -1e9])- bx[0] = min(bx[0], min(xs)); bx[1] = min(bx[1], min(ys)); bx[2] = max(bx[2], max(xs)); bx[3] = max(bx[3], max(ys))- gap = 0.25 # courtyard-ish gap between pad boxes- refs = sorted(boxes)- bad = 0- for i, r1 in enumerate(refs):- for r2 in refs[i + 1:]:- A, B = boxes[r1], boxes[r2]- if A[0] - gap < B[2] and B[0] - gap < A[2] and A[1] - gap < B[3] and B[1] - gap < A[3]:- if r1 in moves or r2 in moves:- print("OVERLAP (pad boxes within %.2f mm): %s %s" % (gap, r1, r2)); bad += 1- (x0, y0, x1, y1) = b.bbox- for ref in moves:- A = boxes.get(ref)- if A and (A[0] < x0 + 0.5 or A[1] < y0 + 0.5 or A[2] > x1 - 0.5 or A[3] > y1 - 0.5):- print("OUTSIDE: %s" % ref); bad += 1- print("check: %d problems" % bad)- return 2 if bad else 0--if __name__ == "__main__":- sys.exit(main())
reference/python/pack.pydeleted−136@@ -1,136 +0,0 @@-#!/usr/bin/env python3-"""Find legal spots for a few parts near where they should be (the placement-adjustment helper).-- python3 tools/pack_moves.py BOARD.kicad_pcb --wish wishes.json --out moves.json [--gap 0.4]--wishes.json: an ordered object {REF: {"x":.., "y":.., "rot":[0,90], "radius": 4.0, "keepout": [[x0,y0,x1,y1,margin], ...]}}.-For each part in order: candidates on a 0.1 mm grid around the wish, nearest first, each rotation in the-given order; the first whose courtyard clears every other part's courtyard (pad box + 0.25 mm when a part has none) by `gap` (or the part's own "gap"), stays `edge` inside the outline-bbox and avoids the keepout rectangles (grown by their margin) wins. Prints what it found; the output is-the shape kicad_move_footprint and tools/move_footprints.py take.-"""-import argparse, json, math, os, re, sys-from escboard import Board, rot_kicad # noqa: E402--def courtyard_local(text, ref):- """Local (unrotated, anchor-relative) F.CrtYd points of a footprint from the file text, or None."""- i = text.find('(property "Reference" "%s"' % ref)- if i < 0:- return None- s = text.rfind("\n\t(footprint ", 0, i)- depth = 0; j = s + 1- while True:- c = text[j]- if c == "(":- depth += 1- elif c == ")":- depth -= 1- if depth == 0:- break- j += 1- block = text[s:j + 1]- pts = []- for m in re.finditer(r"\((fp_line|fp_rect|fp_poly|fp_circle|fp_arc)\b(.*?)\n\t\t\)", block, re.S):- body = m.group(2)- if '(layer "F.CrtYd")' not in body and '(layer "B.CrtYd")' not in body:- continue- for xm in re.finditer(r"\((?:start|end|xy|center|mid) (-?[\d.]+) (-?[\d.]+)\)", body):- pts.append((float(xm.group(1)), float(xm.group(2))))- if m.group(1) == "fp_circle":- c = re.search(r"\(center (-?[\d.]+) (-?[\d.]+)\)", body); e = re.search(r"\(end (-?[\d.]+) (-?[\d.]+)\)", body)- if c and e:- r = math.hypot(float(e.group(1)) - float(c.group(1)), float(e.group(2)) - float(c.group(2)))- cx, cy = float(c.group(1)), float(c.group(2))- pts += [(cx - r, cy - r), (cx + r, cy + r)]- return pts or None---def pad_boxes(board):- boxes = {}- for p in board.pads:- xs = [q[0] for poly in p.polys for q in poly]; ys = [q[1] for poly in p.polys for q in poly]- bx = boxes.setdefault(p.ref, [1e9, 1e9, -1e9, -1e9])- bx[0] = min(bx[0], min(xs)); bx[1] = min(bx[1], min(ys)); bx[2] = max(bx[2], max(xs)); bx[3] = max(bx[3], max(ys))- return boxes--def rel_box(board, ref):- """The part's pad box relative to its anchor at rotation 0 (undo the file rotation)."""- fp = board.footprints[ref]- pts = []- for p in board.pads:- if p.ref != ref:- continue- for poly in p.polys:- for (x, y) in poly:- pts.append(rot_kicad(x - fp["x"], y - fp["y"], -(fp["rot"] or 0)))- return pts--def placed_box(pts, x, y, rot):- rp = [rot_kicad(dx, dy, rot) for dx, dy in pts]- xs = [x + a for a, _ in rp]; ys = [y + c for _, c in rp]- return [min(xs), min(ys), max(xs), max(ys)]--def clear(box, others, gap):- for o in others:- if box[0] - gap < o[2] and o[0] - gap < box[2] and box[1] - gap < o[3] and o[1] - gap < box[3]:- return False- return True--def main():- ap = argparse.ArgumentParser()- ap.add_argument("board"); ap.add_argument("--wish", required=True); ap.add_argument("--out", required=True)- ap.add_argument("--gap", type=float, default=0.1); ap.add_argument("--edge", type=float, default=1.0)- a = ap.parse_args()- b = Board(a.board)- text = open(a.board, encoding="utf-8").read()- boxes = pad_boxes(b)- crt = {}- for ref, fp in b.footprints.items():- pts = courtyard_local(text, ref)- if pts:- crt[ref] = pts- boxes[ref] = placed_box(pts, fp["x"], fp["y"], fp["rot"] or 0)- else:- bx = boxes.get(ref)- if bx:- boxes[ref] = [bx[0] - 0.25, bx[1] - 0.25, bx[2] + 0.25, bx[3] + 0.25]- wishes = json.load(open(a.wish))- x0, y0, x1, y1 = b.bbox- out = {}- for ref, w in wishes.items():- pts = crt.get(ref) or [(dx - 0.25, dy - 0.25) for dx, dy in rel_box(b, ref)] + [(dx + 0.25, dy + 0.25) for dx, dy in rel_box(b, ref)]- others = [bx for r, bx in boxes.items() if r != ref]- keep = w.get("keepout", [])- best = None- R = w.get("radius", 4.0)- cands = []- n = int(R / 0.1)- for i in range(-n, n + 1):- for j in range(-n, n + 1):- d = math.hypot(i, j) * 0.1- if d <= R:- cands.append((d, round(w["x"] + i * 0.1, 2), round(w["y"] + j * 0.1, 2)))- cands.sort()- for d, x, y in cands:- for rot in w.get("rot", [0]):- box = placed_box(pts, x, y, rot)- if box[0] < x0 + a.edge or box[1] < y0 + a.edge or box[2] > x1 - a.edge or box[3] > y1 - a.edge:- continue- if not clear(box, others, w.get("gap", a.gap)):- continue- if any(box[0] - m < kx1 and kx0 - m < box[2] and box[1] - m < ky1 and ky0 - m < box[3] for (kx0, ky0, kx1, ky1, m) in keep):- continue- best = (x, y, rot, box, d)- break- if best:- break- if best is None:- print("%-5s NO SPOT within %.1f mm of (%.1f, %.1f)" % (ref, R, w["x"], w["y"])); continue- x, y, rot, box, d = best- boxes[ref] = box- out[ref] = {"x": x, "y": y, "rotation": rot}- print("%-5s -> (%.2f, %.2f, %g) %.2f mm from the wish box x %.2f..%.2f y %.2f..%.2f" % (ref, x, y, rot, d, box[0], box[2], box[1], box[3]))- json.dump(out, open(a.out, "w"), indent=1)--if __name__ == "__main__":- main()
reference/python/planes.pydeleted−121@@ -1,121 +0,0 @@-#!/usr/bin/env python3-"""Add full-outline copper planes to a copy of a KiCad 10 board.-- python3 tools/add_planes.py BOARD.kicad_pcb [--out FILE] [--plane GND:In1.Cu] [--plane +3V3:In2.Cu]- [--clearance 0.2] [--min-thickness 0.25] [--drc]--Default planes: GND on In1.Cu and +3V3 on In2.Cu. Each plane is one zone whose polygon is the-Edge.Cuts outline (lines as they are, arcs sampled to short chords), thermal-relief pad-connection with the given clearance, priority 0 (KiCad's default, so the line is not written),-fill enabled, `(filled_areas_thickness no)` and NO filled_polygon: KiCad fills it on load or-when a DRC refills zones. The net is written the way this board writes nets: KiCad 10 boards-carry `(net "NAME")` on every pad and no net table, so the zone gets `(net "NAME")`; a board-with a `(net N "NAME")` table gets `(net N)` plus `(net_name "NAME")`.--The zones are spliced in before the closing paren; every other byte of the file is untouched.-output and prints the error, warning and unconnected counts.-"""-import argparse-import json-import os-import subprocess-import sys-import tempfile-import uuid-from pathlib import Path--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from fixture import num # noqa: E402-from escboard import Board, polygon_area # noqa: E402---def zone_sexpr(board, net_name, layer, outline, clearance, min_thickness):- if board.net_format == "number":- number = next((n for n, name in board.net_table.items() if name == net_name), None)- if number is None:- raise KeyError("net %r is not in the net table" % net_name)- net_lines = "\t\t(net %d)\n\t\t(net_name \"%s\")\n" % (number, net_name.replace('"', '\\"'))- else:- net_lines = board.net_ref_sexpr(net_name)- pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in outline)- return (- "\t(zone\n"- + net_lines- + "\t\t(layer \"%s\")\n" % layer- + "\t\t(uuid \"%s\")\n" % uuid.uuid4()- + "\t\t(name \"%s plane\")\n" % net_name.replace('"', '\\"')- + "\t\t(hatch edge 0.5)\n"- + "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n" % num(clearance)- + "\t\t(min_thickness %s)\n" % num(min_thickness)- + "\t\t(filled_areas_thickness no)\n"- + "\t\t(fill yes\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n"- + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts- + "\t)"- )---def splice(text, blocks):- """Insert blocks before the closing paren of the kicad_pcb list, like the bridge's pcb::splice."""- trimmed = text.rstrip()- idx = trimmed.rfind(")")- body = text[:idx].rstrip()- nl = "\r\n" if "\r\n" in text[:2000] else "\n"- out = body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl- return out---def run_drc(path):- """service-kicad pcb drc as JSON: (errors, warnings, unconnected, report dict)."""- with tempfile.TemporaryDirectory() as tmp:- report = Path(tmp) / "drc.json"- proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],- capture_output=True, text=True, timeout=600)- if proc.returncode or not report.is_file():- raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-400:], proc.stderr[-400:]))- data = json.loads(report.read_text(encoding="utf-8"))- viol = data.get("violations", [])- errors = sum(1 for v in viol if v.get("severity") == "error")- warnings = sum(1 for v in viol if v.get("severity") == "warning")- return errors, warnings, len(data.get("unconnected_items", [])), data---def main(argv=None):- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board")- ap.add_argument("--out", help="output path (default: <board>-planes.kicad_pcb beside the input)")- ap.add_argument("--plane", action="append", help="NET:LAYER, repeatable (default GND:In1.Cu and +3V3:In2.Cu)")- ap.add_argument("--clearance", type=float, default=0.2)- ap.add_argument("--min-thickness", type=float, default=0.25)- ap.add_argument("--drc", action="store_true", help="run service-kicad pcb drc on the output and print the counts")- a = ap.parse_args(argv)- planes = [p.split(":", 1) for p in (a.plane or ["GND:In1.Cu", "+3V3:In2.Cu"])]- src = Path(a.board)- board = Board(str(src))- for net, layer in planes:- if net not in board.nets:- raise SystemExit("net %r has no pads on this board" % net)- if layer not in board.copper_layers:- raise SystemExit("layer %r is not a copper layer of this board (%s)" % (layer, board.copper_layers))- outline = list(board.outline)- if polygon_area(outline) < 0:- outline.reverse()- blocks = [zone_sexpr(board, net, layer, outline, a.clearance, a.min_thickness) for net, layer in planes]- out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-planes.kicad_pcb")- text = splice(board.text, blocks)- out.write_text(text, encoding="utf-8", newline="")- Board(str(out)) # must still parse as one balanced document- print("%s: %d zones (%s), %d outline points, net format %s, %d bytes" % (- out, len(planes), ", ".join("%s on %s" % (n, l) for n, l in planes), len(outline), board.net_format, len(text.encode("utf-8"))))- if a.drc:- errors, warnings, unconnected, data = run_drc(out)- by_type = {}- for v in data.get("violations", []):- by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1- print("drc: %d errors, %d warnings, %d unconnected items; by type %s (KiCad %s)" % (- errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version")))- return 0---if __name__ == "__main__":- sys.exit(main())
reference/python/pourplan.pydeleted−157@@ -1,157 +0,0 @@-"""A pours plan from the spec, not from the AI's memory of one board.--spec.pours is a list of entries; each becomes zones in the kicad_add_zone shape:- {"net": "GND", "outline": true, "layers": ["F.Cu","B.Cu"], "priority": 0, "connection": "thermal",- "thermalGap": 0.3, "thermalBridge": 0.35}- {"net": "+VBAT", "around": ["MC1","C2","C3","D1"], "margin": 1.0, "layers": ["F.Cu","B.Cu"], "priority": 2,- "connection": "solid", "exclude": ["U4"]} # the box around those parts' pads, grown by margin,- # clipped so it does not reach the excluded parts' boxes- {"net": "/DRV_SHA", "around": ["Q1.1","Q2.5","MC5"], "margin": 0.6, "layers": ["F.Cu"], "priority": 3, "connection": "solid"}- {"net": "+VBAT", "polygon": [[x,y],...], "layers": ["B.Cu"], "priority": 2} # explicit-spec.solidAt: ["C45.2"] pads that get a small solid patch (starved thermal reliefs)-spec.thermalVias: [{"pad": "Q1.5", "net": "+VBAT", "count": 2, "size": 0.8, "drill": 0.4}]-spec.kelvin: {"U6.3": "R32.2", ...} taps get pour keepouts along the routed tap (from the routing plan's kelvin entries)-"""-import json-import math-import os-import sys--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board # noqa: E402---def pad_box(pads):- xs = [q[0] for p in pads for poly in p.polys for q in poly]- ys = [q[1] for p in pads for poly in p.polys for q in poly]- return [min(xs), min(ys), max(xs), max(ys)]---def rect(x0, y0, x1, y1):- return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]---def select(board, keys):- out = []- for k in keys:- if "." in k:- ref, name = k.split(".", 1)- out += [p for p in board.pads if p.ref == ref and p.name == name]- else:- out += [p for p in board.pads if p.ref == k]- return out---def build(board, spec, plan=None):- x0, y0, x1, y1 = board.bbox- edge = float(spec.get("edgeClearance", 0.5))- zones = []- for e in spec.get("pours", []):- z = {k: v for k, v in e.items() if k not in ("around", "margin", "exclude", "polygon", "outline", "inset")}- z.setdefault("clearance", spec.get("clearance", 0.2)); z.setdefault("minThickness", 0.25)- if e.get("outline"):- z["outline"] = True; z["inset"] = float(e.get("inset", edge))- elif e.get("polygon"):- z["polygon"] = e["polygon"]- elif e.get("around"):- pads = select(board, e["around"])- if not pads:- raise SystemExit("pour %s: none of %s found on the board" % (e.get("net"), e["around"]))- m = float(e.get("margin", 0.8))- bx = pad_box(pads)- bx = [max(x0 + edge, bx[0] - m), max(y0 + edge, bx[1] - m), min(x1 - edge, bx[2] + m), min(y1 - edge, bx[3] + m)]- for ex in e.get("exclude", []):- xb = pad_box(select(board, [ex]))- # clip the pour box away from the excluded part on the side where they overlap least- if not (xb[2] < bx[0] or xb[0] > bx[2] or xb[3] < bx[1] or xb[1] > bx[3]):- cuts = [("x1", xb[0] - 1.0, bx[2] - (xb[0] - 1.0)), ("x0", xb[2] + 1.0, (xb[2] + 1.0) - bx[0]), ("y1", xb[1] - 1.0, bx[3] - (xb[1] - 1.0)), ("y0", xb[3] + 1.0, (xb[3] + 1.0) - bx[1])]- side, val, _ = min(cuts, key=lambda c: c[2])- {"x0": lambda: bx.__setitem__(0, val), "y0": lambda: bx.__setitem__(1, val), "x1": lambda: bx.__setitem__(2, val), "y1": lambda: bx.__setitem__(3, val)}[side]()- z["polygon"] = rect(*bx)- else:- raise SystemExit("pour %s needs outline, polygon or around" % e.get("net"))- z.setdefault("name", "%s pour" % z.get("net"))- zones.append(z)- for key in spec.get("solidAt", []):- pads = select(board, [key])- if pads:- p = pads[0]- zones.append({"net": p.net, "layers": ["F.Cu" if "F.Cu" in p.layers else "B.Cu"], "polygon": rect(p.x - 0.9, p.y - 0.9, p.x + 0.9, p.y + 0.9), "priority": 1, "connection": "solid",- "clearance": spec.get("clearance", 0.2), "minThickness": 0.25, "name": "%s solid joint at %s" % (p.net, key)})- vias = []- blockers = []- if plan:- for e in plan.get("nets", []):- for path in e.get("paths", []):- prev = None- for w in path:- if isinstance(w, str):- prev = None; continue- if isinstance(w, dict) and "x" not in w:- continue- pt = (w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1])- if prev is not None:- blockers.append((prev, pt, e["net"], float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.45))- prev = pt-- def clear(x, y, net):- for (ax, ay), (bx, by), n, need in blockers:- if n == net:- continue- dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy- t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))- if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:- return False- return True-- for tv in spec.get("thermalVias", []):- pads = select(board, [tv["pad"]])- if not pads:- continue- p = pads[0]; net = tv.get("net", p.net); n = int(tv.get("count", 2)); placed = 0- for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)):- if placed >= n:- break- x, y = round(p.x + dx, 2), round(p.y + dy, 2)- if abs(dx) < p.half_long - 0.4 and abs(dy) < p.half_short - 0.4 or (dx, dy) == (0.0, 0.0):- if clear(x, y, net):- vias.append({"net": net, "width": 0.5, "viaSize": tv.get("size", 0.8), "viaDrill": tv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]}); placed += 1- for sv in spec.get("stitchVias", []):- x, y = sv["x"], sv["y"]- if clear(x, y, sv["net"]):- vias.append({"net": sv["net"], "width": 0.5, "viaSize": sv.get("size", 0.8), "viaDrill": sv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]})- keepouts = 0- if plan and spec.get("kelvin"):- for e in plan.get("nets", []):- if not e.get("kelvin"):- continue- for path in e["paths"]:- pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]- for (ax, ay), (bx, by) in zip(pts, pts[1:]):- dx, dy = bx - ax, by - ay; n = math.hypot(dx, dy)- if n < 0.05:- continue- ux, uy = dx / n, dy / n; w = float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.1- poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)], [round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]- zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True}, "name": "Kelvin tap keepout %s" % e["net"]})- keepouts += 1- return zones, vias, keepouts---def main():- import argparse- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board"); ap.add_argument("--spec", required=True); ap.add_argument("--plan"); ap.add_argument("--out", required=True); ap.add_argument("--vias")- a = ap.parse_args()- board = Board(a.board); spec = json.load(open(a.spec)); plan = json.load(open(a.plan)) if a.plan else None- zones, vias, keepouts = build(board, spec, plan)- json.dump({"zones": zones}, open(a.out, "w"), indent=1)- if a.vias:- json.dump({"engine": "adom-boardflow pour plan (thermal and stitching vias)", "nets": vias}, open(a.vias, "w"), indent=1)- print("%d zones (%d keepouts), %d vias -> %s" % (len(zones), keepouts, len(vias), a.out))- for z in zones:- print(" %-10s %-12s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %s %s %s" % (z.get("priority", 0), z.get("connection", "thermal"), "outline" if z.get("outline") else "%d pts" % len(z["polygon"]))))---if __name__ == "__main__":- main()
reference/python/pours.pydeleted−167@@ -1,167 +0,0 @@-#!/usr/bin/env python3-"""Add copper pours (zones) to a copy of a KiCad board offline, from the same JSON kicad_add_zone takes.-- python3 tools/add_pours.py BOARD.kicad_pcb --pours pours.json [--out FILE] [--drc] [--baseline BOARD.kicad_pcb]--pours.json is {"zones": [ {"net": "+VBAT", "layers": ["F.Cu", "B.Cu"], "polygon": [[x, y], ...] | "outline": true,-"priority": 2, "clearance": 0.2, "minThickness": 0.25, "connection": "thermal" | "solid",-"thermalGap": 0.5, "thermalBridge": 0.5, "name": "..."}, ... ]}. Coordinates are mm, +y down, board frame.-"outline": true uses the Edge.Cuts outline (arcs sampled) inset by the edge clearance; "inset": mm sets it.--The zones are written the way KiCad stores them (no filled polygons: KiCad fills them on load and the-shared DRC refills them), spliced before the closing paren; every other byte is untouched. --drc runs-service-kicad's DRC on the result and, with --baseline, reports NEW errors against the unrouted board.-This is the offline half of the kicad-copper-pours skill: gate the pours here, then land the same JSON-live with kicad_add_zone.-"""-import argparse-import json-import os-import sys-import uuid-from pathlib import Path--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from fixture import num # noqa: E402-from escboard import Board, polygon_area # noqa: E402-from planes import splice, run_drc # noqa: E402---def inset_polygon(poly, d):- """Shrink a convex-ish outline by d toward its centroid (good enough for a rectangle with rounded corners)."""- if d <= 0:- return list(poly)- cx = sum(p[0] for p in poly) / len(poly)- cy = sum(p[1] for p in poly) / len(poly)- out = []- for (x, y) in poly:- vx, vy = x - cx, y - cy- n = (vx * vx + vy * vy) ** 0.5 or 1.0- out.append((x - vx / n * d, y - vy / n * d))- return out---def keepout_sexpr(board, z):- """A rule area: no net, the layers, the keepout flags (copperPour by default), the outline."""- k = z.get("keepout") or {"copperPour": True}- layers = z.get("layers") or [z["layer"]]- layer_line = "\t\t(layer \"%s\")\n" % layers[0] if len(layers) == 1 else "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)- a = lambda b: "not_allowed" if b else "allowed"- pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in z["_polygon"])- return (- "\t(zone\n"- + ("\t\t(net 0)\n\t\t(net_name \"\")\n" if board.net_format == "number" else "")- + layer_line- + "\t\t(uuid \"%s\")\n" % uuid.uuid4()- + "\t\t(name \"%s\")\n" % z.get("name", "keepout").replace('"', '\\"')- + "\t\t(hatch edge 0.5)\n"- + "\t\t(keepout\n\t\t\t(tracks %s)\n\t\t\t(vias %s)\n\t\t\t(pads %s)\n\t\t\t(copperpour %s)\n\t\t\t(footprints %s)\n\t\t)\n" % (a(k.get("tracks")), a(k.get("vias")), a(k.get("pads")), a(k.get("copperPour")), a(k.get("footprints")))- + "\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n"- + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts- + "\t)"- )---def zone_sexpr(board, z):- if z.get("type") == "keepout" or z.get("keepout"):- return keepout_sexpr(board, z)- net_name = z["net"]- if board.net_format == "number":- number = next((n for n, name in board.net_table.items() if name == net_name), None)- if number is None:- raise KeyError("net %r is not in the net table" % net_name)- net_lines = "\t\t(net %d)\n\t\t(net_name \"%s\")\n" % (number, net_name.replace('"', '\\"'))- else:- net_lines = board.net_ref_sexpr(net_name)- layers = z.get("layers") or [z["layer"]]- if len(layers) == 1:- layer_line = "\t\t(layer \"%s\")\n" % layers[0]- else:- layer_line = "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)- poly = z["_polygon"]- pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in poly)- solid = z.get("connection", "thermal") in ("solid", "full")- prio = int(z.get("priority", 0) or 0)- return (- "\t(zone\n"- + net_lines- + layer_line- + "\t\t(uuid \"%s\")\n" % uuid.uuid4()- + "\t\t(name \"%s\")\n" % z.get("name", "%s pour" % net_name).replace('"', '\\"')- + "\t\t(hatch edge 0.5)\n"- + ("\t\t(priority %d)\n" % prio if prio > 0 else "")- + ("\t\t(connect_pads yes\n\t\t\t(clearance %s)\n\t\t)\n" if solid else "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n") % num(float(z.get("clearance", 0.2)))- + "\t\t(min_thickness %s)\n" % num(float(z.get("minThickness", 0.25)))- + "\t\t(filled_areas_thickness no)\n"- + "\t\t(fill yes\n\t\t\t(thermal_gap %s)\n\t\t\t(thermal_bridge_width %s)\n\t\t)\n" % (num(float(z.get("thermalGap", 0.5))), num(float(z.get("thermalBridge", 0.5))))- + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts- + "\t)"- )---def main(argv=None):- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board")- ap.add_argument("--pours", required=True, help="JSON: {zones: [...]} (the kicad_add_zone shape)")- ap.add_argument("--out")- ap.add_argument("--drc", action="store_true")- ap.add_argument("--baseline", help="board whose DRC errors are inherited (default: the input board)")- ap.add_argument("--edge", type=float, default=0.5, help="edge clearance used for outline: true")- a = ap.parse_args(argv)- src = Path(a.board)- board = Board(str(src))- spec = json.load(open(a.pours, encoding="utf-8"))- zones = spec["zones"] if isinstance(spec, dict) else spec- outline = list(board.outline)- if polygon_area(outline) < 0:- outline.reverse()- blocks = []- summary = []- for z in zones:- keep = z.get("type") == "keepout" or bool(z.get("keepout"))- if not keep and z["net"] not in board.nets:- raise SystemExit("net %r has no pads on this board" % z["net"])- for l in (z.get("layers") or [z["layer"]]):- if l not in board.copper_layers:- raise SystemExit("layer %r is not a copper layer (%s)" % (l, board.copper_layers))- if z.get("outline"):- poly = inset_polygon(outline, float(z.get("inset", a.edge)))- else:- poly = [(float(p[0]), float(p[1])) for p in z["polygon"]]- if len(poly) >= 2 and poly[0] == poly[-1]:- poly.pop()- if len(poly) < 3:- raise SystemExit("zone %s needs three or more points" % z["net"])- z["_polygon"] = poly- blocks.append(zone_sexpr(board, z))- summary.append("%s on %s (%.0f mm2, %s)" % (z.get("net") or "keepout", "+".join(z.get("layers") or [z["layer"]]), abs(polygon_area(poly)), "keepout" if keep else "priority %s, %s" % (z.get("priority", 0), z.get("connection", "thermal"))))- out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-pours.kicad_pcb")- text = splice(board.text, blocks)- out.write_text(text, encoding="utf-8", newline="")- Board(str(out))- print("%s: %d zones: %s" % (out, len(blocks), "; ".join(summary)))- if a.drc:- errors, warnings, unconnected, data = run_drc(out)- by_type = {}- for v in data.get("violations", []):- if v.get("severity") == "error":- by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1- line = "drc: %d errors, %d warnings, %d unconnected; error types %s (KiCad %s)" % (errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version"))- if a.baseline:- b_err, _, _, b_data = run_drc(Path(a.baseline))- def sig(v):- return (v.get("type"), tuple(sorted((i.get("description") or "") for i in v.get("items", []))))- base = {sig(v) for v in b_data.get("violations", []) if v.get("severity") == "error"}- new = [v for v in data.get("violations", []) if v.get("severity") == "error" and sig(v) not in base]- line += "; %d NEW against the baseline (%d inherited)" % (len(new), errors - len(new))- for v in new[:20]:- print(" new:", v.get("type"), [(i.get("description") or "")[:60] for i in v.get("items", [])])- print(line)- return 0 if (not new and unconnected == 0) else 2- print(line)- return 0 if (errors == 0 and unconnected == 0) else 2- return 0---if __name__ == "__main__":- sys.exit(main())
reference/python/pours_esc_example.pydeleted−182@@ -1,182 +0,0 @@-#!/usr/bin/env python3-"""Claude Fable 5.1's copper-pour plan for the ESC G431, from the board's own pad geometry.-- python3 demo/routing/esc/pours_plan.py BOARD.kicad_pcb --out pours.json [--vias vias-plan.json]--Reads the placed board, finds the power stage (the six MOSFET tabs and source pads, the three-phase contacts, the +VBAT contact, the bulk capacitors, the shunt and the GND_OUT contact) and-writes the pours as the JSON kicad_add_zone takes (tools/add_pours.py gates it offline):-- * GND on F.Cu and B.Cu, whole outline, priority 0, thermal reliefs: the copper that stays- everywhere nothing else needs it (ablation), stitched to the In1.Cu plane by every through- pad and the existing stub vias.- * +VBAT, priority 2: the rail block (contact MC1, TVS, the bulk and ceramic capacitors, the- high-side tab of phase C) on F.Cu and B.Cu, plus a strip on B.Cu behind the MOSFET column- that reaches every high-side tab through thermal vias in the tabs. The gate driver and its- bootstrap nodes are left out of the rail pour on purpose (switching nodes beside gate drive).- * One pour per phase (/DRV_SHA, /DRV_SHB, /DRV_SHC), priority 3, solid connections: on F.Cu- from the high-side source pads over the low-side tab to the phase contact; on B.Cu only- under the low-side tab, so the +VBAT strip on B.Cu stays continuous. Compact by design:- these are the switching nodes.- * GND_OUT (the battery return through the shunt), priority 3, solid: from the return contact- MC3 to the shunt's GND_OUT pad, on both outer layers. The two Kelvin traces to the sense- amplifier stay outside every pour.--Not poured, deliberately: the bootstrap and gate-drive nets, the crystal and its capacitors, the-BEMF dividers and comparator inputs, /VBAT_SENSE and /IBAT_SENSE, +3V3 and /VDDA on the outer-layers (they have the In2.Cu plane), and anything under the MCU's escape bands.--route_live.py replay them): two 0.8/0.4 vias in each high-side tab (F.Cu tab to the B.Cu +VBAT-strip) and a row along the B.Cu strip.-"""-import argparse, json, math, os, sys--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board # noqa: E402--PHASES = {"/DRV_SHA": "MC5", "/DRV_SHB": "MC9", "/DRV_SHC": "MC11"}---def box(pads):- xs = [q[0] for p in pads for poly in p.polys for q in poly]- ys = [q[1] for p in pads for poly in p.polys for q in poly]- return [min(xs), min(ys), max(xs), max(ys)]---def rect(x0, y0, x1, y1):- return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]---def main():- ap = argparse.ArgumentParser()- ap.add_argument("board"); ap.add_argument("--out", required=True); ap.add_argument("--vias")- ap.add_argument("--avoid", help="routing plan JSON: a via candidate that would hit another net's copper is moved to the next spot in the tab; its Kelvin entries get pour keepouts")- ap.add_argument("--solid-at", action="append", default=[], help="REF.PAD that gets a small solid-connection patch (starved thermal relief)")- a = ap.parse_args()- b = Board(a.board)- pads = b.pads- x0, y0, x1, y1 = b.bbox- edge = 0.5- # the MOSFET column: high-side tab (+VBAT) and low-side tab (phase) per phase- q = {}- for p in pads:- if p.ref.startswith("Q") and p.name == "5":- q.setdefault(p.net, []).append(p)- hs_tabs = q.get("+VBAT", []) # three tabs, one per phase- zones = []- vias = []- # 1. GND everywhere, both outer layers, lowest priority- zones.append({"net": "GND", "layers": ["F.Cu", "B.Cu"], "outline": True, "inset": edge, "priority": 0, "connection": "thermal",- "clearance": 0.2, "minThickness": 0.25, "thermalGap": 0.3, "thermalBridge": 0.35, "name": "GND outer pour (ablation: keep the copper)"})- # pads where only one thermal spoke fits get a small solid patch (KiCad's rule wants two spokes or a solid joint)- for key in a.solid_at:- ref, name = key.split(".")- pd = next(p for p in pads if p.ref == ref and p.name == name)- zones.append({"net": pd.net, "layers": ["F.Cu"], "polygon": rect(pd.x - 0.9, pd.y - 0.9, pd.x + 0.9, pd.y + 0.9), "priority": 1, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "%s solid joint at %s (one spoke only)" % (pd.net, key)})- # 2. the rail: contact MC1, TVS D1, bulk caps C2/C3, the ceramic bank, down to the phase-C high-side tab- rail_parts = [p for p in pads if p.net == "+VBAT" and (p.ref.startswith("C") or p.ref in ("D1", "MC1"))]- rb = box(rail_parts)- col = box([p for p in pads if p.ref.startswith("Q")])- driver = box([p for p in pads if p.ref == "U4"])- rail_left = 127.5 # right of the buck (U2) and its SW node; the TVS is inside- rail_bottom = driver[1] - 1.0 # above the gate driver's band- strip_right = x1 - edge - 1.6- zones.append({"net": "+VBAT", "layers": ["F.Cu"], "polygon": rect(rail_left, y0 + edge, strip_right, rail_bottom), "priority": 2, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block F.Cu"})- # B.Cu: the rail block plus the strip behind the MOSFET column as ONE L-shaped zone (two zones of one- # net and one priority may not touch). The strip reaches every high-side tab through the thermal vias;- # the phase pours under the low-side tabs (priority 3) punch through it and the +VBAT copper flows around.- L = [[rail_left, y0 + edge], [strip_right, y0 + edge], [strip_right, col[3] + 1.0], [col[0] - 0.8, col[3] + 1.0], [col[0] - 0.8, rail_bottom], [rail_left, rail_bottom]]- zones.append({"net": "+VBAT", "layers": ["B.Cu"], "polygon": [[round(x, 2), round(y, 2)] for x, y in L], "priority": 2, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block and column strip B.Cu"})- # 3. one compact pour per phase- for net, contact in PHASES.items():- hs_src = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name in ("1", "2", "3")]- ls_tab = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name == "5"]- mc = [p for p in pads if p.ref == contact]- fb = box(hs_src + ls_tab + mc)- zones.append({"net": net, "layers": ["F.Cu"], "polygon": rect(fb[0] - 0.5, fb[1] - 0.6, x1 - edge, fb[3] + 0.6), "priority": 3, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "%s phase F.Cu" % net})- lb = box(ls_tab)- zones.append({"net": net, "layers": ["B.Cu"], "polygon": rect(lb[0] - 1.5, lb[1] - 0.3, lb[2] + 0.3, lb[3] + 0.6), "priority": 3, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "%s under the low-side tab B.Cu" % net})- # 4. the return through the shunt: contact MC3 to the shunt's GND_OUT pad- mc3 = [p for p in pads if p.ref == "MC3"]- r32 = [p for p in pads if p.ref == "R32" and p.net == "GND_OUT"]- gb = box(mc3 + r32)- zones.append({"net": "GND_OUT", "layers": ["F.Cu", "B.Cu"], "polygon": rect(gb[0] - 0.4, max(y0 + edge, gb[1] - 0.6), gb[2] + 0.4, gb[3] + 0.6), "priority": 3, "connection": "solid",- "clearance": 0.2, "minThickness": 0.25, "name": "GND_OUT return"})- # thermal vias in every high-side tab (two per tab, moved within the tab when the routed copper of- # another net passes under that spot on B.Cu), and two stitching vias along the B.Cu strip- blockers = []- if a.avoid:- for e in json.load(open(a.avoid))["nets"]:- if e["net"] == "+VBAT":- continue- for path in e["paths"]:- prev = None; layer = "F.Cu"- for w in path:- if isinstance(w, str):- prev = None; continue- if isinstance(w, dict):- if "x" not in w:- continue- layer = w.get("layer", layer); pt = (w["x"], w["y"])- else:- pt = (w[0], w[1])- if prev is not None:- blockers.append((prev, pt, e["width"] / 2.0 + 0.2 + 0.4))- prev = pt-- def clear(x, y):- for (ax, ay), (bx, by), need in blockers:- dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy- t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))- if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:- return False- return True-- for t in hs_tabs:- placed = 0- for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3)):- if placed >= 2:- break- x, y = round(t.x + dx, 2), round(t.y + dy, 2)- if clear(x, y):- vias.append({"net": "+VBAT", "x": x, "y": y}); placed += 1- strip_x = round(col[2] + 1.9, 2)- for yy in (90.0, 118.0):- if clear(strip_x, yy):- vias.append({"net": "+VBAT", "x": strip_x, "y": yy})- # pour keepouts around every Kelvin tap in the routing plan (both outer layers, the whole trace, not the pads)- if a.avoid:- for e in json.load(open(a.avoid))["nets"]:- if not e.get("kelvin"):- continue- for path in e["paths"]:- pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]- for (ax, ay), (bx, by) in zip(pts, pts[1:]):- dx, dy = bx - ax, by - ay- n = math.hypot(dx, dy)- if n < 0.05:- continue- ux, uy = dx / n, dy / n- w = 0.45 # half width of the keepout: trace half width 0.15 + clearance 0.2 + margin 0.1- poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)],- [round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]- zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True},- "name": "Kelvin tap keepout %s" % e["net"]})- json.dump({"zones": zones}, open(a.out, "w"), indent=1)- print("%d zones -> %s" % (len(zones), a.out))- for z in zones:- print(" %-10s %-12s %s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %d %s" % (z["priority"], z["connection"]), z.get("polygon") or "outline"))- if a.vias:- entries = [{"net": v["net"], "width": 0.5, "viaSize": 0.8, "viaDrill": 0.4, "paths": [[[v["x"], v["y"]], {"x": v["x"], "y": v["y"], "layer": "B.Cu"}]]} for v in vias]- json.dump({"engine": "Claude Fable 5.1 pours plan (thermal and stitching vias)", "nets": entries}, open(a.vias, "w"), indent=1)- print("%d vias -> %s" % (len(entries), a.vias))---if __name__ == "__main__":- main()
reference/python/router.pydeleted−1420@@ -1,1420 +0,0 @@-#!/usr/bin/env python3-"""adom-boardflow's routing engine: a grid maze router for any KiCad 10 board (born on the ESC G431).-- python3 demo/routing/esc/ai_router.py BOARD-planes.kicad_pcb [--out plan.json] [--report report.json]- [--nets NET,NET] [--no-planes] [--seed 0] [--verbose]--What it does- * 0.1 mm grid over the Edge.Cuts outline, two signal layers (F.Cu, B.Cu), through vias between- them (0.6 mm / 0.3 mm drill for signals, 0.8 mm / 0.4 mm on the wide power nets), KiCad's default rules for this board: 0.25 mm track, 0.2 mm- clearance, 0.5 mm copper-to-edge, plus every per-pad or per-footprint `(clearance X)` override.- * A* (octilinear, 8 neighbours) per connection. Costs: 10 per orthogonal cell, 14 diagonal,- x1.25 on B.Cu (prefer F.Cu), +400 per via (heavily penalised), +4 per direction change.- * Obstacles per layer for the net being routed: every pad and every committed segment/via of- another net, dilated by (that item's clearance + half the track width + grid slop); the- outline inset by the edge clearance; through pads on both layers. Own-net copper is free.- A diagonal step is allowed only when one of its ends also clears the larger dilation that- covers the midpoint of the step, so 45-degree segments never clip a corner.- * Narrow pads (fine-pitch IC pins) are entered on their long axis: a 0.2 mm stub from the exact- pad centre to an on-grid escape point beyond either pad end; the route starts there. Other- pads are hit at their centre. Same-net pads that overlap (MOSFET source pads) form one cluster.- * A net with N clusters is routed as a minimum spanning tree: Prim's order, each new cluster- routed to ANY copper the net already has (pads, tracks, vias), so branches land on their own- copper and junctions split the existing segment at an on-grid point.- * Order: nets by MST length, shortest first; plane-net stubs last.- * Widths: +VBAT and the phase nets (/DRV_SHA, /DRV_SHB, /DRV_SHC, which are also the MOSFET- drain and source nets) try 1.0 mm, then 0.5, then 0.25; +5V and +12V try 0.5 then 0.25;- everything else 0.25. A pad narrower than the width caps it (a 1.0 mm track never enters a- 0.35 mm pin). Fallback happens per connection when the wider route finds no path.- * GND and +3V3 are plane nets (In1.Cu and In2.Cu): no traces. Each SMD pad gets the shortest- stub to a spot where a via fits (Dijkstra to the first via-legal cell), through pads need- nothing. If no spot fits within 6 mm the via goes in the pad (reported).- * Rip-up and retry: when a connection fails on every width, a second search treats other nets'- tracks as expensive instead of solid; the nets it crosses are ripped up (most recently routed- first), the connection is routed again, and the ripped nets are re-queued. Bounded per net- and globally.--Output: a JSON plan, a list of kicad_route_net calls: {"net", "width", "viaSize", "viaDrill",-"paths": [[...], ...]} where a waypoint is "REF.PAD", [x, y] or {"x", "y", "layer": "B.Cu"}-(a layer marker = a via there). write_copper.py applies it offline, route_live.py replays it.-"""-import argparse-import heapq-import json-import math-import os-import sys-import time-from collections import defaultdict--import numpy as np--sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))-from escboard import Board, Grid, F_CU, B_CU, LAYER_NAMES, RES # noqa: E402--# ----------------------------------------------------------------------------- rules--RULES = {- "track": 0.25, "clearance": 0.2, "via": 0.8, "drill": 0.4, "edge": 0.5,- "stub": 0.2, # escape stub width for narrow pins- "narrow": 0.5, # pads with a short side under this get an axis escape- "escape": 0.45, # escape point this far beyond the pad end (grows if blocked)- "corridor": 0.5, # reserved own-net corridor beyond the escape point (other nets keep out)- "ic_zone": 2.0, # soft-cost band around a fine-pitch IC (routes should not run along a pin row)- "channel": 2.5, # no via on the line of another reserved pin escape this far beyond the pad- "planes": {"GND": "In1.Cu", "+3V3": "In2.Cu"},- "wide": {"+VBAT": [1.0, 0.5, 0.25], "/DRV_SHA": [1.0, 0.5, 0.25], "/DRV_SHB": [1.0, 0.5, 0.25], "/DRV_SHC": [1.0, 0.5, 0.25],- "GND_OUT": [1.0, 0.5, 0.25]}, # the battery return through the shunt carries the same current as +VBAT- "mid": {"+5V": [0.5, 0.25], "+12V": [0.5, 0.25]},- "via_small": 0.6, "drill_small": 0.3, # signal vias; the wide power nets keep 0.8 / 0.4- "hole_to_hole": 0.25, # KiCad's default hole-to-hole minimum: same-net vias may sit this close (drill edge to drill edge)- "via_search_mm": 6.0,- # Kelvin sense taps: sense pad -> shunt pad. The sense pad gets NO plane stub and no share of the- # net's wide copper; it gets one dedicated 0.25 mm trace on the pad's own layer to the shunt pad,- # kept clear of every other piece of the same net (a pour keepout protects it afterwards).- "kelvin": {},-}-SLOP = 0.05 * math.sqrt(2) + 1e-6 # half a cell diagonal: pads and stubs are off-grid-COST_ORTH, COST_DIAG, COST_VIA, COST_TURN = 10, 14, 250, 4-LAYER_MULT = {F_CU: 1.0, B_CU: 1.1}-SOFT_EXTRA = 80-ZONE_COST = 20 # per F.Cu cell inside a fine-pitch IC's escape band-VIA_WALL = 300 # per unrouted neighbour pin channel a via would wall in-HIST_COST = 12 # per cell per time a ripped-up route was there (contested corridors get pricier)-DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)]-STEP = [COST_ORTH] * 4 + [COST_DIAG] * 4-INF = 1 << 30---def log(*a):- print(*a, flush=True)---# ----------------------------------------------------------------------------- data--class Cluster:- """Pads of one net in one footprint whose copper overlaps: routed once."""-- def __init__(self, idx, pads):- self.idx = idx- self.pads = pads- self.primary = max(pads, key=lambda p: p.half_long * p.half_short)- self.terminals = [] # [(layer, i, j)] cells a route may start or end at- self.escapes = {} # (layer, i, j) -> escape point (x, y) exact grid coords (stub end)- self.corridors = {} # (layer, i, j) -> corridor end (x, y) beyond the escape point- self.connected = False- self.allow = 0.25-- @property- def xy(self):- return self.primary.x, self.primary.y-- @property- def key(self):- return self.primary.key---class Route:- def __init__(self, cells, width, start, end):- self.cells = cells # [(layer, i, j)]- self.width = width- self.start = start # ("pad", Cluster, term) | ("escape", Cluster, term)- self.end = end # ("pad", Cluster, term) | ("escape", ...) | ("copper",) | ("via",)- self.breaks = set() # indices into cells where a segment must end (junctions)- self.via_end = False # a via at the last cell (plane stub, or a via in the target pad)- self.via_end_layer = "B.Cu" # the layer the via switches to- self.kelvin = False # a dedicated sense tap (kept apart from its own net; pour keepout later)---class NetState:- def __init__(self, idx, name, pads):- self.idx, self.name, self.pads = idx, name, pads- self.clusters = []- self.routes = []- self.widths = [RULES["track"]]- self.plane = RULES["planes"].get(name)- self.mst_len = 0.0- self.used_escapes = set() # (cluster idx, terminal)- self.failed = [] # [(cluster key, reason)]- self.rips = 0- self.routed_order = None- self.via_in_pad = []- self.last_soft = []- self.via = RULES["via_small"]- self.drill = RULES["drill_small"]---def _seg_distance(x, y, ax, ay, bx, by):- dx, dy = bx - ax, by - ay- L2 = dx * dx + dy * dy- if L2 < 1e-12:- return math.hypot(x - ax, y - ay)- t = max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L2))- return math.hypot(x - (ax + t * dx), y - (ay + t * dy))---def _point_poly_distance(x, y, poly):- """0 inside the polygon, else the distance to its boundary."""- inside = False- n = len(poly)- best = float("inf")- for k in range(n):- ax, ay = poly[k]- bx, by = poly[(k + 1) % n]- if (ay > y) != (by > y):- xint = ax + (y - ay) * (bx - ax) / (by - ay)- if x < xint:- inside = not inside- d = _seg_distance(x, y, ax, ay, bx, by)- if d < best:- best = d- return 0.0 if inside else best---def _route_segments(g, cells):- """Straight runs of a cell path as exact segments (x0, y0, x1, y1), vias skipped."""- out = []- k = 0- n = len(cells)- while k < n - 1:- L, i0, j0 = cells[k]- m = k + 1- if cells[m][0] != L:- k = m- continue- di, dj = cells[m][1] - i0, cells[m][2] - j0- while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):- m += 1- a = g.to_xy(i0, j0)- b = g.to_xy(cells[m][1], cells[m][2])- out.append((a[0], a[1], b[0], b[1]))- k = m- return out---# ----------------------------------------------------------------------------- router--class Router:- def __init__(self, board, verbose=False):- self.board = board- self.verbose = verbose- self.grid = Grid(board.bbox)- g = self.grid- self.H, self.W = g.H, g.W- self.nets = {}- self.net_ids = {}- for k, (name, pads) in enumerate(sorted(board.nets.items())):- self.net_ids[name] = k- self.nets[name] = NetState(k, name, pads)- # raw copper by layer: pads (+ reserved stubs) and routes, net id or -1- self.pad_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}- self.pad_owner = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}- self.route_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}- self.big_clr = {L: np.zeros((g.H, g.W), dtype=bool) for L in (F_CU, B_CU)} # pads with a 0.6 override- self.big_clr_val = RULES["clearance"]- self.smd_pad_cells = {} # net id -> bool mask (F.Cu smd pads) for via placement- self.thru_cells = np.zeros((g.H, g.W), dtype=bool) # every through pad: never a via site- self.history = {L: np.zeros((g.H, g.W), dtype=np.int16) for L in (F_CU, B_CU)} # contested cells (rip-ups)- self.chan_count = np.zeros((g.H, g.W), dtype=np.int16) # how many unrouted pin channels a via here would wall- self.chan_lines = {} # (net idx, cluster idx, term) -> (jj, ii) of the walled region- self.vias = [] # [(i, j, net id, diameter, drill)]- self.kelvin_owner = {} # synthetic net id of a Kelvin tap -> the real NetState (never ripped up)- self.fp_index = {ref: n for n, ref in enumerate(sorted(board.footprints))}- self.pad_cells = {} # pad key -> (jj, ii)- t0 = time.time()- self._stamp_pads()- self.inside, self.edge_dist = g.outline_distance(board.outline)- self.inside_w = {}- self.order = []- self.rip_total = 0- self.stats = defaultdict(int)- self._build_clusters()- self._build_ic_zones()- log("router: %d x %d cells, %d pads stamped, %d nets to route, init %.1fs" % (- g.W, g.H, len(board.pads), len([n for n in self.nets.values() if len(n.clusters) >= 2 or n.plane]), time.time() - t0))-- # -- stamping ------------------------------------------------------------------- def _stamp_pads(self):- g = self.grid- for p in self.board.pads:- nid = self.net_ids.get(p.net, -1)- m = np.zeros((g.H, g.W), dtype=bool)- for poly in p.polys:- g.poly_mask(poly, m)- if p.drill:- jj, ii = g.disk_cells((p.x, p.y), p.drill / 2.0)- m[jj, ii] = True- jj, ii = np.nonzero(m)- self.pad_cells[p.key] = (jj, ii)- layers = [F_CU, B_CU] if p.thru else [F_CU if "F.Cu" in p.layers else B_CU]- for L in layers:- self.pad_raw[L][jj, ii] = nid- self.pad_owner[L][jj, ii] = self.fp_index[p.ref]- if p.clearance > RULES["clearance"]:- self.big_clr[L][jj, ii] = True- self.big_clr_val = max(self.big_clr_val, p.clearance)- if not p.thru and nid >= 0:- sm = self.smd_pad_cells.setdefault(nid, np.zeros((g.H, g.W), dtype=bool))- sm[jj, ii] = True- if p.thru:- self.thru_cells[jj, ii] = True-- def inside_for(self, w):- key = round(w, 3)- if key not in self.inside_w:- self.inside_w[key] = self.inside & (self.edge_dist >= RULES["edge"] + w / 2.0 + SLOP)- return self.inside_w[key]-- def stamp_route(self, net, route, sign=1):- """Add (sign=1) or remove (sign=-1) a route's copper from route_raw."""- g = self.grid- nid = net.idx if sign > 0 else -1- cells = route.cells- k = 0- while k < len(cells) - 1:- # straight run from k- L, i0, j0 = cells[k]- m = k + 1- di, dj = cells[m][1] - i0, cells[m][2] - j0- while m + 1 < len(cells) and cells[m + 1][0] == L and cells[m][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):- m += 1- if cells[m][0] == L:- a = g.to_xy(i0, j0)- b = g.to_xy(cells[m][1], cells[m][2])- jj, ii = g.segment_cells(a, b, route.width / 2.0, 0.0 + 1e-6)- self.route_raw[L][jj, ii] = nid- k = m- # vias: layer changes- for k in range(1, len(cells)):- if cells[k][0] != cells[k - 1][0] and cells[k][1:] == cells[k - 1][1:]:- self._stamp_via(cells[k][1], cells[k][2], net, sign)- if route.via_end:- L, i, j = cells[-1]- self._stamp_via(i, j, net, sign)- if len(cells) == 1 and route.via_end:- pass-- def _stamp_via(self, i, j, net, sign):- g = self.grid- nid = net.idx- xy = g.to_xy(i, j)- jj, ii = g.disk_cells(xy, net.via / 2.0, 0.0 + 1e-6)- for L in (F_CU, B_CU):- self.route_raw[L][jj, ii] = nid if sign > 0 else -1- if sign > 0:- self.vias.append((i, j, nid, net.via, net.drill))- else:- self.vias = [v for v in self.vias if not (v[0] == i and v[1] == j and v[2] == nid)]-- def via_gap(self, net, nid, dia, drill):- """Centre-to-centre distance this net's via must keep from a via of (nid, dia, drill)."""- if nid == net.idx:- return (net.drill + drill) / 2.0 + RULES["hole_to_hole"] + SLOP- return net.via / 2.0 + dia / 2.0 + RULES["clearance"] + SLOP-- def stamp_stub(self, net, cluster, term, sign=1):- """Reserve or release the escape stub copper (exact pad centre to the on-grid escape point)."""- g = self.grid- L, i, j = term- p = cluster.primary- a = p.polys_centre- b = g.to_xy(i, j)- jj, ii = g.segment_cells(a, b, RULES["stub"] / 2.0, 1e-6)- self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1- if sign > 0:- self.pad_owner[L][jj, ii] = self.fp_index[p.ref]- kend = cluster.corridors.get(term)- if kend is not None:- jj, ii = g.segment_cells(b, kend, RULES["track"] / 2.0, 1e-6)- self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1- if sign > 0:- self.pad_owner[L][jj, ii] = self.fp_index[p.ref]- self._stamp_channel(net, cluster, term, sign)- if sign < 0:- # restore the pad itself- pj, pi = self.pad_cells[p.key]- self.pad_raw[L][pj, pi] = net.idx- self.pad_owner[L][pj, pi] = self.fp_index[p.ref]-- def _stamp_channel(self, net, cluster, term, sign):- """Count (or uncount) the region where a via would wall in this unrouted pin's straight path."""- g = self.grid- key = (net.idx, cluster.idx, term)- if sign > 0 and key in self.chan_lines:- return- if sign < 0:- hit = self.chan_lines.pop(key, None)- if hit is not None:- self.chan_count[hit[0], hit[1]] -= 1- return- L, i, j = term- p = cluster.primary- b = g.to_xy(i, j)- ax, ay = p.long_axis- cx, cy = p.polys_centre- sgn = 1.0 if (b[0] - cx) * ax + (b[1] - cy) * ay > 0 else -1.0- cend = (b[0] + sgn * round(ax, 6) * RULES["channel"], b[1] + sgn * round(ay, 6) * RULES["channel"])- r = net.via / 2.0 + RULES["clearance"] + RULES["track"] / 2.0 + SLOP- jj, ii = g.segment_cells(b, cend, r, 0.0)- ok = self.inside_for(net.via)[jj, ii]- jj, ii = jj[ok], ii[ok]- self.chan_count[jj, ii] += 1- self.chan_lines[key] = (jj, ii)-- # -- clusters and terminals ----------------------------------------------------- def _build_clusters(self):- g = self.grid- for net in self.nets.values():- if len(net.pads) < 2 and not net.plane:- continue- by_fp = defaultdict(list)- for p in net.pads:- by_fp[p.ref].append(p)- clusters = []- for ref, pads in by_fp.items():- groups = []- for p in pads:- mask = set(zip(*[a.tolist() for a in self.pad_cells[p.key]]))- merged = None- for gidx, (gpads, gcells) in enumerate(groups):- if mask & gcells:- if merged is None:- gpads.append(p)- gcells |= mask- merged = gidx- else:- groups[merged][0].extend(gpads)- groups[merged][1] |= gcells- groups[gidx] = None- groups = [x for x in groups if x is not None]- if merged is None:- groups.append(([p], set(mask)))- for gpads, _ in groups:- clusters.append(Cluster(len(clusters), gpads))- net.clusters = clusters- if net.name in RULES["wide"]:- net.widths = list(RULES["wide"][net.name])- net.via, net.drill = RULES["via"], RULES["drill"]- elif net.name in RULES["mid"]:- net.widths = list(RULES["mid"][net.name])- for c in clusters:- self._terminals_for(net, c)- # MOSFET drain/source nets get the wide treatment too- for p in self.board.pads:- if p.ref.startswith("Q") and p.name in ("1", "2", "3", "5") and p.net in self.nets:- n = self.nets[p.net]- if not n.plane and n.name not in RULES["wide"]:- n.widths = [1.0, 0.5, 0.25]- n.via, n.drill = RULES["via"], RULES["drill"]-- def _build_ic_zones(self):- """Fine-pitch ICs (two or more escaped pins): the body interior on F.Cu is a keepout for nets- without a pin on that IC; a band around the IC carries a soft cost for every net."""- g = self.grid- self.body_owner = np.full((g.H, g.W), -1, dtype=np.int16)- self.ic_zone = np.zeros((g.H, g.W), dtype=bool)- self.ic_nets = {} # fp index -> set of net ids with a pin on it- by_fp = defaultdict(list)- for net in self.nets.values():- for c in net.clusters:- if c.escapes:- by_fp[c.primary.ref].append(c)- for ref, clusters in by_fp.items():- if len(clusters) < 2:- continue- fp = self.board.footprints[ref]- inner = []- for c in clusters:- p = c.primary- ax, ay = p.long_axis- cx, cy = p.polys_centre- sgn = 1.0 if (fp["x"] - cx) * ax + (fp["y"] - cy) * ay > 0 else -1.0- inner.append((cx + sgn * ax * p.half_long, cy + sgn * ay * p.half_long))- pads = [q for q in self.board.pads if q.ref == ref]- xs = [pt[0] for pt in inner]- ys = [pt[1] for pt in inner]- if max(xs) - min(xs) < 1.0 or max(ys) - min(ys) < 1.0:- continue- i0, j0 = g.to_cell(min(xs), min(ys))- i1, j1 = g.to_cell(max(xs), max(ys))- idx = self.fp_index[ref]- self.body_owner[j0:j1 + 1, i0:i1 + 1] = idx- self.ic_nets[idx] = {self.net_ids[q.net] for q in pads if q.net in self.net_ids}- m = int(round(RULES["ic_zone"] / RES))- px = [q.x for q in pads]- py = [q.y for q in pads]- a0, b0 = g.to_cell(min(px), min(py))- a1, b1 = g.to_cell(max(px), max(py))- self.ic_zone[max(0, b0 - m):b1 + m + 1, max(0, a0 - m):a1 + m + 1] = True- log(" ic %s: body keepout %.1f x %.1f mm for %d foreign nets, escape band +%d/cell" % (- ref, (i1 - i0) * RES, (j1 - j0) * RES, len(self.nets) - len(self.ic_nets[idx]), ZONE_COST))-- def _terminals_for(self, net, c):- g = self.grid- p = c.primary- md = p.min_dim- c.allow = 1.0 if md >= 1.0 else (0.5 if md >= 0.5 else 0.25)- if p.thru:- i, j = g.to_cell(p.x, p.y)- c.terminals = [(F_CU, i, j), (B_CU, i, j)]- return- L = F_CU if "F.Cu" in p.layers else B_CU- narrow = md < RULES["narrow"] and p.half_long > p.half_short * 1.4- if not narrow:- i, j = g.to_cell(p.x, p.y)- c.terminals = [(L, i, j)]- return- c.allow = 0.25- cx, cy = p.polys_centre- ax, ay = p.long_axis- found = []- for sgn in (1.0, -1.0):- hit = None- # a full corridor first; a shorter one when a neighbouring part sits close to the pin end- for corridor in (RULES["corridor"], RULES["corridor"] / 2.0, 0.0):- d = RULES["escape"]- while d <= 1.6 and hit is None:- ex, ey = cx + sgn * ax * (p.half_long + d), cy + sgn * ay * (p.half_long + d)- i, j = g.to_cell(ex, ey)- # the corridor leaves the ON-GRID escape cell along the pin axis, so an axis-aligned- # pin row gets straight corridors that rasterise three cells wide, never four- bx, by = g.to_xy(i, j)- kx, ky = bx + sgn * round(ax, 6) * corridor, by + sgn * round(ay, 6) * corridor- if g.in_bounds(i, j) and self._escape_ok(net, p, L, i, j, (kx, ky)):- hit = ((L, i, j), sgn, (kx, ky) if corridor > 0 else None)- d += 0.1- if hit is not None:- break- if hit is not None:- found.append(hit)- c.terminals = [t for t, _, _ in found]- for t, sgn, kend in found:- c.escapes[t] = g.to_xy(t[1], t[2])- if kend is not None:- c.corridors[t] = kend- self.stamp_stub(net, c, t, 1)- if not found:- i, j = g.to_cell(p.x, p.y)- c.terminals = [(L, i, j)]-- def _escape_ok(self, net, p, L, i, j, corridor_end):- """Exact geometry: the escape cell, the stub and the reserved corridor beyond it clear every- pad of another footprint (same-footprint neighbours are the regular pin field, fine by- construction), and the corridor end lies inside the outline."""- g = self.grid- if not self.inside_for(RULES["track"])[j, i]:- return False- b = g.to_xy(i, j)- a = p.polys_centre- checks = [(a, b, RULES["stub"] / 2.0)]- if math.dist(b, corridor_end) > 1e-6:- ki, kj = g.to_cell(*corridor_end)- if not g.in_bounds(ki, kj) or not self.inside_for(RULES["track"])[kj, ki]:- return False- checks.append((b, corridor_end, RULES["track"] / 2.0))- samples = []- for (s0, s1, hw) in checks:- n = max(1, int(math.ceil(math.dist(s0, s1) / 0.05)))- for k in range(n + 1):- t = k / n- samples.append((s0[0] + (s1[0] - s0[0]) * t, s0[1] + (s1[1] - s0[1]) * t, hw))- # the route leaving the escape cell: a track of the net's width in any direction- samples.append((b[0], b[1], RULES["track"] / 2.0))- reach = 2.5- for q in self.board.pads:- if q.ref == p.ref or q.net == net.name:- continue- if abs(q.x - b[0]) > reach + q.half_long or abs(q.y - b[1]) > reach + q.half_long:- continue- if L == F_CU and "F.Cu" not in q.layers:- continue- clr = max(RULES["clearance"], q.clearance)- for poly in q.polys:- for (x, y, hw) in samples:- if _point_poly_distance(x, y, poly) < hw + clr:- return False- return True-- # -- obstacle maps ---------------------------------------------------------------- def obstacle_maps(self, net, width, win, soft=False):- """(obs_o, obs_d, soft_mask) per layer for the window, plus via_ok. Windows are (i0, j0, i1, j1) inclusive."""- g = self.grid- i0, j0, i1, j1 = win- r_o = (RULES["clearance"] + width / 2.0 + SLOP) / RES- r_d = r_o + 0.0707 / RES- r_big = (self.big_clr_val + width / 2.0 + SLOP) / RES- pad_R = int(math.ceil(max(r_d, r_big))) + 1- # work on a padded window so the dilation sees copper just outside it- I0, J0 = max(0, i0 - pad_R), max(0, j0 - pad_R)- I1, J1 = min(self.W - 1, i1 + pad_R), min(self.H - 1, j1 + pad_R)- obs_o, obs_d, softm = {}, {}, {}- via_block = np.zeros((J1 - J0 + 1, I1 - I0 + 1), dtype=bool)- for L in (F_CU, B_CU):- praw = self.pad_raw[L][J0:J1 + 1, I0:I1 + 1]- rraw = self.route_raw[L][J0:J1 + 1, I0:I1 + 1]- other_pads = (praw != -1) & (praw != net.idx)- other_routes = (rraw != -1) & (rraw != net.idx)- big = self.big_clr[L][J0:J1 + 1, I0:I1 + 1] & other_pads- hard = other_pads if soft else (other_pads | other_routes)- o = g.dilate(hard, r_o) | g.dilate(big, r_big) | ~self.inside_for(width)[J0:J1 + 1, I0:I1 + 1]- d = g.dilate(hard, r_d) | g.dilate(big, r_big + 0.0707 / RES)- if L == F_CU and self.ic_nets:- foreign = [idx for idx, nets in self.ic_nets.items() if net.idx not in nets]- if foreign:- body = np.isin(self.body_owner[J0:J1 + 1, I0:I1 + 1], foreign)- o |= body- d |= body- s = g.dilate(other_routes, r_d) if soft else None- sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))- obs_o[L] = o[sl]- obs_d[L] = d[sl]- softm[L] = s[sl] if s is not None else None- via_block |= g.dilate(other_pads | other_routes, (RULES["clearance"] + net.via / 2.0 + SLOP) / RES)- via_block |= g.dilate(big, (self.big_clr_val + net.via / 2.0 + SLOP) / RES)- own_smd = self.smd_pad_cells.get(net.idx)- if own_smd is not None:- via_block |= g.dilate(own_smd[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)- via_block |= g.dilate(self.thru_cells[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)- # other vias: annular ring + clearance for another net, hole to hole for this net- groups = defaultdict(list)- for (vi, vj, nid, dia, drill) in self.vias:- if I0 <= vi <= I1 and J0 <= vj <= J1:- groups[round(self.via_gap(net, nid, dia, drill), 4)].append((vj - J0, vi - I0))- for gap, cells in groups.items():- vm = np.zeros_like(via_block)- for (a, b) in cells:- vm[a, b] = True- via_block |= g.dilate(vm, gap / RES - 1e-6)- via_ok = ~via_block & self.inside_for(net.via)[J0:J1 + 1, I0:I1 + 1]- sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))- return obs_o, obs_d, softm, via_ok[sl]-- # -- A* --------------------------------------------------------------------------- def search(self, net, width, sources, targets, heur_pts, win, allow_via=True, soft=False, via_target=False, max_nodes=None, vip=()):- """A* from sources to targets. sources: [(L,i,j,cost0)]; targets: {(L,i,j)} or None when via_target.- Returns (cells, blockers) with cells [(L,i,j)] or None."""- i0, j0, i1, j1 = win- Ww, Hw = i1 - i0 + 1, j1 - j0 + 1- NL = Ww * Hw- obs_o, obs_d, softm, via_ok = self.obstacle_maps(net, width, win, soft)- oo = {L: obs_o[L].ravel().tobytes() for L in (F_CU, B_CU)}- od = {L: obs_d[L].ravel().tobytes() for L in (F_CU, B_CU)}- sm = {L: (softm[L].ravel().tobytes() if softm[L] is not None else None) for L in (F_CU, B_CU)}- vo = via_ok.ravel().tobytes()- zone = self.ic_zone[j0:j1 + 1, i0:i1 + 1].ravel().tobytes()- wallm = self.chan_count[j0:j1 + 1, i0:i1 + 1].astype(np.int32)- for (nid, cidx, term), (cj, ci) in self.chan_lines.items():- if nid == net.idx:- sel = (cj >= j0) & (cj <= j1) & (ci >= i0) & (ci <= i1)- wallm[cj[sel] - j0, ci[sel] - i0] -= 1- wall = np.clip(wallm, 0, 60).astype(np.uint8).ravel().tobytes()- hist = {L: np.clip(self.history[L][j0:j1 + 1, i0:i1 + 1], 0, 40).astype(np.uint8).ravel().tobytes() for L in (F_CU, B_CU)}- tgt = bytearray(2 * NL)- vipset = set()- for (L, i, j) in vip:- if i0 <= i <= i1 and j0 <= j <= j1:- vipset.add((j - j0) * Ww + (i - i0))- if via_target:- for L in (F_CU,):- base = L * NL- for m in range(NL):- if vo[m] and not oo[L][m]:- tgt[base + m] = 1- if targets:- for (L, i, j) in targets:- if i0 <= i <= i1 and j0 <= j <= j1:- tgt[L * NL + (j - j0) * Ww + (i - i0)] = 1- if not any(tgt):- return None, set()- gcost = [INF] * (2 * NL)- parent = [-1] * (2 * NL)- pdir = [-1] * (2 * NL)- closed = bytearray(2 * NL)- heap = []- hp = [(i - i0, j - j0) for (i, j) in heur_pts]-- def h(il, jl):- best = INF- for (hi, hj) in hp:- dx, dy = abs(hi - il), abs(hj - jl)- v = 10 * max(dx, dy) + 4 * min(dx, dy)- if v < best:- best = v- return best if hp else 0-- for (L, i, j, c0) in sources:- if not (i0 <= i <= i1 and j0 <= j <= j1):- continue- m = (j - j0) * Ww + (i - i0)- n = L * NL + m- if oo[L][m]:- continue- if c0 < gcost[n]:- gcost[n] = c0- heapq.heappush(heap, (c0 + h(i - i0, j - j0), n))- expanded = 0- found = -1- while heap:- f, n = heapq.heappop(heap)- if closed[n]:- continue- closed[n] = 1- if tgt[n]:- found = n- break- expanded += 1- if max_nodes and expanded > max_nodes:- break- L = n // NL- m = n - L * NL- jl = m // Ww- il = m - jl * Ww- gn = gcost[n]- pd = pdir[n]- ooL, odL, smL, hiL = oo[L], od[L], sm[L], hist[L]- mult = LAYER_MULT[L]- for d in range(8):- ni = il + DIRS[d][0]- nj = jl + DIRS[d][1]- if ni < 0 or nj < 0 or ni >= Ww or nj >= Hw:- continue- nm = nj * Ww + ni- if ooL[nm]:- continue- if d >= 4 and odL[nm] and odL[m]:- continue- nn = L * NL + nm- if closed[nn]:- continue- c = STEP[d] * mult- if pd != -1 and pd != d:- c += COST_TURN- if smL is not None and smL[nm]:- c += SOFT_EXTRA- if L == F_CU and zone[nm]:- c += ZONE_COST- if hiL[nm]:- c += HIST_COST * hiL[nm]- ng = gn + c- if ng < gcost[nn]:- gcost[nn] = ng- parent[nn] = n- pdir[nn] = d- heapq.heappush(heap, (ng + h(ni, nj), nn))- if allow_via and (vo[m] or m in vipset):- L2 = 1 - L- if not oo[L2][m]:- nn = L2 * NL + m- if not closed[nn]:- ng = gn + COST_VIA + VIA_WALL * wall[m]- if ng < gcost[nn]:- gcost[nn] = ng- parent[nn] = n- pdir[nn] = -1- heapq.heappush(heap, (ng + h(il, jl), nn))- self.stats["expanded"] += expanded- if found < 0:- return None, set()- cells = []- n = found- while n != -1:- L = n // NL- m = n - L * NL- jl = m // Ww- il = m - jl * Ww- cells.append((L, il + i0, jl + j0))- n = parent[n]- cells.reverse()- blockers = set()- if soft:- r = int(math.ceil((RULES["clearance"] + width / 2.0 + SLOP) / RES)) + 1- for (L, i, j) in cells:- if sm[L] is not None and sm[L][(j - j0) * Ww + (i - i0)]:- sub = self.route_raw[L][max(0, j - r):j + r + 1, max(0, i - r):i + r + 1]- for v in np.unique(sub):- if v != -1 and v != net.idx:- blockers.add(int(v))- return cells, blockers-- # -- per net ------------------------------------------------------------------------ def window_for(self, pts, margin_mm):- m = int(round(margin_mm / RES))- xs = [p[0] for p in pts]- ys = [p[1] for p in pts]- return (max(0, min(xs) - m), max(0, min(ys) - m), min(self.W - 1, max(xs) + m), min(self.H - 1, max(ys) + m))-- def copper_targets(self, net):- """Every cell of the net's connected copper: connected clusters' terminals and route centrelines."""- t = set()- for c in net.clusters:- if c.connected:- t.update(c.terminals)- for r in net.routes:- t.update(r.cells)- return t-- def vip_cells(self, net, clusters):- """Direct SMD pad terminals where a via may sit in the pad (clear of every other net)."""- out = []- for c in clusters:- if c.primary.thru or c.escapes:- continue- for t in c.terminals:- if t[0] == F_CU and self._via_clear_exact(net, *self.grid.to_xy(t[1], t[2])):- out.append(t)- return out-- def connect(self, net, cluster, anchor_pts, soft=False, vip=False):- """Route `cluster` to the net's existing copper. Returns (Route|None, blockers).-- vip=True is the last resort before rip-up: a via may sit in the pad of the cluster being- routed or in the pad it lands on when every exit on F.Cu is walled in."""- targets = self.copper_targets(net)- sources = []- for t in cluster.terminals:- sources.append((t[0], t[1], t[2], 0))- widths = [w for w in net.widths if w <= cluster.allow] or [min(net.widths)]- src_pts = [(t[1], t[2]) for t in cluster.terminals]- allpts = src_pts + list(anchor_pts)- vip_src, vip_tgt = [], []- if vip:- vip_src = self.vip_cells(net, [cluster])- vip_tgt = self.vip_cells(net, [c for c in net.clusters if c.connected])- targets = set(targets) | {(B_CU, t[1], t[2]) for t in vip_tgt}- if soft:- stages = [(12.0, 200000)]- elif vip:- stages = [(12.0, 200000)]- else:- stages = [(5.0, None), (12.0, None), (80.0, 250000)]- for width in widths:- for margin, cap in stages:- win = self.window_for(allpts, margin)- cells, blockers = self.search(net, width, sources, targets, anchor_pts, win, soft=soft, max_nodes=cap, vip=vip_src)- if cells:- start_term = cells[0]- end_cell = cells[-1]- start = ("escape", cluster, start_term) if start_term in cluster.escapes else ("pad", cluster, start_term)- end = ("copper",)- for c in net.clusters:- if c.connected and (end_cell in c.terminals or (end_cell[0] == B_CU and (F_CU, end_cell[1], end_cell[2]) in c.terminals and end_cell in targets)):- end = ("escape", c, end_cell) if end_cell in c.escapes else ("pad", c, end_cell)- route = Route(cells, width, start, end)- if end[0] == "pad" and end_cell[0] == B_CU and not end[1].primary.thru:- route.via_end = True- route.via_end_layer = "F.Cu"- self.stats["viaInPad"] += 1- net.via_in_pad.append(end[1].key)- if len(cells) > 1 and cells[0][1:] == cells[1][1:] and cells[0][0] != cells[1][0] and not cluster.primary.thru:- self.stats["viaInPad"] += 1- net.via_in_pad.append(cluster.key)- return route, blockers- return None, set()-- def route_net(self, net):- """Prim's order over clusters; each cluster to the existing copper. Returns list of blocker net ids on failure."""- clusters = [c for c in net.clusters]- for c in clusters:- c.connected = False- net.routes = []- net.failed = []- net.last_soft = []- # start from the biggest pad (the trunk grows from the power pads)- start = max(clusters, key=lambda c: (c.allow, c.primary.half_long * c.primary.half_short))- start.connected = True- connected = [start]- remaining = [c for c in clusters if c is not start]- blockers_all = set()- # Kelvin taps first: sense pad to shunt pad as a dedicated trace, then the rest of the net treats- # that trace as foreign copper so the wide current path never merges with it- for c in list(remaining):- if c.key in RULES["kelvin"]:- dst = next((k for k in clusters if k.key == RULES["kelvin"][c.key]), None)- if dst is None:- continue- if self.route_kelvin(net, c, dst):- remaining.remove(c)- connected.append(c)- if dst in remaining:- remaining.remove(dst)- connected.append(dst)- else:- net.failed.append((c.key, "kelvin tap to %s found no dedicated path" % dst.key))- remaining.remove(c)- while remaining:- best = None- for c in remaining:- for k in connected:- d = math.dist(c.xy, k.xy)- if best is None or d < best[0]:- best = (d, c, k)- d, c, k = best- remaining.remove(c)- anchor = [(t[1], t[2]) for t in k.terminals]- route, _ = self.connect(net, c, anchor)- if route is None:- route, _ = self.connect(net, c, anchor, vip=True)- if route is None:- soft_route, blockers = self.connect(net, c, anchor, soft=True)- blockers_all |= blockers- if soft_route is not None:- net.last_soft.extend(soft_route.cells)- net.failed.append((c.key, "no path" if not blockers else "blocked by %s" % sorted(self.name_of(b) for b in blockers)))- c.connected = False- continue- self.commit(net, route)- c.connected = True- connected.append(c)- return blockers_all-- def name_of(self, nid):- if nid in self.kelvin_owner:- return self.kelvin_owner[nid].name- for n in self.nets.values():- if n.idx == nid:- return n.name- return "?"-- def commit(self, net, route):- # junction: if the route ends inside another route of this net, split it there- end = route.cells[-1]- for r in net.routes:- if end in r.cells:- r.breaks.add(r.cells.index(end))- for term in (route.start, route.end):- if term[0] == "escape":- net.used_escapes.add((term[1].idx, term[2]))- net.routes.append(route)- self.stamp_route(net, route, 1)- # a routed cluster no longer needs its other escape: release that reservation- for term in (route.start, route.end):- if term[0] in ("escape", "pad"):- self.release_escapes(net, term[1])- if term[0] == "escape":- self._stamp_channel(net, term[1], term[2], -1)-- def release_escapes(self, net, cluster):- for t in list(cluster.escapes):- if (cluster.idx, t) not in net.used_escapes and (cluster.idx, t) not in cluster.__dict__.setdefault("released", set()):- self.stamp_stub(net, cluster, t, -1)- cluster.released.add((cluster.idx, t))-- def reserve_escapes(self, net, cluster):- """Re-reserve a cluster's escapes after a rip-up. An escape that was released and has since- been crossed by another net's copper (a via dropped into the freed corridor) stays dead:- re-stamping it would let the next route leave through that copper."""- g = self.grid- for t in list(cluster.escapes):- if (cluster.idx, t) in cluster.__dict__.get("released", set()):- L, i, j = t- a = cluster.primary.polys_centre- b = g.to_xy(i, j)- pts = [(a, b, RULES["stub"] / 2.0 + RULES["clearance"])]- kend = cluster.corridors.get(t)- if kend is not None:- pts.append((b, kend, RULES["track"] / 2.0 + RULES["clearance"]))- blocked = False- for (s0, s1, hw) in pts:- jj, ii = g.segment_cells(s0, s1, hw, 1e-6)- raw = self.route_raw[L][jj, ii]- if np.any((raw != -1) & (raw != net.idx)):- blocked = True- break- if blocked:- if t in cluster.terminals and len(cluster.terminals) > 1:- cluster.terminals.remove(t)- self.stats["deadEscapes"] += 1- continue- self.stamp_stub(net, cluster, t, 1)- cluster.released.discard((cluster.idx, t))- else:- self._stamp_channel(net, cluster, t, 1)-- def rip_cluster(self, net, cluster):- """Rip the stub of one plane-net cluster (its route to the plane) so a signal can pass; the- cluster is re-queued as a plane retry."""- keep = []- for r in net.routes:- if r.start[1] is cluster:- self.stamp_route(net, r, -1)- for (L, i, j) in r.cells:- self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1- else:- keep.append(r)- net.routes = keep- net.used_escapes = {(ci, t) for (ci, t) in net.used_escapes if ci != cluster.idx}- cluster.connected = False- self.reserve_escapes(net, cluster)- if cluster.key not in {k for k, _ in net.failed}:- net.failed.append((cluster.key, "ripped for a signal"))- self.stats["rips"] += 1- self.rip_total += 1-- def rip(self, net):- g = self.grid- for r in net.routes:- self.stamp_route(net, r, -1)- # remember where the contested copper was: a little dearer for every later search- for (L, i, j) in r.cells:- self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1- net.routes = []- net.used_escapes = set()- for c in net.clusters:- c.connected = False- self.reserve_escapes(net, c)- net.rips += 1- self.rip_total += 1- self.stats["rips"] += 1-- # -- Kelvin taps -------------------------------------------------------------------------- def route_kelvin(self, net, src, dst):- """One dedicated trace from the sense pad `src` to the shunt pad `dst` (same net): every other- piece of this net is an obstacle for the search (a synthetic net id owns the two pads and the- trace), no vias (a via would tie the tap to the plane), narrow width, the pad's own layer."""- g = self.grid- kid = 30000 + (src.idx % 1000) # a net id no board net uses; int16 raw maps hold it- while any(n.idx == kid for n in self.nets.values()):- kid += 1- fake = NetState(kid, net.name, [])- self.kelvin_owner[kid] = net- saved = []- for cl in (src, dst):- p = cl.primary- jj, ii = g.disk_cells((p.x, p.y), p.half_long + RULES["escape"] + RULES["corridor"] + 0.3, 1e-6)- for L in (F_CU, B_CU):- m = self.pad_raw[L][jj, ii] == net.idx- saved.append((L, jj[m], ii[m]))- self.pad_raw[L][jj[m], ii[m]] = kid- try:- sources = [(t[0], t[1], t[2], 0) for t in src.terminals]- targets = set(dst.terminals)- pts = [(t[1], t[2]) for t in src.terminals] + [(t[1], t[2]) for t in dst.terminals]- cells = None- for margin in (4.0, 10.0):- win = self.window_for(pts, margin)- # a via on a plane-net tap would tie it to the plane; a tap on a signal net may change layer- cells, _ = self.search(fake, RULES["stub"] + 0.05, sources, targets, [(t[1], t[2]) for t in dst.terminals], win, allow_via=not net.plane)- if cells:- break- finally:- for (L, pj, pi) in saved:- self.pad_raw[L][pj, pi] = net.idx- if not cells:- return False- start = ("escape", src, cells[0]) if cells[0] in src.escapes else ("pad", src, cells[0])- end = ("escape", dst, cells[-1]) if cells[-1] in dst.escapes else ("pad", dst, cells[-1])- r = Route(cells, RULES["stub"] + 0.05, start, end)- r.kelvin = True- self.commit(net, r)- # the trace is foreign copper to the rest of its own net from here on- for (L, i, j) in cells:- jj, ii = g.disk_cells(g.to_xy(i, j), r.width / 2.0, 1e-6)- self.route_raw[L][jj, ii] = kid- src.connected = True- dst.connected = True- self.stats["kelvin"] += 1- log(" kelvin %s -> %s: %d cells, dedicated" % (src.key, dst.key, len(cells)))- return True-- # -- planes --------------------------------------------------------------------------- def route_plane_net(self, net, retry=False):- g = self.grid- if retry:- failed = {k for k, _ in net.failed}- net.failed = []- for c in net.clusters:- if c.primary.thru:- c.connected = True- continue- if c.key in RULES["kelvin"]:- continue # a Kelvin sense pad never gets a plane stub; route_kelvin gives it its trace- if retry and (c.connected or c.key not in failed):- continue- sources = [(t[0], t[1], t[2], 0) for t in c.terminals]- win = self.window_for([(t[1], t[2]) for t in c.terminals], RULES["via_search_mm"])- # a via site, or the net's own copper already on its way to the plane (a neighbour's stub)- own = {cell for cell in self.copper_targets(net) if cell[0] == F_CU}- cells, _ = self.search(net, RULES["track"], sources, own, [], win, allow_via=False, via_target=True)- if cells is None:- # via in pad: the pad centre if a via there clears every other net- i, j = g.to_cell(*c.xy)- ok = self._via_clear_exact(net, c.primary.x, c.primary.y)- if ok:- r = Route([(F_CU, i, j)], RULES["track"], ("pad", c, (F_CU, i, j)), ("via",))- r.via_end = True- self.commit(net, r)- c.connected = True- net.via_in_pad.append(c.key)- self.stats["viaInPad"] += 1- else:- net.failed.append((c.key, "no via site within %.0f mm" % RULES["via_search_mm"]))- continue- start_term = cells[0]- start = ("escape", c, start_term) if start_term in c.escapes else ("pad", c, start_term)- joined = cells[-1] in own- r = Route(cells, RULES["track"], start, ("copper",) if joined else ("via",))- r.via_end = not joined- self.commit(net, r)- c.connected = True-- def _via_clear_exact(self, net, x, y):- """Exact geometry: may a via of this net sit at (x, y)? Pads, routes and vias of other nets,- every through pad, the outline and existing vias of any net are checked with real distances."""- g = self.grid- rv = net.via / 2.0- i, j = g.to_cell(x, y)- if not g.in_bounds(i, j) or not self.inside_for(net.via)[j, i]:- return False- reach = rv + self.big_clr_val + 1.5- for p in self.board.pads:- if abs(p.x - x) > reach + p.half_long or abs(p.y - y) > reach + p.half_long:- continue- if p.net == net.name and not p.thru:- continue- if p.thru and p.net == net.name:- need = net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0 + SLOP # same net: the holes must not meet- else:- need = rv + max(RULES["clearance"], p.clearance)- if p.thru and p.net != net.name:- need = max(need, net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0)- for poly in p.polys:- if _point_poly_distance(x, y, poly) < need:- return False- for other in self.nets.values():- if other.idx == net.idx:- continue- for r in other.routes:- need = rv + RULES["clearance"] + r.width / 2.0- for (ax, ay, bx, by) in _route_segments(g, r.cells):- if abs(ax - x) > 3 and abs(bx - x) > 3:- continue- if _seg_distance(x, y, ax, ay, bx, by) < need:- return False- for (vi, vj, nid, dia, drill) in self.vias:- vx, vy = g.to_xy(vi, vj)- if math.hypot(vx - x, vy - y) < self.via_gap(net, nid, dia, drill):- return False- return True-- def _via_clear_other(self, net, i, j):- g = self.grid- r = (RULES["clearance"] + net.via / 2.0 + SLOP) / RES- R = int(math.ceil(r)) + 1- for L in (F_CU, B_CU):- for raw in (self.pad_raw[L], self.route_raw[L]):- sub = raw[max(0, j - R):j + R + 1, max(0, i - R):i + R + 1]- jj, ii = np.nonzero((sub != -1) & (sub != net.idx))- for a, b in zip(jj, ii):- if (a + max(0, j - R) - j) ** 2 + (b + max(0, i - R) - i) ** 2 <= r * r:- return False- for (vi, vj, nid, dia, drill) in self.vias:- if math.hypot(vi - i, vj - j) * RES < self.via_gap(net, nid, dia, drill):- return False- return bool(self.inside_for(net.via)[j, i])-- # -- driver --------------------------------------------------------------------------- def mst_length(self, net):- pts = [c.xy for c in net.clusters]- if len(pts) < 2:- return 0.0- inn = [pts[0]]- rest = pts[1:]- total = 0.0- while rest:- best = min(((math.dist(a, b), b) for a in inn for b in rest), key=lambda x: x[0])- total += best[0]- inn.append(best[1])- rest.remove(best[1])- return total-- def run(self, only=None, planes=True, rip_per_net=8, rip_global=400, priority=None):- t0 = time.time()- self.rip_total = 0- for n in self.nets.values():- n.rips = 0- if planes:- # plane stubs first: the shortest connections on the board, and their vias then shape- # the signal routing instead of hunting for holes in it afterwards- for name in RULES["planes"]:- net = self.nets.get(name)- if net is None or (only is not None and name not in only):- continue- t1 = time.time()- for c in net.clusters:- if c.key in RULES["kelvin"]:- dst = next((k for k in net.clusters if k.key == RULES["kelvin"][c.key]), None)- if dst is None or not self.route_kelvin(net, c, dst):- net.failed.append((c.key, "kelvin tap found no dedicated path"))- self.route_plane_net(net)- log(" %-28s plane %s: %d smd stubs, %d via-in-pad, %d failed, %.1fs" % (- net.name, net.plane, len(net.routes) - len(net.via_in_pad), len(net.via_in_pad), len(net.failed), time.time() - t1))- signal = [n for n in self.nets.values() if not n.plane and len(n.clusters) >= 2 and (only is None or n.name in only)]- for n in signal:- n.mst_len = self.mst_length(n)- first = [n for n in signal if n.name in (priority or ())]- rest = [n for n in signal if n.name not in (priority or ())]- queue = sorted(first, key=lambda n: n.mst_len) + sorted(rest, key=lambda n: n.mst_len)- log("routing %d signal nets, shortest first (%.1f mm .. %.1f mm MST)%s" % (- len(queue), queue[0].mst_len if queue else 0, queue[-1].mst_len if queue else 0,- "" if not first else ", %d nets that failed last pass go first: %s" % (len(first), [n.name for n in first])))- done = []- order_no = 0- best_state = None- while queue:- net = queue.pop(0)- if net.routes:- self.rip(net)- t1 = time.time()- blockers = self.route_net(net)- order_no += 1- net.routed_order = order_no- log(" %-28s %2d clusters %s %d routes, %d failed, %.1fs%s" % (- net.name, len(net.clusters), "/".join("%.2g" % w for w in net.widths), len(net.routes), len(net.failed), time.time() - t1,- "" if not net.failed else " FAILED %s" % net.failed))- score = sum(len(n.failed) for n in self.nets.values() if n not in queue) + sum(max(0, len(n.clusters) - 1) for n in queue)- if best_state is None or score < best_state[0]:- best_state = (score, self.snapshot())- if net.failed and blockers and net.rips < rip_per_net and self.rip_total < rip_global:- cands = sorted((self.nets[self.name_of(b)] for b in blockers if b not in self.kelvin_owner and self.name_of(b) in self.nets and self.nets[self.name_of(b)].routes and not self.nets[self.name_of(b)].plane),- key=lambda n: -(n.routed_order or 0))[:4]- stubs = []- soft = set()- for (L, i, j) in net.last_soft:- for di in range(-6, 7):- for dj in range(-6, 7):- soft.add((i + di, j + dj))- # plane stubs are ripped only when nothing else blocks: a stub moves easily, a- # signal net that has to be re-routed around a moved stub does not- for b in (blockers if not cands else ()):- pn = self.nets[self.name_of(b)]- if not pn.plane:- continue- for r in list(pn.routes):- if any((i, j) in soft for (_, i, j) in r.cells):- stubs.append((pn, r.start[1]))- if cands or stubs:- log(" rip-up %s%s and retry %s" % ([c.name for c in cands], "" if not stubs else " + plane stubs %s" % [c.key for _, c in stubs], net.name))- for c in cands:- self.rip(c)- if c in queue:- queue.remove(c)- for pn, cl in stubs:- self.rip_cluster(pn, cl)- net.rips += 1- queue.insert(0, net)- for c in cands:- queue.insert(1, c)- continue- # a signal landed: give any ripped plane stubs their spot back right away- for name in RULES["planes"]:- pn = self.nets.get(name)- if pn is not None and pn.failed and planes:- self.route_plane_net(pn, retry=True)- done.append(net)- if planes:- # second chance for plane stubs that were walled in by reservations the signal routing has since released- for name in RULES["planes"]:- net = self.nets.get(name)- if net is None or not net.failed or (only is not None and name not in only):- continue- before = len(net.failed)- self.route_plane_net(net, retry=True)- log(" %-28s plane retry: %d of %d stubs recovered" % (net.name, before - len(net.failed), before))- final = sum(len(n.failed) for n in self.nets.values())- if best_state is not None and best_state[0] < final:- log(" restoring the best intermediate state (%d failed connections instead of %d)" % (best_state[0], final))- self.restore(best_state[1])- if planes:- for name in RULES["planes"]:- pn = self.nets.get(name)- if pn is not None and pn.failed:- self.route_plane_net(pn, retry=True)- final = sum(len(n.failed) for n in self.nets.values())- log("routing done in %.1fs, %d node expansions, %d rip-ups, %d failed connections" % (time.time() - t0, self.stats["expanded"], self.stats["rips"], final))- return final-- def reset(self):- """Drop every route and reservation state so a fresh pass can start (history is kept)."""- for net in self.nets.values():- if net.routes:- self.rip(net)- net.failed = []- net.via_in_pad = []- net.routed_order = None- net.used_escapes = set()- for c in net.clusters:- c.connected = False- self.rip_total = 0-- def snapshot(self):- return {name: (list(n.routes), set(n.used_escapes), list(n.failed), list(n.via_in_pad), n.routed_order) for name, n in self.nets.items()}-- def restore(self, snap):- self.reset()- for name, (routes, used, failed, vip, order) in snap.items():- net = self.nets[name]- net.used_escapes = set(used)- net.failed = list(failed)- net.via_in_pad = list(vip)- net.routed_order = order- for r in routes:- net.routes.append(r)- self.stamp_route(net, r, 1)- for c in net.clusters:- c.connected = any(r.start[1] is c or (r.end[0] in ("pad", "escape") and r.end[1] is c) for r in routes) or (net.plane and c.primary.thru)-- # -- plan emission ------------------------------------------------------------------------ def _waypoints(self, net, route):- g = self.grid- cells = route.cells- pts = []-- def xy(c):- return list(g.to_xy(c[1], c[2]))-- # start- kind, cl, term = route.start- if kind == "pad":- if term[0] == B_CU:- pts.append({"pad": cl.primary.key, "layer": "B.Cu"})- else:- pts.append(cl.primary.key)- else:- pts.append(xy(cells[0]))- # middle: collinear merge with forced breaks and layer changes- k = 1- n = len(cells)- while k < n:- L, i, j = cells[k]- pL, pi, pj = cells[k - 1]- if L != pL:- # via at this cell- pts.append({"x": xy(cells[k])[0], "y": xy(cells[k])[1], "layer": LAYER_NAMES[L]})- k += 1- continue- di, dj = i - pi, j - pj- m = k- while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj) and m not in route.breaks:- m += 1- if m == n - 1:- break- pts.append(xy(cells[m]))- k = m + 1- # end- last = cells[-1]- ek = route.end[0]- if route.via_end:- p = xy(last)- if len(cells) == 1 and kind == "pad":- p = [cl.primary.x, cl.primary.y]- pts.append({"x": p[0], "y": p[1], "layer": route.via_end_layer})- if ek == "pad":- pts.append(route.end[1].primary.key)- elif ek == "pad":- cl = route.end[1]- if pts and isinstance(pts[-1], dict) and pts[-1].get("layer") and len(cells) > 1 and cells[-1][1:] == cells[-2][1:]:- pass- pts.append(cl.primary.key)- else:- pts.append(xy(last))- # a via exactly at the last cell followed by the pad name: keep both (the marker then the pad)- return pts-- def plan(self):- entries = []- g = self.grid- for net in sorted(self.nets.values(), key=lambda n: (n.routed_order or 10 ** 6, n.name)):- if not net.routes:- continue- by_width = defaultdict(list)- for r in net.routes:- pts = self._waypoints(net, r)- if len(pts) < 2:- continue- if getattr(r, "kelvin", False):- entries.append({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": True})- continue- by_width[r.width].append(pts)- stubs = []- for (cidx, term) in sorted(net.used_escapes):- cl = net.clusters[cidx]- stubs.append([cl.primary.key, list(g.to_xy(term[1], term[2]))])- if stubs:- entries.append({"net": net.name, "width": RULES["stub"], "viaSize": net.via, "viaDrill": net.drill, "paths": stubs})- for w in sorted(by_width, reverse=True):- entries.append({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": by_width[w]})- return entries-- def report(self):- nets = {}- for net in self.nets.values():- if len(net.clusters) < 2 and not net.plane:- continue- nets[net.name] = {- "clusters": len(net.clusters), "pads": len(net.pads), "routes": len(net.routes),- "widths": sorted({r.width for r in net.routes}, reverse=True), "plane": net.plane,- "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order,- "mstMm": round(net.mst_len, 2),- }- return {"rules": RULES, "nets": nets, "stats": dict(self.stats),- "unrouted": [(n, f) for n in nets for f in nets[n]["failed"]]}---def main(argv=None):- ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])- ap.add_argument("board")- ap.add_argument("--out", help="plan JSON (default: <board dir>/fable-esc-routing-plan.json)")- ap.add_argument("--report", help="router report JSON")- ap.add_argument("--nets", help="comma separated subset of nets")- ap.add_argument("--no-planes", action="store_true")- ap.add_argument("--passes", type=int, default=3, help="full passes; nets that failed a pass are routed first in the next")- ap.add_argument("--rip-per-net", type=int, default=8); ap.add_argument("--rip-global", type=int, default=400)- ap.add_argument("--config", help="JSON overriding the board rules: wide, mid, planes, kelvin, via sizes, track, clearance")- ap.add_argument("--verbose", action="store_true")- a = ap.parse_args(argv)- if a.config:- cfg = json.load(open(a.config, encoding="utf-8"))- for k in ("wide", "mid", "planes", "kelvin"):- if k in cfg:- RULES[k] = cfg[k]- for k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "via_search_mm"):- if k in cfg:- RULES[k] = float(cfg[k])- board = Board(a.board)- r = Router(board, a.verbose)- only = set(a.nets.split(",")) if a.nets else None- best = None- priority = None- for k in range(1, a.passes + 1):- if k > 1:- r.reset()- log("=== pass %d of %d ===" % (k, a.passes))- failed = r.run(only=only, planes=not a.no_planes, priority=priority, rip_per_net=a.rip_per_net, rip_global=a.rip_global)- names = sorted({n.name for n in r.nets.values() if n.failed})- log("=== pass %d: %d failed connections in %s" % (k, failed, names))- if best is None or failed < best[0]:- best = (failed, r.snapshot(), k)- if failed == 0:- break- priority = names- if best is not None:- r.restore(best[1])- log("keeping pass %d (%d failed connections)" % (best[2], best[0]))- entries = r.plan()- rep = r.report()- out = a.out or os.path.join(os.path.dirname(os.path.abspath(a.board)), "fable-esc-routing-plan.json")- with open(out, "w", encoding="utf-8") as f:- json.dump({"engine": "Claude Fable 5.1 grid maze router (ai_router.py)", "board": os.path.basename(a.board),- "rules": {k: v for k, v in RULES.items() if k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "planes", "wide", "mid")},- "nets": entries}, f, indent=1)- log("plan: %s (%d entries, %d nets)" % (out, len(entries), len({e["net"] for e in entries})))- if rep["unrouted"]:- log("UNROUTED: %s" % rep["unrouted"])- if a.report:- with open(a.report, "w", encoding="utf-8") as f:- json.dump(rep, f, indent=1)- return 0 if not rep["unrouted"] else 3---if __name__ == "__main__":- sys.exit(main())
reference/python/runlog.pydeleted−94@@ -1,94 +0,0 @@-"""The run manifest: one JSON file per run that carries the clock, the plan, the decisions,-the stage outcomes, the capture markers and the final summary. Every command reads and-writes it, so the comparison between engines is what the file says, not what anyone remembers."""-import json-import os-import time-from pathlib import Path--STAGES = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"]---def now():- return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())---def parse(ts):- return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone---class Run:- def __init__(self, path):- self.dir = Path(path)- self.file = self.dir / "run.json"- self.data = json.load(open(self.file, encoding="utf-8")) if self.file.is_file() else None-- def exists(self):- return self.data is not None-- def save(self):- self.dir.mkdir(parents=True, exist_ok=True)- tmp = self.file.with_suffix(".tmp")- json.dump(self.data, open(tmp, "w", encoding="utf-8"), indent=1)- os.replace(tmp, self.file)-- @staticmethod- def create(path, board, spec, engine, prompt_time, ai_thread, target=None, remote_board=None):- r = Run(path)- r.data = {- "version": 1, "engine": engine, "aiThread": ai_thread, "board": str(board), "spec": spec,- "target": target, "remoteBoard": remote_board,- "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": None},- "plan": None, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [str(board)],- "outcomes": {}, "summary": None,- }- r.save()- return r-- # -- stages ---------------------------------------------------------------------- def stage_start(self, name, engine=None, note=None):- st = self.data["stages"].setdefault(name, {"runs": []})- st["runs"].append({"start": now(), "end": None, "engine": engine or self.engine_for(name), "note": note, "result": None})- self.mark("stage-start:" + name)- self.save()-- def stage_end(self, name, result=None, note=None):- st = self.data["stages"].setdefault(name, {"runs": []})- if not st["runs"] or st["runs"][-1]["end"] is not None:- st["runs"].append({"start": now(), "end": None, "engine": self.engine_for(name), "note": None, "result": None})- st["runs"][-1]["end"] = now()- if result is not None:- st["runs"][-1]["result"] = result- if note:- st["runs"][-1]["note"] = note- self.mark("stage-end:" + name)- self.save()-- def engine_for(self, name):- return self.data["decisions"].get(name) or ((self.data.get("plan") or {}).get("stages", {}).get(name, {}) or {}).get("default", "binary")-- def mark(self, label):- self.data["markers"].append({"t": now(), "label": label})-- def current_board(self):- return self.data["boards"][-1]-- def add_board(self, path):- self.data["boards"].append(str(path))- self.save()-- # -- time ------------------------------------------------------------------------ def elapsed_minutes(self):- t0 = parse(self.data["clock"]["promptTime"])- t1 = parse(self.data["clock"]["finishTime"]) if self.data["clock"]["finishTime"] else time.time()- return round((t1 - t0) / 60.0, 1)-- def stage_minutes(self):- out = {}- for name, st in self.data["stages"].items():- tot = 0.0- for r in st["runs"]:- if r["end"]:- tot += parse(r["end"]) - parse(r["start"])- out[name] = round(tot / 60.0, 1)- return out