- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base) - 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base) - 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、 东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base) - 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面 - 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、 J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder 开关(route_base、remote_route.ps1);剩 37 个网待手工补 - 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base); 已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查) - 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
351 行
17 KiB
Python
351 行
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""载板:用 EasyEDA 的自动布线器**只布 11 对差分**(22 个网络)。
|
||
|
||
/c/Python314/python .claude/pcb2_route_diff.py --snap # 存布线前快照(可精确回滚)
|
||
/c/Python314/python .claude/pcb2_route_diff.py --config # 选网络 + 开对话框 + 配置 + 回读断言
|
||
/c/Python314/python .claude/pcb2_route_diff.py --run # 断言配置无误后点「运行」
|
||
/c/Python314/python .claude/pcb2_route_diff.py --watch # 只读 DOM 文本盯进度(跑的时候只许用它)
|
||
/c/Python314/python .claude/pcb2_route_diff.py --finish # 存盘 + 重开 + 数导线/过孔(唯一可信的判据)
|
||
/c/Python314/python .claude/pcb2_route_diff.py --rollback # 删掉快照里没有的线与过孔
|
||
|
||
## 为什么是「先差分、只差分」
|
||
|
||
下板那一轮的教训(PROGRESS「自动布线终于跑起来了」):
|
||
|
||
1. **顺序上应该先布差分再自动布线** —— 下板反过来做,结果 7 对只布上 2 对,
|
||
板子已经被普通信号线占满,只能局部返工。载板这次先做差分。
|
||
2. **必须一次统筹全部的对**,一对一对布会互相堵死:先布好的成了后面的障碍。
|
||
所以这里一次选中全部 22 个网络。
|
||
3. **`已有导线/过孔` 要选「移除」**。`clearRoute(false, undefined, selectNets)`
|
||
只清**选中的网络**,不会波及 GND 扇出和铺铜(pcb.js 里确认过)。
|
||
|
||
## ⚠️ 三条会让人白跑一趟的坑(都来自下板那一轮)
|
||
|
||
1. **板框必须是一条闭合多段线**,否则自动布线静默失败(一根线不画、无报错)。
|
||
载板的 `pcb2_place.push_outline()` 已经是 `createPolygon` + `PrimitivePolyline` 的写法,
|
||
`--config` 会断言 layer 11 上正好 1 条多段线。
|
||
2. **布线图层只许勾顶层 + 底层。** 重载客户端会把四层全勾回来(实测本轮开局就是四层全勾)。
|
||
内层1 是分割电源面、内层2 是完整地平面,**被信号线割开就全完了**。
|
||
`--config` 逐层回读断言。
|
||
3. **结果不到最后不落盘**:`routerResult()` 在 `state!==1`(被打断)时直接丢弃。
|
||
跑的时候**不要**调任何别的 API(连只读的 `getAll()` 都不行),只用 `--watch`。
|
||
判据只有一个:**save 之后重新 openDocument 再数一次**(`--finish` 就是干这个的)。
|
||
"""
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
from eda import run
|
||
|
||
PCB = '942ed1560d81c1e7'
|
||
SNAP = os.path.join(HERE, '_pcb2_snap_prerouting.json')
|
||
|
||
# 11 对差分 = 22 个网络(和 pcb2_rules.PAIRS 同源,这里独立列一遍并断言一致)
|
||
PAIRS = [('UPSTREAM', 'UP_DP', 'UP_DM')]
|
||
PAIRS += [('USB%d' % n, 'USB%d_DP' % n, 'USB%d_DM' % n) for n in range(1, 6)]
|
||
PAIRS += [('USB%dJ' % n, 'USB%d_DPJ' % n, 'USB%d_DMJ' % n) for n in range(1, 6)]
|
||
NETS = [n for _, a, b in PAIRS for n in (a, b)]
|
||
|
||
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
|
||
"await new Promise(r=>setTimeout(r,1800));" % PCB)
|
||
|
||
# React 对话框的通用手法:菜单项是 React 元素,`el.click()` 不管用,
|
||
# 要从元素身上找 `__reactProps$*`、一路往上找到带 onClick 的那层直接调。
|
||
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 q=propsOf(t);
|
||
if (q && typeof q.onClick === 'function') {
|
||
q.onClick({target:t,currentTarget:t,stopPropagation(){},preventDefault(){},nativeEvent:{},button:0,detail:1});
|
||
return true; } t = t.parentElement; } return false; };
|
||
const leaf = t => { for (const el of document.querySelectorAll('*'))
|
||
if (el.children.length===0 && (el.textContent||'').trim()===t) return el; return null; };
|
||
const btn = t => { for (const b of document.querySelectorAll('button'))
|
||
if ((b.innerText||'').trim()===t && b.offsetParent!==null) return b; return null; };
|
||
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)); };
|
||
// ⚠️ 不能按 DOM 顺序取单选按钮:`doCrossProbeSelect` 选中焊盘之后,
|
||
// 右侧**属性面板**会冒出「形状 / 阻焊扩展 / 焊盘连接方式」三组共 6 个单选,
|
||
// 页面上就从 9 个变成 15 个,按下标点会点到焊盘属性上去。
|
||
// 所以一律按**组标题**定位:往上找 innerText 以组名开头的祖先。
|
||
const radios = () => Array.from(document.querySelectorAll('input[type=radio]')).filter(i=>i.offsetParent!==null);
|
||
// ⚠️ 「往上找 innerText 以组名开头的祖先」还不够:对话框最外层容器的 innerText
|
||
// 也以「布线网络」开头(它是第一节),于是九个单选全都命中。
|
||
// 要取**最紧的**那个容器 —— 在所有「文本以组名开头且含单选」的元素里,取单选数最少的。
|
||
const groupRadios = (label) => {
|
||
let best = null, bestN = 1e9;
|
||
for (const el of document.querySelectorAll('div,section,form,fieldset')) {
|
||
if (el.offsetParent === null) continue;
|
||
if (!(el.innerText||'').trim().startsWith(label)) continue;
|
||
const n = el.querySelectorAll('input[type=radio]').length;
|
||
if (n >= 1 && n < bestN) { best = el; bestN = n; }
|
||
}
|
||
return best ? Array.from(best.querySelectorAll('input[type=radio]')) : [];
|
||
};
|
||
// ⚠️ 单选按钮身上**只有 onChange,没有 onClick** —— `fire()`(找 onClick)对它无效,
|
||
// 而且是静默无效:不报错,回读还是老值。要直接调 onChange 并把真实 input 当 target。
|
||
const fireChange = (el) => { const q = propsOf(el);
|
||
if (!q || typeof q.onChange !== 'function') return false;
|
||
el.checked = true;
|
||
q.onChange({target: el, currentTarget: el, type: 'change',
|
||
stopPropagation(){}, preventDefault(){}, nativeEvent: {}});
|
||
return true; };
|
||
const pick = (label, idx, want) => {
|
||
const g = groupRadios(label);
|
||
if (g.length !== want) return '「' + label + '」这一组是 ' + g.length + ' 个单选,应为 ' + want;
|
||
if (!fireChange(g[idx])) return '「' + label + '」第 ' + idx + ' 个单选没有 onChange';
|
||
return null;
|
||
};
|
||
const checkedIdx = (label) => { const g = groupRadios(label);
|
||
for (let i=0;i<g.length;i++) if (g[i].checked) return i; return -1; };
|
||
const layerRow = id => { for (const td of document.querySelectorAll('td[data-content-checkboxtdid]'))
|
||
if (td.getAttribute('data-content-checkboxtdid') === String(id) && td.offsetParent !== null) return td;
|
||
return null; };
|
||
const layerOn = id => { const td = layerRow(id); if (!td) return null;
|
||
const i = td.querySelector('i.checkbox_5JRDx'); return i ? i.className.includes('active_') : null; };
|
||
"""
|
||
|
||
|
||
def call(js, timeout=180, open_doc=True):
|
||
r = run((OPEN if open_doc else '') + HELPERS + js, timeout=timeout)
|
||
if not r.get('success'):
|
||
sys.exit(u'失败: ' + json.dumps(r, ensure_ascii=False)[:600])
|
||
return r['result']
|
||
|
||
|
||
# ── 快照 / 回滚 ────────────────────────────────────────────────────────
|
||
JS_INVENTORY = u"""
|
||
const lines = [], vias = [];
|
||
for (const o of await eda.pcb_PrimitiveLine.getAll())
|
||
lines.push([o.getState_PrimitiveId(), o.getState_Net(), o.getState_Layer()]);
|
||
for (const o of await eda.pcb_PrimitiveVia.getAll())
|
||
vias.push([o.getState_PrimitiveId(), o.getState_Net()]);
|
||
const pl = await eda.pcb_PrimitivePolyline.getAll();
|
||
return {lines: lines, vias: vias,
|
||
outline: pl.filter(p => p.getState_Layer() === 11).length,
|
||
layers: await eda.pcb_Layer.getTheNumberOfCopperLayers()};
|
||
"""
|
||
|
||
|
||
def inventory():
|
||
return call(JS_INVENTORY, timeout=300)
|
||
|
||
|
||
def snap():
|
||
d = inventory()
|
||
json.dump({'lines': [x[0] for x in d['lines']], 'vias': [x[0] for x in d['vias']]},
|
||
open(SNAP, 'w'), indent=0)
|
||
print(u'快照已存 %s:导线 %d / 过孔 %d' % (SNAP, len(d['lines']), len(d['vias'])))
|
||
|
||
|
||
def rollback():
|
||
keep = json.load(open(SNAP))
|
||
r = call(u"""
|
||
const KEEP_L = new Set(%s), KEEP_V = new Set(%s);
|
||
let dl = 0, dv = 0;
|
||
for (const o of await eda.pcb_PrimitiveLine.getAll()) {
|
||
const id = o.getState_PrimitiveId();
|
||
if (!KEEP_L.has(id)) { await eda.pcb_PrimitiveLine.delete(id); dl++; }
|
||
}
|
||
for (const o of await eda.pcb_PrimitiveVia.getAll()) {
|
||
const id = o.getState_PrimitiveId();
|
||
if (!KEEP_V.has(id)) { await eda.pcb_PrimitiveVia.delete(id); dv++; }
|
||
}
|
||
await eda.pcb_Document.save();
|
||
return {lines: dl, vias: dv};
|
||
""" % (json.dumps(keep['lines']), json.dumps(keep['vias'])), timeout=600)
|
||
print(u'已回滚:删掉导线 %d / 过孔 %d' % (r['lines'], r['vias']))
|
||
|
||
|
||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||
JS_CONFIG = u"""
|
||
const NETS = %s;
|
||
// 0) 先把可能开着的对话框关掉
|
||
const c0 = btn('取消'); if (c0) { nat(c0); await sleep(1000); }
|
||
// 1) 选中 22 个差分网络(自动布线的「所选网络」就是看这个)
|
||
await eda.pcb_SelectControl.clearSelected();
|
||
await sleep(400);
|
||
const okSel = await eda.pcb_SelectControl.doCrossProbeSelect(undefined, undefined, NETS, true, true);
|
||
await sleep(1200);
|
||
const nSel = (await eda.pcb_SelectControl.getAllSelectedPrimitives_PrimitiveId()).length;
|
||
// 2) 开 布线 -> 自动布线...
|
||
if (!fire(leaf('布线'))) return {err: 'no 布线 menu'};
|
||
await sleep(800);
|
||
if (!fire(leaf('自动布线...'))) return {err: 'no 自动布线 item'};
|
||
await sleep(2500);
|
||
// 3) 单选:布线网络 = 所选网络;已有导线/过孔 = 移除
|
||
let e;
|
||
if ((e = pick('布线网络', 1, 3))) return {err: e};
|
||
await sleep(500);
|
||
if ((e = pick('已有导线/过孔', 1, 2))) return {err: e};
|
||
await sleep(500);
|
||
if ((e = pick('布线拐角', 0, 2))) return {err: e};
|
||
await sleep(400);
|
||
if ((e = pick('效果优先级', 0, 2))) return {err: e};
|
||
await sleep(400);
|
||
// 4) 布线图层:只留顶层(1) + 底层(2),**内层1(15) / 内层2(16) 必须关掉**
|
||
for (const id of [15, 16]) {
|
||
if (layerOn(id)) { const td = layerRow(id); fire(td.querySelector('label') || td); await sleep(500); }
|
||
}
|
||
for (const id of [1, 2]) {
|
||
if (layerOn(id) === false) { const td = layerRow(id); fire(td.querySelector('label') || td); await sleep(500); }
|
||
}
|
||
await sleep(600);
|
||
// 5) 回读(按组名,不按下标)
|
||
return {selected: nSel, okSel: okSel === true,
|
||
net: checkedIdx('布线网络'), corner: checkedIdx('布线拐角'),
|
||
existing: checkedIdx('已有导线/过孔'), prio: checkedIdx('效果优先级'),
|
||
layers: {top: layerOn(1), bottom: layerOn(2), in1: layerOn(15), in2: layerOn(16)},
|
||
hasRun: !!btn('运行')};
|
||
"""
|
||
|
||
|
||
def config():
|
||
inv = inventory()
|
||
if inv['outline'] != 1:
|
||
sys.exit(u'!! layer 11 上有 %d 条多段线(应为 1)。板框不是闭合多段线时自动布线会**静默失败**。'
|
||
% inv['outline'])
|
||
if inv['layers'] != 4:
|
||
sys.exit(u'!! 铜层数是 %s,应为 4' % inv['layers'])
|
||
print(u'板框多段线 1 条 ✔ 铜层 4 ✔ 当前导线 %d / 过孔 %d'
|
||
% (len(inv['lines']), len(inv['vias'])))
|
||
|
||
r = call(JS_CONFIG % json.dumps(NETS, ensure_ascii=False), timeout=300)
|
||
if r.get('err'):
|
||
sys.exit(u'!! ' + r['err'])
|
||
bad = []
|
||
if not r['okSel'] or r['selected'] == 0:
|
||
bad.append(u'网络没选上(选中图元 %s)' % r['selected'])
|
||
for key, want, name, opts in (('net', 1, u'布线网络', u'所有网络/所选网络/所选器件网络'),
|
||
('corner', 0, u'布线拐角', u'45°/90°'),
|
||
('existing', 1, u'已有导线/过孔', u'保留/移除'),
|
||
('prio', 0, u'效果优先级', u'完成度优先/速度优先')):
|
||
if r[key] != want:
|
||
bad.append(u'%s 选的是第 %d 项(%s),应为第 %d 项' % (name, r[key], opts, want))
|
||
L = r['layers']
|
||
if not L['top'] or not L['bottom']:
|
||
bad.append(u'顶层/底层没勾上(%s / %s)' % (L['top'], L['bottom']))
|
||
if L['in1'] or L['in2']:
|
||
bad.append(u'⚠️ 内层1/内层2 还勾着(%s / %s)—— 会把地平面和电源岛割开'
|
||
% (L['in1'], L['in2']))
|
||
print(u'选中图元 %d 个(%d 个网络)' % (r['selected'], len(NETS)))
|
||
print(u'单选回读: 布线网络=%d(所选网络) 拐角=%d(45°) 已有导线过孔=%d(移除) 效果优先级=%d(完成度优先)'
|
||
% (r['net'], r['corner'], r['existing'], r['prio']))
|
||
print(u'布线图层: 顶层=%s 底层=%s 内层1=%s 内层2=%s' % (L['top'], L['bottom'], L['in1'], L['in2']))
|
||
if bad:
|
||
print(u'')
|
||
for b in bad:
|
||
print(u' [!!] ' + b)
|
||
sys.exit(1)
|
||
print(u'')
|
||
print(u'✅ 配置无误,对话框开着、「运行」按钮在。接着跑 --run。')
|
||
|
||
|
||
# ── 运行 / 盯进度 ──────────────────────────────────────────────────────
|
||
JS_RUN = u"""
|
||
// 再断言一次,防止 --config 到 --run 之间对话框被动过
|
||
if (!btn('运行')) return {err: '对话框不在了'};
|
||
if (checkedIdx('布线网络') !== 1) return {err: '布线网络不是「所选网络」'};
|
||
if (checkedIdx('已有导线/过孔') !== 1) return {err: '已有导线/过孔不是「移除」'};
|
||
if (layerOn(15) || layerOn(16)) return {err: '内层还勾着'};
|
||
if (!layerOn(1) || !layerOn(2)) return {err: '顶层/底层没勾'};
|
||
const b = btn('运行');
|
||
if (!b) return {err: '找不到「运行」按钮'};
|
||
nat(b);
|
||
return {fired: true};
|
||
"""
|
||
|
||
# ⚠️ 跑的时候**只许**读 DOM 文本。任何别的 API 调用都可能打断布线器,
|
||
# 而 routerResult() 在 state!==1 时会把这一批结果直接丢掉(画布上看得见但没进文档)。
|
||
JS_WATCH = u"""
|
||
const m = (document.body.innerText || '').match(/自动布线中\\.\\.\\.\\((\\d+)%%\\)/);
|
||
return {pct: m ? parseInt(m[1]) : null};
|
||
"""
|
||
|
||
|
||
def do_run():
|
||
r = call(JS_RUN, timeout=120, open_doc=False)
|
||
if r.get('err'):
|
||
sys.exit(u'!! ' + r['err'])
|
||
print(u'已点「运行」。**从现在起不要碰客户端** —— 只用 --watch 盯进度。')
|
||
|
||
|
||
def watch():
|
||
print(u'盯进度(只读 DOM 文本;进度条连续 3 次消失 = 跑完)')
|
||
gone = 0
|
||
t0 = time.time()
|
||
while True:
|
||
r = run(HELPERS + JS_WATCH, timeout=40)
|
||
pct = r.get('result', {}).get('pct') if r.get('success') else None
|
||
el = int(time.time() - t0)
|
||
if pct is None:
|
||
gone += 1
|
||
print(u' %5ds (无进度条) %d/3' % (el, gone))
|
||
if gone >= 3:
|
||
print(u'DONE —— 接着跑 --finish(**必须**,只有重开文档数一次才算数)')
|
||
return
|
||
else:
|
||
gone = 0
|
||
print(u' %5ds 自动布线中...(%d%%)' % (el, pct))
|
||
time.sleep(30)
|
||
|
||
|
||
def finish():
|
||
call(u"await eda.pcb_Document.save(); await sleep(2500); return 1;", timeout=300)
|
||
d = inventory() # inventory() 自带 openDocument,等于重开一次
|
||
keep = json.load(open(SNAP))
|
||
new_l = [x for x in d['lines'] if x[0] not in set(keep['lines'])]
|
||
new_v = [x for x in d['vias'] if x[0] not in set(keep['vias'])]
|
||
print(u'存盘 + 重开之后:导线 %d(新增 %d)/ 过孔 %d(新增 %d)'
|
||
% (len(d['lines']), len(new_l), len(d['vias']), len(new_v)))
|
||
per = {}
|
||
for _, net, layer in new_l:
|
||
per.setdefault(net, [0, set()])
|
||
per[net][0] += 1
|
||
per[net][1].add(layer)
|
||
print(u'')
|
||
print(u'%-12s %-6s %-8s %s' % (u'网络', u'新增线', u'层', u'新增过孔'))
|
||
miss = []
|
||
vnet = {}
|
||
for _, net in new_v:
|
||
vnet[net] = vnet.get(net, 0) + 1
|
||
for net in NETS:
|
||
n, layers = per.get(net, (0, set()))
|
||
print(u'%-12s %-6d %-8s %d' % (net, n, ','.join(str(x) for x in sorted(layers)) or '-',
|
||
vnet.get(net, 0)))
|
||
if n == 0:
|
||
miss.append(net)
|
||
other = sorted(set(per) - set(NETS))
|
||
if other:
|
||
print(u'')
|
||
print(u'[!!] 差分之外的网络也被加了线(不该发生): %s' % ' '.join(other))
|
||
if miss:
|
||
print(u'')
|
||
print(u'[!!] 这些网络一根线都没有: %s' % ' '.join(miss))
|
||
return 0 if not miss and not other else 1
|
||
|
||
|
||
def main():
|
||
if '--snap' in sys.argv:
|
||
snap()
|
||
elif '--config' in sys.argv:
|
||
config()
|
||
elif '--run' in sys.argv:
|
||
do_run()
|
||
elif '--watch' in sys.argv:
|
||
watch()
|
||
elif '--finish' in sys.argv:
|
||
sys.exit(finish())
|
||
elif '--rollback' in sys.argv:
|
||
rollback()
|
||
else:
|
||
print(__doc__)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|