# -*- coding: utf-8 -*- u"""载板 PCB2 事故恢复:把电气数据从本地快照重放回文档。 /c/Python314/python .claude/pcb2_restore.py --survey # 只读:清点板上还剩什么 /c/Python314/python .claude/pcb2_restore.py --clean-orphans # 删掉已删元件留下的孤儿图形 /c/Python314/python .claude/pcb2_restore.py --copper # 干跑:从快照算出要建多少铜 /c/Python314/python .claude/pcb2_restore.py --copper --push # 真的建(分批 + 回查) ## 背景 2026-09-07:`pcb2_reload.py` 重载之后桥接与文档失同步(skill §9ae),铜层读回全 0, 而当时判断「重载成功」只看 `windowId` 变没变、**没有数图元**;紧接着 `pcb2_pour.py --push` 在那个状态下调了一次 `save()`,把「空」写回了文档。 结果:**元件 / 焊盘 / 导线 / 过孔 / 铺铜全没了,板框 / 挖槽 / 丝印等非铜层图形全在。** (防呆已加进 `pcb2_reload.py:assert_alive()`,判据改成「图元数不为零」。) ## 为什么能重建 事故前一刻正好跑过 `pcb2_cfgsel.py --refresh`,它调 `pcb2_route_dp.dump()` 把整板几何写进了 `_pcb2_route_geom.json`: | 内容 | 数量 | |---|---:| | 铜层导线(层 1/2,带网名和线宽)| **2033** | | 过孔(带网名和孔径)| **894** | | 焊盘(216 个元件 + 38 个自由焊盘)| 1000 | ⚠️ 这份快照是在 `--clean`(删 12 段异网旧铜)**之后**、`--push`(建 6 线 2 孔)**之前**抓的, 所以重放它 **再加上** `_pcb2_cfgsel.json` 账本里那 6 线 2 孔,正好等于事故前的最终状态。 元件本身不从快照恢复 —— 走原本的流程(从原理图导入变更 → `pcb2_place.py --push`), 那才是摆位的唯一事实来源;快照里的焊盘只用来**对账**(重放完回读焊盘坐标,和快照逐个比)。 ## 顺序(每一步都回查,不许盲走) 1. --survey 清点,确认确实只剩非铜层图形 2. --clean-orphans 删孤儿丝印/锡膏/装配(**不碰板框 11 / 挖槽 12**) 3. pcb1_import.py --board2 --probe / --apply 216 件全部「增加元件」 4. pcb2_place.py --push 摆位 5. pcb2_place.py --mech / --outline 38 个自由焊盘 + 手机位外框丝印 6. pcb2_restore.py --copper --push 2033 线 + 894 孔 + 账本的 6 线 2 孔 7. pcb2_pour.py --push 7 块铺铜(**跑之前先确认第 6 步回查过**) 8. inner1_verify.py / drc_read.py --pcb2 对 DRC 115 条基线 """ import io import json import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run PCB = '942ed1560d81c1e7' MIL = 39.3700787401575 GEOM = os.path.join(HERE, '_pcb2_route_geom.json') LEDGER = os.path.join(HERE, '_pcb2_cfgsel.json') OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2500));" % PCB) # 孤儿图形所在的层:元件封装自带的丝印 / 阻焊 / 锡膏 / 装配 / 引脚层。 # ⚠️ **绝不能碰**:11 板框、12 挖槽(跑道形穿线槽)、13 文档层。 # skill §9n 那次「删掉 layer 11 之外的全部导线」就是在载板上差点酿祸的写法。 ORPHAN_LAYERS = [3, 4, 5, 6, 7, 8, 9, 10, 48, 49, 50, 51] KEEP_LAYERS = [11, 12, 13] def call(js, what, timeout=600): r = run(js, timeout=timeout) if not r.get('success'): sys.exit(u'%s 失败: %s' % (what, json.dumps(r, ensure_ascii=False)[:700])) return r['result'] SURVEY_JS = OPEN + u""" const out = {doc: (await eda.dmt_Pcb.getCurrentPcbInfo() || {}).name}; const cnt = async (nm, get, layerOf) => { try { const by = {}; let n = 0; for (const o of await get()) { n++; if (layerOf) { const L = layerOf(o); by[L] = (by[L]||0)+1; } } out[nm] = n; if (layerOf) out[nm + '_byLayer'] = by; } catch (e) { out[nm] = 'ERR ' + String(e).slice(0, 60); } }; await cnt('component', () => eda.pcb_PrimitiveComponent.getAll()); await cnt('pad', () => eda.pcb_PrimitivePad.getAll()); await cnt('line', () => eda.pcb_PrimitiveLine.getAll(), o => o.getState_Layer()); await cnt('via', () => eda.pcb_PrimitiveVia.getAll()); await cnt('pour', () => eda.pcb_PrimitivePour.getAll()); await cnt('poured', () => eda.pcb_PrimitivePoured.getAll()); await cnt('polyline', () => eda.pcb_PrimitivePolyline.getAll(), o => o.getState_Layer()); await cnt('fill', () => eda.pcb_PrimitiveFill.getAll(), o => o.getState_Layer()); await cnt('arc', () => eda.pcb_PrimitiveArc.getAll(), o => o.getState_Layer()); await cnt('string', () => eda.pcb_PrimitiveString.getAll(), o => o.getState_Layer()); return out; """ def survey(): d = call(SURVEY_JS, u'清点') print(u'文档: %s' % d.get('doc')) for k in ('component', 'pad', 'line', 'via', 'pour', 'poured', 'polyline', 'fill', 'arc', 'string'): v = d.get(k) by = d.get(k + '_byLayer') print(u' %-10s %-8s %s' % (k, v, (u'按层 ' + json.dumps(by)) if by else u'')) return d # ⚠️ 一次请求 30 s 上限(skill §9c),2700 多个删除必须分批 —— 一批 200,从 Python 侧循环。 CLEAN_BATCH_JS = OPEN + u""" const KILL = %s, KEEP = %s, LIMIT = %d, WHICH = '%s'; const api = (WHICH === 'polyline') ? eda.pcb_PrimitivePolyline : eda.pcb_PrimitiveFill; const doomed = []; for (const o of await api.getAll()) { const L = o.getState_Layer(); if (KEEP.includes(L)) continue; // 板框 11 / 挖槽 12 / 文档 13,绝不碰 if (KILL.includes(L)) { doomed.push(o.getState_PrimitiveId()); if (doomed.length >= LIMIT) break; } } let n = 0; for (const id of doomed) { await api.delete(id); n++; } return {n, more: doomed.length >= LIMIT}; """ LEFT_JS = OPEN + u""" const left = {}; for (const o of await eda.pcb_PrimitivePolyline.getAll()) { const L = o.getState_Layer(); left['pl' + L] = (left['pl' + L] || 0) + 1; } for (const o of await eda.pcb_PrimitiveFill.getAll()) { const L = o.getState_Layer(); left['fi' + L] = (left['fi' + L] || 0) + 1; } return left; """ def clean_orphans(): before = survey() if before.get('component') or before.get('pad'): sys.exit(u'⛔ 板上还有 %s 个元件 / %s 个焊盘 —— 这一步只在「元件已经全没了」时才该跑。' % (before.get('component'), before.get('pad'))) total = {} for which in ('polyline', 'fill'): n = 0 for _round in range(40): # skill §9x:删除写成循环,收尾断言剩 0 r = call(CLEAN_BATCH_JS % (json.dumps(ORPHAN_LAYERS), json.dumps(KEEP_LAYERS), 200, which), u'清 %s' % which) n += r['n'] print(u' %s 已删 %d' % (which, n)) if not r['more']: break total[which] = n call(OPEN + u'await eda.pcb_Document.save(); return 1;', u'存盘') left = call(LEFT_JS, u'回查') # skill §9ad:save() 的返回值不算数 print(u'\n删除:多段线 %d / 填充 %d' % (total['polyline'], total['fill'])) print(u'落盘后剩余:%s' % json.dumps(left)) for k, v in left.items(): L = int(k[2:]) if L in ORPHAN_LAYERS: sys.exit(u'⛔ 层 %d 还剩 %d 个没删掉' % (L, v)) if not left.get('pl11'): sys.exit(u'⛔ 板框(层 11 多段线)不见了 —— 出大问题,立刻停手不要 save') print(u'✅ 板框还在(层 11 多段线 %d 条),挖槽层 12 填充 %s 条' % (left['pl11'], left.get('fi12'))) def copper_plan(): u"""从快照 + 账本算出要建的铜。返回 (segs, vias)。""" g = json.load(io.open(GEOM, encoding='utf-8')) segs = [] for _id, net, lay, x0, y0, x1, y1, w in g['lines']: segs.append([net or '', lay, x0 / MIL, y0 / MIL, x1 / MIL, y1 / MIL, w / MIL]) vias = [[net or '', x / MIL, y / MIL, dm / MIL] for net, x, y, dm in g['vias']] n_snap_l, n_snap_v = len(segs), len(vias) # 快照是 `pcb2_cfgsel.py --push` **之前**抓的,所以要补上账本里那一批 add_l = add_v = 0 if os.path.exists(LEDGER): led = json.load(io.open(LEDGER, encoding='utf-8')) for net, lay, x0, y0, x1, y1, w in led['lines']: segs.append([net, lay, x0, y0, x1, y1, w]); add_l += 1 for net, x, y in led['vias']: vias.append([net, x, y, None]); add_v += 1 # 孔径用默认 print(u'快照 %d 线 / %d 孔 + cfgsel 账本 %d 线 / %d 孔 = %d 线 / %d 孔' % (n_snap_l, n_snap_v, add_l, add_v, len(segs), len(vias))) return segs, vias PUSH_LINES_JS = OPEN + u""" const SEGS = %s, MIL = %f; let n = 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); n++; } return n; """ PUSH_VIAS_JS = OPEN + u""" const VIAS = %s, MIL = %f, DEF_H = %f, DEF_D = %f; let n = 0; // 板上 894 个过孔外径**全部一样**(0.6096 mm = 24 mil,就是 VIA_D 的名义值), // 所以统一用脚本默认的 VIA_H / VIA_D 建,和当初 push_sig 创建时逐位一致; // 不去按快照里那个带 mil↔mm 舍入尾巴的读数反推孔径。 for (const [net, x, y, d] of VIAS) { await eda.pcb_PrimitiveVia.create(net, x*MIL, y*MIL, DEF_H*MIL, DEF_D*MIL); n++; } return n; """ # ⚠️ 只数**铜层**(1 顶层 / 2 底层)。第一版数了全部 `pcb_PrimitiveLine`, # 于是元件封装带进来的 605 条丝印/装配/文档层线把「板上必须没有铜」的守卫顶掉了。 COUNT_JS = OPEN + u""" let nl = 0, nv = 0; const by = {}, other = {}; for (const l of await eda.pcb_PrimitiveLine.getAll()) { const L = l.getState_Layer(); if (L === 1 || L === 2) { nl++; by[L] = (by[L]||0)+1; } else other[L] = (other[L]||0)+1; } for (const v of await eda.pcb_PrimitiveVia.getAll()) nv++; return {nl, nv, by, other}; """ def copper(push=False): import pcb2_route_dp as dp segs, vias = copper_plan() by = {} for s in segs: by[s[1]] = by.get(s[1], 0) + 1 print(u'按层:%s' % json.dumps(by)) nets = sorted(set(s[0] for s in segs)) print(u'网络 %d 个;线宽 %.3f~%.3f mm' % (len(nets), min(s[6] for s in segs), max(s[6] for s in segs))) if not push: print(u'\n加 --push 下发') return st0 = call(COUNT_JS, u'下发前清点') print(u'下发前:铜层导线 %d / 过孔 %d(非铜层 %s,不算数)' % (st0['nl'], st0['nv'], json.dumps(st0['other']))) if st0['nl'] or st0['nv']: sys.exit(u'⛔ 板上已经有铜了(%d 线 / %d 孔)—— 本脚本只在空板上重放,' u'否则会画出两套。先确认状态。' % (st0['nl'], st0['nv'])) nl = 0 for i in range(0, len(segs), 250): nl += call(PUSH_LINES_JS % (json.dumps(segs[i:i + 250]), MIL), u'建线 %d~' % i) print(u' 线 %d / %d' % (nl, len(segs))) nv = 0 for i in range(0, len(vias), 250): nv += call(PUSH_VIAS_JS % (json.dumps(vias[i:i + 250]), MIL, dp.VIA_H, dp.VIA_D), u'建孔 %d~' % i) print(u' 孔 %d / %d' % (nv, len(vias))) call(OPEN + u'await eda.pcb_Document.save(); return 1;', u'存盘') st1 = call(COUNT_JS, u'下发后回查') print(u'\n落盘后回查:导线 %d(按层 %s)/ 过孔 %d' % (st1['nl'], json.dumps(st1['by']), st1['nv'])) if st1['nl'] != len(segs) or st1['nv'] != len(vias): sys.exit(u'⛔ 回查对不上:期望 %d 线 / %d 孔,实得 %d / %d' % (len(segs), len(vias), st1['nl'], st1['nv'])) print(u'✅ 铜层重放完成,数目逐项对上') def main(): if '--survey' in sys.argv: survey() elif '--clean-orphans' in sys.argv: clean_orphans() elif '--copper' in sys.argv: copper('--push' in sys.argv) else: print(__doc__) main()