# -*- coding: utf-8 -*- u"""R2「从原理图导入变更」(刀片 / 底板通用)。 /c/Python314/python .claude/r2/pcb_import.py --bom 逐页抓原理图 BOM(对账基准) /c/Python314/python .claude/r2/pcb_import.py --probe 开对话框、把变更行读出来 /c/Python314/python .claude/r2/pcb_import.py --apply 真的点「应用修改」(先取消「包含设计规则」) /c/Python314/python .claude/r2/pcb_import.py --verify PCB 元件表 ↔ 原理图 BOM 逐件对账 加 --base 就对底板做同一套事(默认是刀片)。 做法照搬 R1 的 `.claude/pcb1_import.py`(那一份是唯一的说明书,两个坑都在它头上): **没有可用的 API**,只能驱动 GUI。⚠ `pcb_Document.importChanges()` 这个 API 在本机 客户端上**一律返回 false、对话框都不弹**(2026-09-08 实测,刀片 PCB 上试过带/不带 schematic uuid 两种),别再浪费时间试它。 ⚠ 改过原理图就要**先 `--bom` 再 `--verify`**,否则是拿新 PCB 和旧 BOM 对账。 """ import io import json import os import re import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) sys.path.insert(0, os.path.dirname(HERE)) from eda import run # noqa: E402 U = json.load(io.open(os.path.join(HERE, 'r2-uuids.json'), encoding='utf-8')) BASE = '--base' in sys.argv BOARD = 'BASE' if BASE else 'BLADE' PCB = U['boards'][BOARD]['pcb']['uuid'] PAGES = U['boards'][BOARD]['pages'] TAG = 'base' if BASE else 'blade' BOM_FILE = '_%s_sch_bom.json' % TAG COMPS_FILE = '_%s_pcb_comps.json' % TAG ROWS_FILE = '_%s_import_rows.json' % TAG HELPERS = u""" const sleep = ms => new Promise(r => setTimeout(r, ms)); const propsOf = el => { for (const k of Object.keys(el)) if (k.startsWith('__reactProps$')) return el[k]; return null; }; const fire = (el) => { let t = el; for (let i=0;i<8&&t;i++){const p=propsOf(t); if(p&&typeof p.onClick==='function'){p.onClick({target:t,currentTarget:t,stopPropagation(){},preventDefault(){},nativeEvent:{},button:0,detail:1});return true;} t=t.parentElement;} return false; }; const nat = b => { const o={bubbles:true,cancelable:true,view:window,button:0}; for (const t of ['pointerdown','mousedown','pointerup','mouseup','click']) b.dispatchEvent(new (t.startsWith('pointer')?PointerEvent:MouseEvent)(t,o)); }; const leaf = (txt) => { for (const el of document.querySelectorAll('*')) if (el.children.length===0 && (el.textContent||'').trim()===txt && el.offsetParent!==null) return el; return null; }; """ JS_PROBE = HELPERS + u""" await eda.dmt_EditorControl.openDocument('%s'); await sleep(2000); const out = {}; fire(leaf('设计')); await sleep(900); out.opened = fire(leaf('从原理图导入变更')); await sleep(9000); let sc = null; for (const el of document.querySelectorAll('div')) if (el.scrollHeight > el.clientHeight + 20 && /移除元件|增加元件|修改/.test(el.innerText || '')) if (!sc || el.innerText.length < sc.innerText.length) sc = el; const seq = []; if (sc) { sc.scrollTop = 0; await sleep(400); let last = -1; for (let i = 0; i < 200; i++) { seq.push({top: sc.scrollTop, txt: sc.innerText}); if (sc.scrollTop === last) break; last = sc.scrollTop; if (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2) break; sc.scrollTop += Math.max(40, sc.clientHeight / 2); await sleep(260); } } out.seq = seq; out.buttons = [...document.querySelectorAll('button')].filter(b => b.offsetParent !== null).map(b => (b.innerText||'').trim()).filter(Boolean); return out; """ % PCB JS_APPLY = HELPERS + u""" const out = {}; let ruleBox = null, ruleTxt = null; for (const el of document.querySelectorAll('*')) { if (el.children.length === 0 && /设计规则/.test((el.textContent||'').trim()) && el.offsetParent !== null) { ruleTxt = (el.textContent||'').trim(); let r = el; for (let i = 0; i < 6 && r; i++) { const cb = r.querySelector && r.querySelector('input[type=checkbox]'); if (cb) { ruleBox = cb; break; } r = r.parentElement; } break; } } out.ruleTxt = ruleTxt; out.ruleChecked = ruleBox ? ruleBox.checked : null; if (ruleBox && ruleBox.checked) { nat(ruleBox); await sleep(500); out.ruleAfter = ruleBox.checked; } const btn = [...document.querySelectorAll('button')].find(b => b.offsetParent !== null && (b.innerText||'').trim() === '应用修改'); if (!btn) return Object.assign(out, {err: 'no 应用修改'}); nat(btn); await sleep(15000); for (const b of document.querySelectorAll('button')) if (b.offsetParent !== null && /^(确定|确认|是)$/.test((b.innerText||'').trim())) { nat(b); await sleep(4000); out.confirmed = (b.innerText||'').trim(); } await sleep(5000); await eda.pcb_Document.save(); out.n = (await eda.pcb_PrimitiveComponent.getAll()||[]).length; return out; """ JS_DUMP = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 2000)); const cs = await eda.pcb_PrimitiveComponent.getAll(); const out = []; for (const c of (cs||[])) out.push({d: c.designator, x: c.x, y: c.y, rot: c.rotation, lay: c.layer, fp: (c.footprint && c.footprint.name) ? c.footprint.name : (c.footprint || null)}); return out; """ % PCB JS_BOM = u""" const sleep = ms => new Promise(r => setTimeout(r, ms)); await eda.dmt_EditorControl.openDocument('%s'); await sleep(1500); const cur = String((await eda.dmt_Schematic.getCurrentSchematicPageInfo()).name || ''); if (cur.toUpperCase() !== '%s') return {err: 'openDocument 打到了 ' + cur}; const list = []; for (const c of await eda.sch_PrimitiveComponent.getAll()) { if (c.getState_ComponentType() !== 'part') continue; let fp = null; try { const f = c.getState_Footprint(); if (f) fp = {name: String(f.name || f)}; } catch (e) {} list.push({d: String(c.getState_Designator ? c.getState_Designator() : ''), uuid: c.getState_PrimitiveId(), fp: fp}); } return {list: list}; """ def call(js, timeout=300): r = run(js, timeout=timeout) if not r.get('success'): sys.exit(json.dumps(r, ensure_ascii=False)[:800]) return r['result'] def refresh_bom(): u"""一页一个请求 —— eda.py 会把返回截到 20000 字符,整本 BOM 装不下。""" bom, bad = {}, [] for name, uuid in PAGES.items(): r = run(JS_BOM % (uuid, name.upper()), timeout=300) if not r.get('success'): bad.append(u'%s: %s' % (name, json.dumps(r, ensure_ascii=False)[:160])) continue res = r['result'] if res.get('err'): bad.append(u'%s: %s' % (name, res['err'])) continue bom[name] = res['list'] print(u' %-12s %3d 件' % (name, len(res['list']))) if bad: print(u'[X] 这几页没抓到,**没有写缓存**:') for b in bad: print(u' · ' + b) sys.exit(1) io.open(os.path.join(HERE, BOM_FILE), 'w', encoding='utf-8').write( json.dumps(bom, ensure_ascii=False)) print(u'已写 %s:%d 页 / %d 件' % (BOM_FILE, len(bom), sum(len(v) for v in bom.values()))) def verify(): cs = call(JS_DUMP) io.open(os.path.join(HERE, COMPS_FILE), 'w', encoding='utf-8').write( json.dumps(cs, ensure_ascii=False)) bom = json.load(io.open(os.path.join(HERE, BOM_FILE), encoding='utf-8')) sch = {} for pg, v in bom.items(): for c in v: d = c.get('d') if d and re.match(r'^[A-Za-z]+\d', d): sch[d] = ((c.get('fp') or {}).get('name'), pg) pcb = {c['d']: c for c in cs} miss = sorted(set(sch) - set(pcb)) extra = sorted(set(pcb) - set(sch)) bad = [(d, pcb[d]['fp'], sch[d][0]) for d in sorted(set(sch) & set(pcb)) if pcb[d]['fp'] != sch[d][0]] print(u'原理图 %d 件 / PCB %d 件' % (len(sch), len(pcb))) if miss: print(u'[X] 原理图有、PCB 没有 (%d): %s' % (len(miss), ' '.join(miss[:30]))) if extra: print(u'[X] PCB 有、原理图没有 (%d): %s' % (len(extra), ' '.join(extra[:30]))) for d, a, b in bad[:20]: print(u'[X] %s 封装不一致: PCB=%s 原理图=%s' % (d, a, b)) if not (miss or extra or bad): print(u'[OK] %d 件逐件对上(位号 + 封装)' % len(sch)) return miss, extra, bad if __name__ == '__main__': print(u'板: %s PCB=%s %d 页' % (BOARD, PCB, len(PAGES))) if '--bom' in sys.argv: refresh_bom() if '--probe' in sys.argv: r = call(JS_PROBE, timeout=420) io.open(os.path.join(HERE, ROWS_FILE), 'w', encoding='utf-8').write( json.dumps(r, ensure_ascii=False, indent=1)) txt = '\n'.join(s['txt'] for s in r.get('seq', [])) print(u'对话框: opened=%s 按钮=%s' % (r.get('opened'), r.get('buttons'))) print(txt[:3000]) if '--apply' in sys.argv: print(call(JS_APPLY, timeout=420)) if '--verify' in sys.argv: verify()