# -*- coding: utf-8 -*- u"""PCB 布线质量:**绕行比** —— 不用截图,把「连上了但绕了一圈」判出来。 /c/Python314/python .claude/pcb_detour.py # 当前打开的那一页 /c/Python314/python .claude/pcb_detour.py --pcb PCB1 # 先打开再看 /c/Python314/python .claude/pcb_detour.py --net SDA SCL # 只看这几个 /c/Python314/python .claude/pcb_detour.py --top 30 # 最差的 30 个 /c/Python314/python .claude/pcb_detour.py --json out.json ## 判据 绕行比 = 实际走线总长 / 该网络焊盘的**最小生成树**长度 分母用 MST 而不是「最大两两跨距」:跨距只在 2 脚网络上是对的, 3 脚以上网络本来就需要比跨距更多的铜,用跨距会把正常走线全判成绕远路。 2 脚时 MST 恰好等于跨距,跟外部来源的原始判据一致 (见 `easyeda-sch-geometry` skill §9af,来自 v0id-byte/easyeda-pro-claude-skill)。 1.0 ~ 1.5 直连,正常 1.5 ~ 2.0 偏绕,看一眼 > 2.0 **绕远路**,八成能重画短一半 ⚠️ 长度本身没有判别力(跨距大的网络本来就长),要判的是**路径**不是长度。 ## 不适用的网络 铺铜承载的网络(GND / 电源)走线极少甚至没有,绕行比无意义 —— 默认跳过, `--include-poured` 可以强制看。单焊盘网络、没有走线的网络单独列。 """ from __future__ import print_function import argparse import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run # noqa: E402 COPPER = (1, 2, 15, 16) # Top / Bottom / Inner1 / Inner2(skill §10 层号) # 铺铜承载、不按走线判的网络名(前缀匹配,大小写不敏感) POURED_PREFIX = ('GND', 'AGND', 'PGND', 'VBAT', 'VBUS', 'VIN', 'VCC', 'VDD', '+12V', '12V', 'VSYS', 'VO', 'PWR') JS_OPEN = u"await eda.dmt_EditorControl.openDocument('%s');" \ u"await new Promise(r => setTimeout(r, 1200));" JS_FETCH = u""" const M = 39.3701, q = v => Math.round(v / M * 1e4) / 1e4; // mil -> mm const lines = (await eda.pcb_PrimitiveLine.getAll()) .filter(l => [1,2,15,16].includes(l.layer)) .map(l => ({net: l.net || '', layer: l.layer, x1: q(l.startX), y1: q(l.startY), x2: q(l.endX), y2: q(l.endY)})); const vias = (await eda.pcb_PrimitiveVia.getAll()) .map(v => ({net: v.net || '', x: q(v.x), y: q(v.y)})); const pads = (await eda.pcb_PrimitivePad.getAll()) .map(p => ({net: p.net || '', x: q(p.x), y: q(p.y)})); return {lines, vias, pads}; """ def dist(a, b): return math.hypot(a[0] - b[0], a[1] - b[1]) def mst_length(pts): u"""Prim。返回最小生成树总长;1 个点回 0,2 个点回两点距离。""" n = len(pts) if n < 2: return 0.0 inside = [False] * n best = [float('inf')] * n best[0] = 0.0 total = 0.0 for _ in range(n): k = min((i for i in range(n) if not inside[i]), key=lambda i: best[i]) inside[k] = True total += best[k] for j in range(n): if not inside[j]: d = dist(pts[k], pts[j]) if d < best[j]: best[j] = d return total def is_poured(net): u = net.upper() return any(u.startswith(p) for p in POURED_PREFIX) def main(): ap = argparse.ArgumentParser() ap.add_argument('--pcb', help=u'先 openDocument 这一页;不给就用当前打开的') ap.add_argument('--net', nargs='*', help=u'只看这几个网络') ap.add_argument('--top', type=int, default=20) ap.add_argument('--warn', type=float, default=1.5) ap.add_argument('--fail', type=float, default=2.0) ap.add_argument('--include-poured', action='store_true') ap.add_argument('--json') a = ap.parse_args() js = (JS_OPEN % a.pcb if a.pcb else u'') + JS_FETCH r = run(js, timeout=300) if not r.get('success'): print(u'桥接调用失败:', json.dumps(r, ensure_ascii=False)[:400]) return 2 d = r['result'] lines, vias, pads = d['lines'], d['vias'], d['pads'] # ⚠️ skill §9ae:关掉再打开 PCB 标签页会让桥接失去同步,API 读回 0 图元 # 而画面上明明有线。0 走线时先怀疑桥接,不要当成空板。 if not lines: print(u'⚠️ 一条走线都没读到。') print(u' 如果板子上明明有线 —— 是桥接失去同步(skill §9ae),') print(u' 去 EDA 里 `API Gateway → 重新连接`,别当成空板继续。') return 2 by_net = {} for l in lines: if not l['net']: continue e = by_net.setdefault(l['net'], {'len': 0.0, 'pads': [], 'vias': 0}) e['len'] += math.hypot(l['x2'] - l['x1'], l['y2'] - l['y1']) for p in pads: if p['net'] in by_net or p['net']: by_net.setdefault(p['net'], {'len': 0.0, 'pads': [], 'vias': 0}) by_net[p['net']]['pads'].append((p['x'], p['y'])) for v in vias: if v['net'] in by_net: by_net[v['net']]['vias'] += 1 want = set(a.net) if a.net else None rows, skipped, unrouted = [], [], [] for net, e in by_net.items(): if want and net not in want: continue if not want and is_poured(net) and not a.include_poured: skipped.append(net) continue pts = e['pads'] if len(pts) < 2: continue base = mst_length(pts) if base < 1e-6: continue if e['len'] < 1e-6: unrouted.append((net, len(pts), base)) continue rows.append({'net': net, 'ratio': e['len'] / base, 'len_mm': e['len'], 'mst_mm': base, 'pads': len(pts), 'vias': e['vias']}) rows.sort(key=lambda x: -x['ratio']) print(u'读到 %d 条走线 / %d 个过孔 / %d 个焊盘,参与判定的网络 %d 个' % (len(lines), len(vias), len(pads), len(rows))) if skipped: print(u'跳过铺铜承载的网络 %d 个(%s…)—— 绕行比对它们没意义,' u'要看加 --include-poured' % (len(skipped), ', '.join(sorted(skipped)[:4]))) print() print(u' %-22s %7s %9s %9s %5s %5s %s' % (u'网络', u'绕行比', u'走线mm', u'MST mm', u'焊盘', u'过孔', u'判定')) print(u' ' + u'-' * 76) n_fail = n_warn = 0 for x in rows[:a.top]: if x['ratio'] >= a.fail: tag, n_fail = u'← 绕远路', n_fail + 1 elif x['ratio'] >= a.warn: tag, n_warn = u' 偏绕', n_warn + 1 else: tag = u'' print(u' %-22s %7.2f %9.1f %9.1f %5d %5d %s' % (x['net'][:22], x['ratio'], x['len_mm'], x['mst_mm'], x['pads'], x['vias'], tag)) for x in rows[a.top:]: if x['ratio'] >= a.fail: n_fail += 1 elif x['ratio'] >= a.warn: n_warn += 1 if unrouted: print(u'\n 未布线(有焊盘无走线)%d 个:%s' % (len(unrouted), ', '.join(n for n, _, _ in sorted(unrouted)[:12]))) print(u'\n合计:绕远路(≥%.1f) %d 个,偏绕(≥%.1f) %d 个,其余 %d 个正常。' % (a.fail, n_fail, a.warn, n_warn, len(rows) - n_fail - n_warn)) if n_fail: print(u'⚠️ 绕行比 ≥ %.1f 的通常能重画短一半 —— 先看过孔数最多的那几个。' % a.fail) if a.json: with open(a.json, 'w') as f: json.dump({'rows': rows, 'unrouted': unrouted, 'skipped': skipped}, f, ensure_ascii=False, indent=1) print(u'明细已写 %s' % a.json) return 1 if n_fail else 0 if __name__ == '__main__': sys.exit(main())