# -*- coding: utf-8 -*- u"""补上 §25.8 遗留的**悬空 GND 焊盘** —— 自己找出来,自己打孔。 /c/Python314/python .claude/pcb2_route_dp.py --dump # 1. 先刷障碍表 /c/Python314/python .claude/pcb2_gnd_patch.py # 2. 找悬空 + 选孔位 + 净距校核 /c/Python314/python .claude/pcb2_gnd_patch.py --check # 只问「现在板上还有没有悬空的」 /c/Python314/python .claude/pcb2_gnd_patch.py --push # 3. 下发(**只加不删**) /c/Python314/python .claude/pcb2_pour.py --push # 4. 必须重铺 ## 「悬空」是自己算的,不是抄 DRC 把 GND 网络的**焊盘 / 过孔 / 导线**当图,两两相触连边(判据同 `pcb2_net_conn.py`), 一个连通域里只要有**一个过孔或一个通孔焊盘**,它就落到 Inner1/Inner2 的地平面上 = 连通。 **一个都没有的连通域就是悬空的** —— 那是纯顶层/底层的一小块孤铜。 ⇒ 这个判据是幂等的:补完孔再跑一次,找到 0 个。 ## 和 `pcb2_fanout.py` 的分工 `pcb_fanout` 是**批量**扇出(210 个孔),它对每个焊盘只试固定的几个方位, 挤不下就跳过 —— §25.8 里剩下的这几个就是被它跳过的。 这里换成和 `pcb2_vo_vias.py` 同一套搜索:8 个方向 × 距离 × 线宽阶梯,逐个试到过为止。 ## 下发**不能**用 `pcb2_route_dp.push()` 那个函数会先把 net 上**所有**线和过孔删掉再重画 —— 对 GND 就是把 210 个扇出孔 连同 BM24 的两条梳子全部抹掉。这里自己写一段只动**本脚本自己那几个对象**的 JS。 ## ⚠️ 幂等靠 `_pcb2_gnd_patch.json` 这份「我上次画了啥」的清单 GND 上有两百多个别人的过孔,没法按网络整网重画,所以本脚本把自己下发的 过孔/导线坐标记在 `_pcb2_gnd_patch.json` 里,下次 `--push` 先按坐标删掉它们再重画。 **不记账的代价实测过一次**:改完选点逻辑直接再 `--push`,板上就多出一对 旧坐标的过孔(425 而不是 423),而且新画的短线被 EasyEDA 并进了旧的粗线里 —— DRC 照样报错,看上去像"改了没生效"。 """ import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import pcb2_route_dp as R from pcb2_vo_vias import holes, local, place NET = 'GND' STATE = os.path.join(HERE, '_pcb2_gnd_patch.json') W_STEPS = [0.40, 0.30, 0.25, 0.20] # GND 没有专门的线宽规则,扇出历来用 0.30 EPS = 1e-6 def clusters(pads, vias, lines): u"""GND 的连通域;返回**悬空的**那些(域里一个过孔/通孔焊盘都没有)。""" items = [] for p in pads: if p['net'] == NET: items.append(('pad', set((1, 2)) if p['lay'] == 12 else set((p['lay'],)), p)) for v in vias: if v['net'] == NET: items.append(('via', set((1, 2)), v)) for l in lines: if l['net'] == NET: items.append(('line', set((l['lay'],)), l)) n = len(items) par = list(range(n)) def find(i): while par[i] != i: par[i] = par[par[i]] i = par[i] return i def touch(a, b): (ka, la, oa), (kb, lb, ob) = a, b if not (la & lb): return False if ka == 'line' and kb == 'line': return R.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 R.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 R.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 R.seg_rect((oa['x'], oa['y']), (oa['x'], oa['y']), ob['x'], ob['y'], ob['hw'], ob['hh']) <= oa['r'] + EPS if ka == 'pad' and kb == 'pad': return False return touch(b, a) for i in range(n): for j in range(i + 1, n): if touch(items[i], items[j]): a, b = find(i), find(j) if a != b: par[a] = b comp = {} for i in range(n): comp.setdefault(find(i), []).append(i) out = [] for _k, idx in comp.items(): if any(items[i][0] == 'via' or (items[i][0] == 'pad' and items[i][2]['lay'] == 12) for i in idx): continue out.append([items[i] for i in idx]) return out JS_ADD = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1800)); const SEGS = %s, VIAS = %s, MIL = %f, OLD = %s; // 先按坐标删掉**上一次本脚本画的**那几个(清单在 _pcb2_gnd_patch.json) const hit = (x, y) => OLD.some(t => Math.hypot(x/MIL - t[0], y/MIL - t[1]) < 0.02); let dv = 0, dl = 0; for (const v of await eda.pcb_PrimitiveVia.getAll()) if (v.getState_Net() === 'GND' && hit(v.getState_X(), v.getState_Y())) { await eda.pcb_PrimitiveVia.delete(v.getState_PrimitiveId()); dv++; } for (const l of await eda.pcb_PrimitiveLine.getAll()) if (l.getState_Net() === 'GND' && (hit(l.getState_StartX(), l.getState_StartY()) || hit(l.getState_EndX(), l.getState_EndY()))) { await eda.pcb_PrimitiveLine.delete(l.getState_PrimitiveId()); dl++; } let nl = 0, nv = 0; for (const [net, lay, x0, y0, x1, y1, w] of SEGS) { await eda.pcb_PrimitiveLine.create(net, lay, x0*MIL, y0*MIL, x1*MIL, y1*MIL, w*MIL); nl++; } for (const [net, x, y] of VIAS) { await eda.pcb_PrimitiveVia.create(net, x*MIL, y*MIL, %f*MIL, %f*MIL); nv++; } await eda.pcb_Document.save(); await new Promise(r=>setTimeout(r,1500)); return {lines: nl, vias: nv, delVia: dv, delLine: dl, totVia: (await eda.pcb_PrimitiveVia.getAll()).length}; """ def load_state(): u"""上一轮本脚本下发的坐标(过孔中心 + 每段线的两端)。""" try: return json.load(open(STATE, encoding='utf-8')) except Exception: return [] def drop_mine(pads, vias, lines): u"""算之前先把**自己上一轮的产物**从障碍表里摘掉 —— 否则悬空焊盘已经不悬空了, 脚本会以为没活干(而实际上那几个对象马上就要被删掉重画)。""" old = load_state() def mine(x, y): return any(math.hypot(x - a, y - b) < 0.02 for a, b in old) vias = [v for v in vias if not mine(v['x'], v['y'])] lines = [l for l in lines if not (mine(l['x0'], l['y0']) or mine(l['x1'], l['y1'])) or l['net'] != NET] return pads, vias, lines def push_add(routes): u"""**只 create、不 delete** —— GND 上有 210 个扇出孔和 BM24 的梳子,一删就完。""" segs, vias = [], [] for key, path in routes.items(): net = R.netof(key) s, v = R.expand(path) for x0, y0, x1, y1, lay, w in s: segs.append([net, lay, x0, y0, x1, y1, w]) for x, y in v: vias.append([net, x, y]) old = load_state() r = R.run(JS_ADD % (R.PCB, json.dumps(segs), json.dumps(vias), R.MIL, json.dumps(old), R.VIA_H, R.VIA_D), timeout=600) if not r.get('success'): sys.exit(u'下发失败: ' + json.dumps(r, ensure_ascii=False)[:500]) d = r['result'] pts = [[v[1], v[2]] for v in vias] pts += [[s_[2], s_[3]] for s_ in segs] + [[s_[4], s_[5]] for s_ in segs] json.dump(pts, open(STATE, 'w', encoding='utf-8')) print(u'下发完成:先删掉自己上一轮的 %d 孔 / %d 线,再画 %d 线 / %d 孔,板上过孔共 %d 个' % (d['delVia'], d['delLine'], d['lines'], d['vias'], d['totVia'])) def main(): if '--check' in sys.argv: # ⚠️ 不摘自己上一轮的产物 —— 这一支回答的是「板子现在还有没有悬空 GND」, # 默认那一支回答的是「要画成什么样」(所以必须先摘掉自己画的再算)。 left = clusters(*R.geom()) print(u'板上还悬空的 GND 连通域 %d 个' % len(left)) for cl in left: print(u' %s' % ' + '.join('%s.%s' % (o['ref'], o['num']) for k, _l, o in cl if k == 'pad')) sys.exit(1 if left else 0) pads, vias, lines = drop_mine(*R.geom()) floats = clusters(pads, vias, lines) print(u'悬空的 GND 连通域 %d 个:' % len(floats)) if not floats: print(u' 没有 —— 不用补。') return hl = holes(pads) routes, mine, mvias, fail = {}, [], [], 0 for k, cl in enumerate(floats): cands = [o for kind, _l, o in cl if kind == 'pad'] desc = ' + '.join('%s.%s' % (o['ref'], o['num']) for o in cands) best = None for pad in cands: # 域里哪个焊盘都行,取第一个打得下孔的 lp, lv, ll, lh = local(pads, vias, lines, pad['x'], pad['y'], hl) lay = R.L_TOP if pad['lay'] == 1 else R.L_BOT got = place(pad, lay, W_STEPS, lp, lv, ll, lh, mine, mvias) if got: best = (pad, lay) + got break if not best: print(u' [!!] %-28s 找不到能落孔的位置' % desc) fail += 1 continue pad, lay, w, segs, (vx, vy) = best other = R.L_BOT if lay == R.L_TOP else R.L_TOP pts = [(segs[0][1], segs[0][2], lay, w)] + [(x1, y1, lay, w) for _l, _a, _b, x1, y1, _w in segs] routes[u'%s#patch%d' % (NET, k)] = pts + [(vx, vy, other, w), (vx, vy, other, w)] for l_, x0, y0, x1, y1, w_ in segs: mine.append((NET, l_, x0, y0, x1, y1, w_)) mvias.append((NET, vx, vy)) print(u' %-28s 从 %s.%s 出,过孔 (%8.3f,%8.3f) %d 段 线宽 %.2f' % (desc, pad['ref'], pad['num'], vx, vy, len(segs), w)) segs = sum(len(R.expand(p)[0]) for p in routes.values()) nv = sum(len(R.expand(p)[1]) for p in routes.values()) print(u'') print(u'%d/%d 个域搞定:%d 段导线 / %d 个过孔' % (len(routes), len(floats), segs, nv)) if nv != len(routes): sys.exit(u'!! 过孔数和域数对不上 —— expand() 又把末端过孔吞了') n_sharp = len(R.sharp_corners(routes)) print(u'直角拐点 %d(拐弯只许 45°,应当是 0)' % n_sharp) print(u'') print(u'净距校核(一般 %.2f mm):' % R.CLR) bad = R.check(routes, pads, vias, lines) if not bad: print(u' 全部通过 ✔') if '--push' in sys.argv: if bad or fail or n_sharp: sys.exit(u'\n!! 有未解决的问题,不下发。') push_add(routes) print(u'下一步必须重铺:/c/Python314/python .claude/pcb2_pour.py --push') if __name__ == '__main__': main()