"""fix_pcb.py — 导入后 PCB 修复(阶段 1.5 的两个硬骨头)。必须用 KiCad 自带 python 跑。 A) BGA / 平面焊盘丢网络回填 Altium 用负片内电层隐式连接电源地,KiCad 导入时这些焊盘拿不到网络, 表现为成百上千个 "GND vs 空网络" 假短路。用阶段 0 导出的 Altium 网表真值 (component,pin,net) 把空网络焊盘重新指派回去。 B) 覆铜 zone 的本地 clearance Altium 导入器把【每块 zone 的本地 Clearance 写成 KiCad 默认 0.5mm】 ((zone ... (connect_pads (clearance 0.5)))),而本地值会盖过网络类 clearance —— 这就是导入后异 net 避让间隔明显偏大的根因。只改 .kicad_pro 的网络类 或板级规则没有用,必须逐块 zone 改。目标值取 Altium 的 InPolygon 规则 (XMOD/GQ 两个工程实测都是 0.254mm,但别照抄,去基线 pcb_design_rules.json 里查)。 热焊盘 thermal_gap 通常已经对上 Altium PolygonConnect AirGap(0.165mm),不要动。 本脚本改完不 refill。大板上 SWIG refill 容易超时甚至段错误,去 KiCad GUI 里按 B 填充,或走 IPC 后端。 用法(KICAD_PYTHON 见 ~\\skills_env.md): # 先干跑看看要动多少 D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\ --zone-clearance-mm 0.254 --dry-run # 确认后真改(会先备份 .kicad_pcb 到 .bak_fixpcb) D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\ --zone-clearance-mm 0.254 """ from __future__ import annotations import argparse import csv import json import shutil import sys from pathlib import Path COMP_KEYS = ("component", "designator", "ref", "reference") PIN_KEYS = ("pin", "pin_number", "pad", "pin designator") NET_KEYS = ("net", "netname", "net name") def pick(fieldnames, candidates) -> str | None: lowered = {(f or "").strip().lower(): f for f in fieldnames} for c in candidates: if c in lowered: return lowered[c] return None def load_truth(csv_path: Path) -> dict[tuple[str, str], str]: """Altium 网表真值 (component, pin) -> net。 Altium 的 tabular 导出会把隐藏的电源/平面引脚重复列成 net='?' 的行, 这些行没有信息量,直接丢掉。 """ with csv_path.open(newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) c_key = pick(reader.fieldnames or [], COMP_KEYS) p_key = pick(reader.fieldnames or [], PIN_KEYS) n_key = pick(reader.fieldnames or [], NET_KEYS) if not (c_key and p_key and n_key): raise SystemExit( f"[ERROR] 认不出 CSV 的列名:{reader.fieldnames}\n" f" 需要 component / pin / net 三列(大小写和别名可容忍)。") truth: dict[tuple[str, str], str] = {} dropped = 0 for row in reader: comp = (row.get(c_key) or "").strip() pin = (row.get(p_key) or "").strip() net = (row.get(n_key) or "").strip() if not (comp and pin): continue if not net or net == "?": dropped += 1 continue truth[(comp, pin)] = net print(f"[csv] 真值引脚 {len(truth)} 条(丢弃 net='?'/空 的重复行 {dropped} 条)") return truth def main() -> int: ap = argparse.ArgumentParser(description="Post-import KiCad PCB repair (nets + zone clearance)") ap.add_argument("--board", required=True, help=".kicad_pcb 路径") ap.add_argument("--netlist-csv", required=True, help="Altium 侧网表真值 CSV(阶段0 proj_export_netlist tabular)") ap.add_argument("--zone-clearance-mm", type=float, required=True, help="Altium InPolygon 规则的 polygon clearance,去基线 pcb_design_rules.json 查") ap.add_argument("--dry-run", action="store_true", help="只统计不落盘") ap.add_argument("--report", help="把统计写成 JSON") ap.add_argument("--skip-nets", action="store_true", help="只改 zone clearance,不动焊盘网络") ap.add_argument("--skip-zones", action="store_true", help="只回填焊盘网络,不动 zone clearance") args = ap.parse_args() try: import pcbnew except ImportError: print("[ERROR] 导不到 pcbnew —— 本脚本必须用 KiCad 自带的 python 跑," "例如 D:\\KiCad\\10.0\\bin\\python.exe(见 ~\\skills_env.md 的 KICAD_PYTHON)。") return 1 board_path = Path(args.board) if not board_path.is_file(): print(f"[ERROR] 板文件不存在:{board_path}") return 1 truth = load_truth(Path(args.netlist_csv)) if not args.skip_nets else {} board = pcbnew.LoadBoard(str(board_path)) stats = {"assigned": 0, "no_truth": 0, "no_ref": 0, "already": 0, "zones_fixed": 0, "zones_skipped": 0} missing_examples: list[str] = [] # ---- A) 焊盘网络回填 ---- if not args.skip_nets: def get_net(name): ni = board.FindNet(name) if ni is None: ni = pcbnew.NETINFO_ITEM(board, name) board.Add(ni) return ni for fp in board.GetFootprints(): ref = fp.GetReference() for pad in fp.Pads(): if pad.GetNetCode() != 0 and pad.GetNetname() != "": stats["already"] += 1 continue if not ref: stats["no_ref"] += 1 continue net = truth.get((ref, pad.GetNumber())) if not net: stats["no_truth"] += 1 if len(missing_examples) < 20: missing_examples.append(f"{ref}.{pad.GetNumber()}") continue if not args.dry_run: pad.SetNet(get_net(net)) stats["assigned"] += 1 print(f"[nets] 待回填={stats['assigned']} 真值里查不到={stats['no_truth']} " f"无位号={stats['no_ref']} 本来就有网络={stats['already']}") if missing_examples: print(f"[nets] 查不到真值的空网络焊盘举例:{', '.join(missing_examples)}") print(" 多为机械/屏蔽/安装孔焊盘。确认属实的话,按需单独指派 GND。") # ---- B) zone 本地 clearance ---- if not args.skip_zones: clr = pcbnew.FromMM(args.zone_clearance_mm) for z in board.Zones(): if z.GetIsRuleArea() or not z.IsOnCopperLayer(): stats["zones_skipped"] += 1 continue if not args.dry_run: z.SetLocalClearance(clr) stats["zones_fixed"] += 1 print(f"[zones] 铺铜 zone 本地 clearance -> {args.zone_clearance_mm}mm " f"共 {stats['zones_fixed']} 块;跳过 {stats['zones_skipped']} 块(规则区/非铜层)") if args.dry_run: print("[dry-run] 没有写盘。去掉 --dry-run 才会真正修改。") else: backup = board_path.with_suffix(board_path.suffix + ".bak_fixpcb") shutil.copy2(board_path, backup) board.Save(str(board_path)) print(f"[save] 已写回 {board_path}(原文件备份为 {backup.name})") print("[next] 还要重填铺铜:KiCad GUI 里按 B,或走 IPC 后端。" "大板不要用 SWIG refill,会超时/段错误。") if args.report: Path(args.report).write_text( json.dumps({**stats, "zone_clearance_mm": args.zone_clearance_mm, "dry_run": args.dry_run, "no_truth_examples": missing_examples}, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[report] {args.report}") return 0 if __name__ == "__main__": try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass sys.exit(main())