# -*- coding: utf-8 -*- u"""下板 PCB1:按**实测铜**算全板每个网络的连通性,列出还欠哪些连接。 /c/Python314/python .claude/pcb1_netlist.py # 全板汇总 /c/Python314/python .claude/pcb1_netlist.py --net +5V # 只看这几个网络 /c/Python314/python .claude/pcb1_netlist.py --todo # 只列还没连通的,按「要拉几条」排序 /c/Python314/python .claude/pcb1_netlist.py --refresh # 重抓板上快照(改过板就要加) /c/Python314/python .claude/pcb1_netlist.py --json out.json # 把待布线任务写成文件 ## 为什么不看 DRC 的「连接性错误」 那一支是按**对象**列的(一个没连上的焊盘可能报好几条),还分页、还要 reload 才准, 数字(594)说明不了「还剩几条线要拉」。这里把焊盘 / 过孔 / 走线 / **实测铺铜** 当成图的节点,两两相触连边,数连通域 —— 一个网络 k 个连通域就是**还差 k−1 条线**。 ## 铺铜怎么进这张图(关键) 铺铜不是一个矩形,它是一堆环、还带反焊盘的洞,而且**一个铺铜对象可能铺出好几块**。 所以照 `pcb1_pour.verify()` 的老办法:把同 (网络, 层) 的环光栅化成 0.25 mm 的布尔格、 连通域标号,**每一个连通域各算一个节点**。落脚判据和那里一致: 过孔 / 通孔焊盘落在格子里就算连上;贴片焊盘只在本层(Top/Bottom)算。 ⚠️ 走线**不**算落进铺铜(异网铺铜会被挖开,同网铺铜和走线重叠才算), 这里按「走线中点/端点落在本网铺铜格子里」判,偏保守一点没关系 —— 保守的方向是「多报几条要拉的线」,不是「少报」。 """ import io import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import pcb1_pour as PO # noqa: E402 G = PO.G EPS = 1e-6 LAYNAME = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'} # ══════════════════ 几何:点/段/矩形 ══════════════════ def seg_pt(x0, y0, x1, y1, px, py): dx, dy = x1 - x0, y1 - y0 d2 = dx * dx + dy * dy t = 0.0 if d2 < EPS else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / d2)) return math.hypot(px - (x0 + t * dx), py - (y0 + t * dy)) def seg_seg(a0, a1, b0, b1): def cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) d1, d2 = cross(b0, b1, a0), cross(b0, b1, a1) d3, d4 = cross(a0, a1, b0), cross(a0, a1, b1) if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)): return 0.0 return min(seg_pt(a0[0], a0[1], a1[0], a1[1], b0[0], b0[1]), seg_pt(a0[0], a0[1], a1[0], a1[1], b1[0], b1[1]), seg_pt(b0[0], b0[1], b1[0], b1[1], a0[0], a0[1]), seg_pt(b0[0], b0[1], b1[0], b1[1], a1[0], a1[1])) def seg_rect(p0, p1, cx, cy, hw, hh): u"""线段到轴对齐矩形的距离(重叠返回 0)。""" corners = [(cx - hw, cy - hh), (cx + hw, cy - hh), (cx + hw, cy + hh), (cx - hw, cy + hh)] for p in (p0, p1): if abs(p[0] - cx) <= hw + EPS and abs(p[1] - cy) <= hh + EPS: return 0.0 best = 1e9 for k in range(4): best = min(best, seg_seg(p0, p1, corners[k], corners[(k + 1) % 4])) return best # ══════════════════ 读板 ══════════════════ def geom(refresh=False): u"""返回 (pads, vias, lines),全部 mm。 pads: dict(net,x,y,hw,hh,th,ref,num,plane) plane = 元件所在层(1/2) vias: dict(net,x,y,r) lines: dict(net,lay,x0,y0,x1,y1,w) """ snap = PO.snapshot(refresh) raw, _n, _nb = PO.load_pads(False) pads = [] for net, x, y, hx, hy, th, tag, plane in raw: ref, _, num = tag.rpartition('.') pads.append(dict(net=net, x=x, y=y, hw=hx, hh=hy, th=th, ref=ref, num=num, plane=plane, tag=tag)) vias = [dict(net=n or '', x=x, y=y, r=(od or 0.61) / 2.0) for n, x, y, od in snap.get('via', ())] lines = [dict(net=n or '', lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w or 0.2) for n, lay, x0, y0, x1, y1, w in snap.get('line', ())] return pads, vias, lines def pour_grids(refresh=False): u"""同 (网络, 层) 的实测铺铜 -> 一个 `Grid`(已经标好连通域)。""" if refresh or not os.path.exists(PO.FILLS): d = PO.call(PO.JS_FILLS, timeout=900) io.open(PO.FILLS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False)) else: d = json.load(io.open(PO.FILLS, encoding='utf-8')) rings = {} for e in d['pours']: rings.setdefault((e['net'], e['layer']), []).extend(e['rings']) return {k: PO._grid_from_cells(k[0], k[1], PO._cells_from_rings(v)) for k, v in rings.items()} def layers_of_pad(p): return (1, 2, 15, 16) if p['th'] else (p['plane'],) # ══════════════════ 连通性 ══════════════════ class DSU(object): def __init__(self, n): self.p = list(range(n)) def find(self, i): while self.p[i] != i: self.p[i] = self.p[self.p[i]] i = self.p[i] return i def union(self, a, b): a, b = self.find(a), self.find(b) if a != b: self.p[a] = b def analyze(net, pads, vias, lines, pgrids): u"""返回 (items, groups)。items 是 (kind, layers:set, obj, label)。""" items = [] for p in pads: if p['net'] == net: items.append(('pad', set(layers_of_pad(p)), p, p['tag'])) for v in vias: if v['net'] == net: items.append(('via', set((1, 2, 15, 16)), v, u'过孔(%.2f,%.2f)' % (v['x'], v['y']))) for l in lines: if l['net'] == net: items.append(('line', set((l['lay'],)), l, u'')) # 铺铜:每个连通域一个节点 pour_nodes = [] for (pnet, lay), g in pgrids.items(): if pnet != net: continue for cid, npx in g.sizes.items(): pour_nodes.append((lay, g, cid)) items.append(('pour', set((lay,)), (lay, g, cid), u'%s 铺铜 %.0fmm²' % (LAYNAME[lay], npx * G * G))) n = len(items) dsu = DSU(n) def in_pour(node, x, y, hw, hh, lay): u"""焊盘 / 过孔 / 线段的**外框**有没有碰到这一块铺铜。 ⚠️ 不能只测中心那一格:EasyEDA 回读的环里,**同网通孔焊盘的位置是一个洞** (铜直连时那个洞正好等于焊盘轮廓,焊盘自己把它填上)。按中心格判, J22.2 / J22.3 / J24.1 这三个明明坐在整块 GND 平面里的通孔脚会被判成孤立 —— 2026-09-06 实测就是这么被骗过一次。 判据照 `pcb1_pour.Grid.hit()`:**外框覆盖到的格子里有一格属于这一块**。 """ play, g, cid = node if play != lay: return False i0, i1 = int((x - hw) / G), int((x + hw) / G) + 1 j0, j1 = int((y - hh) / G), int((y + hh) / G) + 1 for j in range(max(0, j0), min(g.ny, j1 + 1)): for i in range(max(0, i0), min(g.nx, i1 + 1)): if g.lab[j][i] == cid: return True return False def touch(a, b): ka, la, oa, _ = a kb, lb, ob, _ = b common = la & lb if not common: return False if ka == 'pour' or kb == 'pour': if ka != 'pour': a, b = b, a ka, la, oa, _x = a kb, lb, ob, _y = b lay = oa[0] if kb == 'pad': # 贴片焊盘只在自己那一层算落脚;通孔焊盘哪一层都算 if not (ob['th'] or ob['plane'] == lay): return False return in_pour(oa, ob['x'], ob['y'], ob['hw'], ob['hh'], lay) if kb == 'via': return in_pour(oa, ob['x'], ob['y'], ob['r'], ob['r'], lay) if kb == 'line': if ob['lay'] != lay: return False n_ = max(2, int(math.hypot(ob['x1'] - ob['x0'], ob['y1'] - ob['y0']) / 0.2) + 1) h_ = ob['w'] / 2.0 for s in range(n_ + 1): t = s / float(n_) if in_pour(oa, ob['x0'] + t * (ob['x1'] - ob['x0']), ob['y0'] + t * (ob['y1'] - ob['y0']), h_, h_, lay): return True return False return False # pour-pour:同层不同连通域,本来就不通 if ka == 'line' and kb == 'line': return seg_seg((oa['x0'], oa['y0']), (oa['x1'], oa['y1']), (ob['x0'], ob['y0']), (ob['x1'], ob['y1'])) \ <= (oa['w'] + ob['w']) / 2 + EPS if ka == 'line' and kb == 'pad': return seg_rect((oa['x0'], oa['y0']), (oa['x1'], oa['y1']), ob['x'], ob['y'], ob['hw'], ob['hh']) <= oa['w'] / 2 + EPS if ka == 'line' and kb == 'via': return seg_pt(oa['x0'], oa['y0'], oa['x1'], oa['y1'], ob['x'], ob['y']) <= oa['w'] / 2 + ob['r'] + EPS if ka == 'via' and kb == 'via': return math.hypot(oa['x'] - ob['x'], oa['y'] - ob['y']) <= oa['r'] + ob['r'] + EPS if ka == 'via' and kb == 'pad': return abs(oa['x'] - ob['x']) <= ob['hw'] + oa['r'] + EPS \ and abs(oa['y'] - ob['y']) <= ob['hh'] + oa['r'] + EPS if ka == 'pad' and kb == 'pad': return (abs(oa['x'] - ob['x']) <= oa['hw'] + ob['hw'] + EPS and abs(oa['y'] - ob['y']) <= oa['hh'] + ob['hh'] + EPS) return touch(b, a) for i in range(n): for j in range(i + 1, n): if touch(items[i], items[j]): dsu.union(i, j) groups = {} for i in range(n): groups.setdefault(dsu.find(i), []).append(i) return items, list(groups.values()) def main(): argv = sys.argv[1:] refresh = '--refresh' in argv todo_only = '--todo' in argv want = [] if '--net' in argv: want = [a for a in argv[argv.index('--net') + 1:] if not a.startswith('--')] outjson = None if '--json' in argv: outjson = argv[argv.index('--json') + 1] pads, vias, lines = geom(refresh) pgrids = pour_grids(refresh) print(u'板上:%d 焊盘 / %d 过孔 / %d 走线 / %d 组铺铜' % (len(pads), len(vias), len(lines), len(pgrids))) nets = {} for p in pads: if p['net']: nets.setdefault(p['net'], 0) nets[p['net']] += 1 todo, done, single = [], 0, 0 tasks = [] for net in sorted(nets): if want and net not in want: continue if nets[net] < 2: single += 1 continue items, groups = analyze(net, pads, vias, lines, pgrids) # 只含走线的碎片不算「要连的点」(它一定挂在别的东西上,否则是垃圾线) real = [g for g in groups if any(items[i][0] != 'line' for i in g)] if len(real) <= 1: done += 1 continue frags = [] for g in sorted(real, key=lambda g: -len(g)): labs = [items[i][3] for i in g if items[i][3]] pts = [] for i in g: k, _l, o, _lab = items[i] if k == 'pad': pts.append((o['x'], o['y'], sorted(layers_of_pad(o)), o['tag'])) elif k == 'via': pts.append((o['x'], o['y'], [1, 2, 15, 16], 'via')) frags.append(dict(n=len(g), labels=labs, pts=pts)) todo.append((len(real) - 1, net, nets[net], frags)) tasks.append(dict(net=net, npads=nets[net], need=len(real) - 1, frags=frags)) todo.sort(key=lambda t: (-t[0], t[1])) print(u'\n---- 还没连通的网络:%d 个,共欠 %d 条连接 ----' % (len(todo), sum(t[0] for t in todo))) for need, net, npads, frags in todo: print(u' %-14s %3d 焊盘 %2d 个碎片 -> 还差 %2d 条' % (net, npads, len(frags), need)) if not todo_only: for f in frags[:8]: labs = f['labels'][:10] print(u' [%2d] %s%s' % (f['n'], u' '.join(labs), u' …' if len(f['labels']) > 10 else u'')) print(u'\n已连通 %d 个网络 / 单焊盘网络 %d 个' % (done, single)) if outjson: io.open(outjson, 'w', encoding='utf-8').write( json.dumps(tasks, ensure_ascii=False, indent=1)) print(u'待布线任务已写到 %s' % outjson) return 0 if __name__ == '__main__': sys.exit(main())