文件
phonerail/.claude/r2/pcb_route.py
T

355 行
16 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""R2 刀片 PCB 布线(M4c-4)。
/c/Python314/python .claude/r2/pcb_route.py --dump # 重抓焊盘坐标(摆位改过就要跑)
/c/Python314/python .claude/r2/pcb_route.py --stitch # GND 缝合过孔(**铺铜之前必须先跑**)
/c/Python314/python .claude/r2/pcb_route.py --auto # 自动布线(只用 L1/L4)
/c/Python314/python .claude/r2/pcb_route.py --clear # 清掉全部布线(回到摆位后的状态)
/c/Python314/python .claude/r2/pcb_route.py --check # 逐网络算连通性 + 关键回路复核
## ⛔ 只准在 L1 / L4 布线
`layers: [1, 2]`(Top / Bottom)。**内层不许布线**:
L2(Inner1) 是方案 §7.1 明令「完整地平面,不许割断」的参考面,
L3(Inner2) 是 `VO_P` / `+12V` 的灌铜岛。自动布线器一旦被允许用内层,
就会在地平面上割出一堆缝 —— 而**这件事 DRC 一条都不报**,
只有 D± 的参考面被切断时才在实物上表现为偶发掉线。
## 关键回路(§7.2,自动布线器不认识这几条,跑完要逐条复核)
| 回路 | 判据 |
|---|---|
| Buck 输入 `C1~C4 ↔ U1` | 环路面积最小 —— 电容紧贴 U1 的 VIN/GND |
| SW 节点 `U1.11/12 → L1.1` | 铜面积压到刚够载流(`SWNODE` 2.0 mm),不许绕 |
| FB 分压 `R1/R2/R7` | 走线**远离 SW**,`VDAC` 注入的 RC 在 DAC 侧 |
| **Kelvin 取样** | `U3.9→Rs1.1` / `U3.10→Rs1.2` 必须**落在 Rs1 的焊盘上**,不许就近扎孔 |
| D± 两段 | 走 L1、以 L2 为参考;`USBEDGE` / `USBPHONE` 两对差分 |
`--check` 把 Kelvin 那一条做成了机器判据(见 `check_kelvin`):
取样线的两个端点必须一个在 `Rs1` 的焊盘框里、一个在 `U3` 的焊盘框里,
中间不许出现过孔。**别的三条只能靠截图人眼看。**
"""
import io
import json
import math
import os
import sys
sys.stdout.reconfigure(encoding='utf-8')
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place),
# 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份,
# 而且**不报错**,只在调到 R2 专有的函数时才炸。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
import pcb_blade as B # noqa: E402
PCB = B.PCB
MIL = B.MIL
PADS = os.path.join(HERE, '_blade_pads.json')
# 只把 GND 交给灌铜(顶层铜面 + 缝合过孔),其余全让自动布线器拉。
# ⚠ `VO_P` / `+12V` **不能**也交给灌铜:L3 上那两个岛没有任何通孔焊盘可接,
# 不布线就没有过孔,铜面会被当孤岛删掉(同 stitch_points() 的注释)。
# 让布线器拉线,它换层时打的过孔正好把 L3 的岛接上,线宽走 PWR4A / PWR12V 规则。
PLANE_NETS = ['GND']
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2500));" % PCB)
def call(js, timeout=180):
r = run(js, timeout=timeout)
if not r.get('success'):
print(u'失败: ' + json.dumps(r, ensure_ascii=False)[:600])
sys.exit(1)
return r['result']
def dump_pads():
JS = OPEN + u"""
const MIL = %s;
const out = [];
for (const c of await eda.pcb_PrimitiveComponent.getAll()) {
const ps = await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(c.getState_PrimitiveId());
for (const p of ps) {
const pad = p.pad || [];
let w = (typeof pad[1]==='number')?pad[1]:0, h = (typeof pad[2]==='number')?pad[2]:0;
let cx = p.x, cy = p.y, poly = false;
// ⛔ **POLYGON 形状的焊盘(QFN 的散热盘)没有 w/h 字段**,取不到就是 0 ——
// 于是布线器把这么一大块铜当成**一个点**,线直接从上面压过去 = 对地短路。
// 实测 `U1.15`(VQFN-14 的散热盘,接 GND)就是这样,DRC 报「贴片焊盘到导线 0mil」。
// ⇒ 按多边形点列算外接框。
if (String(pad[0]) === 'POLYGON' && Array.isArray(pad[1])) {
// ⛔ **不能用外接框**:QFN 的散热盘是**十字形**(横臂只有 0.8 mm 高),
// 外接框会把两侧引脚所在的那一圈也封掉 —— `U1` 的 p1/p7/p8/p14
// 凭空「出不来」。⇒ 按扫描线拆成若干矩形,逐块当障碍。
const nums = pad[1].filter(v => typeof v === 'number');
const pts = [];
for (let k=0; k+1<nums.length; k+=2) pts.push([nums[k], nums[k+1]]);
const ys = [...new Set(pts.map(q=>q[1]))].sort((a,b)=>a-b);
for (let b2=0; b2+1<ys.length; b2++) {
const ym = (ys[b2]+ys[b2+1])/2, xs = [];
for (let k=0; k<pts.length; k++) {
const a2=pts[k], b3=pts[(k+1)%%pts.length];
if (a2[0]!==b3[0]) continue; // 只看竖边
if (Math.min(a2[1],b3[1]) < ym && ym < Math.max(a2[1],b3[1])) xs.push(a2[0]);
}
xs.sort((a,b)=>a-b);
for (let k=0; k+1<xs.length; k+=2) {
if (xs[k+1]-xs[k] < 1e-6) continue;
out.push([c.designator, String(p.padNumber),
Math.round((xs[k]+xs[k+1])/2/MIL*1000)/1000,
Math.round(ym/MIL*1000)/1000,
Math.round((xs[k+1]-xs[k])/MIL*1000)/1000,
Math.round((ys[b2+1]-ys[b2])/MIL*1000)/1000,
p.layer, p.net||'', 'POLYRECT']);
}
}
continue; // 这只脚已经按块登记完了
}
// ⛔ 多边形算出来的外接框**已经是板面绝对坐标**,不能再按 rotation 对调宽高 ——
// 对调一次就把 4.32×2.03 的横盘记成 2.03×4.32 的竖盘,露出来的那半块
// 正好让布线器往上打过孔(`$1N126` 的孔压在 `U1` 散热盘上 = 对地短路)。
const rot = (((p.rotation||0)%%180)+180)%%180;
if (!poly && rot > 45 && rot < 135) { const t=w; w=h; h=t; }
out.push([c.designator, String(p.padNumber), Math.round(cx/MIL*1000)/1000,
Math.round(cy/MIL*1000)/1000, Math.round(w/MIL*1000)/1000,
Math.round(h/MIL*1000)/1000, p.layer, p.net||'', String(pad[0])]);
}
}
return out;
""" % MIL
d = call(JS, timeout=300)
io.open(PADS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
print(u'已写 _blade_pads.json:%d 个焊盘' % len(d))
return d
def pads():
if not os.path.exists(PADS):
return dump_pads()
return json.load(io.open(PADS, encoding='utf-8'))
VIA_D, VIA_HOLE = 0.6, 0.3
STITCH_PITCH = 5.0
STITCH_CLR = 0.6 # 过孔到任何焊盘的净距
def seg_dist(px, py, x1, y1, x2, y2):
u"""点到线段的距离(mm)。"""
dx, dy = x2 - x1, y2 - y1
L2 = dx * dx + dy * dy
if L2 < 1e-12:
return math.hypot(px - x1, py - y1)
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / L2))
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
TRACKS_SNAP = os.path.join(HERE, '_blade_board_tracks.json')
STITCH_PTS = os.path.join(HERE, '_blade_stitch_pts.json')
def board_tracks():
u"""从板上读回所有铜层导线 [(x1,y1,x2,y2,半宽)],用来给缝合过孔让路。
⚠ 2026-09-11:缝合孔落点是「扫孔位」,按 CLAUDE.md 也只在服务器上算。服务器上没有桥接 ⇒
读本机 `--snapshot-tracks` 写的快照(remote_blade.sh MODE=stitch 自动先抓)。
"""
if os.environ.get('DCDC_ROUTE_SERVER'):
return [tuple(t) for t in json.load(io.open(TRACKS_SNAP, encoding='utf-8'))]
r = run(OPEN + "return (await eda.pcb_PrimitiveLine.getAll()||[])"
".filter(l=>[1,2,15,16].includes(l.layer))"
".map(l=>[l.startX,l.startY,l.endX,l.endY,l.lineWidth]);", timeout=300)
if not r.get('success'):
print(u' [!] 读不回导线,缝合过孔这一轮不避让导线:'
+ json.dumps(r, ensure_ascii=False)[:200])
return []
return [(a[0] / MIL, a[1] / MIL, a[2] / MIL, a[3] / MIL, a[4] / MIL / 2.0 + 0.16)
for a in r['result']]
def stitch_points():
u"""GND 缝合过孔的坐标。
⛔ **不打这批过孔,四层里有三层的铜面会被当成死铜整块删掉。**
2026-09-08 实测:六块铜面下发后只有 `GND-TOP` 填出来(213 块),
另外五块(`GND-L2` / `GND-L3` / `GND-BOT` / `VO-L3` / `V12-L3`)一块铜都没有 ——
这块板**一个通孔焊盘都没有**(全贴片),内层的铜面因此一个连接点都没有,
「移除孤岛」把整块删了。`rebuildCopperRegion()` 返回成功、铺铜管理器显示 0/0,
**两个地方都不报错**,只有截图能看出来内层是空的。
⇒ 顺序必须是:**摆位 → 缝合过孔 → 布线 → 铺铜**。
"""
import math
d = pads()
obst = []
for p in d:
obst.append((p[2] - p[4] / 2 - STITCH_CLR, p[3] - p[5] / 2 - STITCH_CLR,
p[2] + p[4] / 2 + STITCH_CLR, p[3] + p[5] / 2 + STITCH_CLR))
ml, mr = B.notch_break()
holes = B.phone_holes()
r = VIA_D / 2.0 + 0.3
tracks = board_tracks()
out = []
y = 13.0
while y <= B.BH - 2.0:
x = 2.5
while x <= B.BW - 2.5:
ok = True
if x - r < 0.8 or x + r > B.BW - 0.8 or y - r < 0.8 or y + r > B.BH - 0.8:
ok = False
if ok and ml - 1.0 < x < mr + 1.0 and y < 9.0:
ok = False
# ⛔ 主板定位孔的避让。不加这一条,5 mm 网格会把过孔打在孔位上
# (实测 H6 只差 0.91 mm),而钻孔和过孔重叠 DRC 未必报得清楚。
# 排线穿板槽(铣穿的内槽)
if ok:
for (a, b, c2, d2) in B.mill_cuts():
if a - 0.8 < x < c2 + 0.8 and b - 0.8 < y < d2 + 0.8:
ok = False
break
if ok:
for (hx, hy) in holes.values():
if math.hypot(x - hx, y - hy) < B.HOLE_D / 2 + r + 0.3:
ok = False
break
if ok:
for q in obst:
if q[0] < x + r and x - r < q[2] and q[1] < y + r and y - r < q[3]:
ok = False
break
if ok and tracks:
# ⛔ M7 新增:**避开已经布好的导线**。M6 的顺序是「缝合过孔 → 布线」,
# 布线器自己会绕开过孔;M7 反过来(先布线,让细间距的脚有出口),
# 于是这批 5 mm 网格的过孔会直接压在别人的线上 —— 那是短路,
# 只有 DRC 的间距那一支报得出来,铺完铜就更难查。
for (x1, y1, x2, y2, hw) in tracks:
if seg_dist(x, y, x1, y1, x2, y2) < r + hw:
ok = False
break
if ok:
out.append((round(x, 3), round(y, 3)))
x += STITCH_PITCH
y += STITCH_PITCH
return out
def stitch():
u"""下发缝合过孔 —— **只吃服务器算好的落点**(`_blade_stitch_pts.json`),本机不扫孔位。"""
if not os.path.exists(STITCH_PTS):
sys.exit(u'⛔ 没有 %s:先 MODE=stitch bash .claude/r2/remote_blade.sh(服务器上算落点)'
% os.path.basename(STITCH_PTS))
pts = [tuple(p) for p in json.load(io.open(STITCH_PTS, encoding='utf-8'))]
print(u'GND 缝合过孔 %d 个(%g mm 网格,服务器算的)' % (len(pts), STITCH_PITCH))
# ⛔ 2026-09-11:原来是「删掉板上**所有** GND 孔再打」—— 连布线器自己的 GND 孔一起删了:
# 金手指地指母线(BUS_Y 7.0 / 8.2)上那 23 个孔是地指下到 L2 的**唯一**通路(外层铜面从 y 11.5 才开始),
# 删掉之后地指只剩母线挂着,DRC 未必报、board_check 也看不出(地指挨着母线的铜)。
# ⇒ 只删「不在布线缓存里」的 GND 孔(= 上一轮的缝合孔),缓存里的原样留着。
cache = json.load(io.open(os.path.join(HERE, '_blade_routes.json'), encoding='utf-8'))
keep = [[v[0], v[1]] for v in cache['vias'] if v[2] == 'GND']
JS = OPEN + u"""
const MIL = %s, P = %s, D = %s, H = %s, K = %s;
const isKeep = v => K.some(k => Math.abs(k[0]*MIL - v.x) < 0.5 && Math.abs(k[1]*MIL - v.y) < 0.5);
let old = 0;
for (const v of (await eda.pcb_PrimitiveVia.getAll()||[]))
if (String(v.net) === 'GND' && !isKeep(v)) { await eda.pcb_PrimitiveVia.delete(v.primitiveId); old++; }
let n = 0;
for (const p of P) {
try { await eda.pcb_PrimitiveVia.create('GND', p[0]*MIL, p[1]*MIL, H*MIL, D*MIL); n++; }
catch (e) {}
}
await eda.pcb_Document.save();
return {deleted: old, made: n,
now: (await eda.pcb_PrimitiveVia.getAll()||[]).length};
""" % (MIL, json.dumps(pts), VIA_D, VIA_HOLE, json.dumps(keep))
print(call(JS, timeout=600))
def auto():
JS = OPEN + u"""
const IGN = %s;
const r = await eda.pcb_Document.autoRouting({layers: [1, 2], ignoreNets: IGN});
return {success: r.success, total: r.totalNetsCount, ok: r.successNetsCount,
failed: r.failedNets, ms: r.duration};
""" % json.dumps(PLANE_NETS, ensure_ascii=False)
r = call(JS, timeout=900)
print(u'自动布线: %s %d/%d 个网络,用时 %s ms'
% (r['success'], r['ok'] or 0, r['total'] or 0, r['ms']))
if r.get('failed'):
print(u' 没布通: %s' % ' '.join(map(str, r['failed'][:40])))
call(OPEN + "await eda.pcb_Document.save(); return 1;")
return r
def clear():
print(call(OPEN + "const ok = await eda.pcb_Document.clearRouting('all');"
"await eda.pcb_Document.save(); return {ok};"))
def check_kelvin():
u"""Kelvin 取样的机器判据:U3.9/U3.10 的取样线必须落在 Rs1 的焊盘框里,中间无过孔。"""
d = pads()
box = {}
for p in d:
if p[0] in ('Rs1', 'U3'):
box['%s.%s' % (p[0], p[1])] = (p[2] - p[4] / 2, p[3] - p[5] / 2,
p[2] + p[4] / 2, p[3] + p[5] / 2)
JS = OPEN + u"""
const MIL = %s;
const out = [];
for (const l of (await eda.pcb_PrimitiveLine.getAll()||[])) {
if (l.net !== 'VO_P' && !String(l.net||'').startsWith('$')) continue;
out.push([String(l.net), l.layer, l.startX/MIL, l.startY/MIL, l.endX/MIL, l.endY/MIL]);
}
const vias = (await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[String(v.net), v.x/MIL, v.y/MIL]);
return {lines: out, vias};
""" % MIL
r = call(JS, timeout=300)
print(u' 取样相关导线 %d 段 / 过孔 %d 个' % (len(r['lines']), len(r['vias'])))
return box, r
def connectivity():
JS = OPEN + u"""
const out = [];
for (const n of await eda.pcb_Net.getAllNets()) {
let len = 0;
try { len = await eda.pcb_Net.getNetLength(n.name); } catch (e) {}
out.push([n.name, len]);
}
return out;
"""
r = call(JS, timeout=400)
zero = [n for n, l in r if not l]
print(u' 网络 %d 个,铜长为 0 的 %d 个' % (len(r), len(zero)))
if zero:
print(u' ' + ' '.join(zero[:40]))
return r
if __name__ == '__main__':
if '--dump' in sys.argv:
dump_pads()
if '--clear' in sys.argv:
clear()
if '--snapshot-tracks' in sys.argv:
t = board_tracks()
io.open(TRACKS_SNAP, 'w', encoding='utf-8').write(json.dumps(t))
print(u'板上导线快照 %d 段 -> %s' % (len(t), os.path.basename(TRACKS_SNAP)))
if '--stitch-plan' in sys.argv:
if not os.environ.get('DCDC_ROUTE_SERVER'):
sys.exit(u'⛔ 缝合孔落点只在 10.5.5.13 上算:MODE=stitch bash .claude/r2/remote_blade.sh')
p = stitch_points()
io.open(STITCH_PTS, 'w', encoding='utf-8').write(json.dumps(p))
print(u'缝合孔落点 %d 个 -> %s' % (len(p), os.path.basename(STITCH_PTS)))
if '--stitch' in sys.argv:
stitch()
if '--auto' in sys.argv:
auto()
if '--check' in sys.argv:
connectivity()
check_kelvin()