# -*- 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+1q[1]))].sort((a,b)=>a-b); for (let b2=0; b2+1a-b); for (let k=0; k+1 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()