# -*- coding: utf-8 -*- u"""下板 PCB1 五路功率级的**手工布线** —— §10.2 那四条关键回路的事实来源。 /c/Python314/python .claude/pcb1_route_pwr.py # 计划 + 全部校核 /c/Python314/python .claude/pcb1_route_pwr.py --push # 校核跑绿才下发,完事自动回读 /c/Python314/python .claude/pcb1_route_pwr.py --verify # 只按板上实测铜再核一遍 /c/Python314/python .claude/pcb1_route_pwr.py --clean --push # 先删掉上一版再重画 /c/Python314/python .claude/pcb1_route_pwr.py --ch 1 # 只看某一路(调试用) ## 为什么这几条不交给栅格布线器 §10.2 的四条:热回路面积、SW 铜箔面积、FB 短且远离 SW、分流电阻的 Kelvin 四线 —— **每一条的判据都不是「连通」**,栅格布线器优化的是长度和过孔数, 它给出的解可以完全连通而四条全不满足。所以这四条按母版手写坐标, 剩下的细信号(EN 分压、补偿、PG、按键驱动、eFuse 编程电阻)留给后面的信号布线。 ## 坐标系 路径全部写在 **CH1 母版的局部坐标**里(和 `pcb1_place.CH1` 同一套), 再按 `pcb1_place.CH_PLACE` 仿射到五路:rot 0 是平移,rot 180 是 `(bx-lx, by-ly)`。 ⇒ **改一处等于五路一起改**,这也是 §26.3「不做 y 镜像,只转 180°」的用处: 母版里手调出来的关键回路原样保留。 ## 网络名一律反查,不写死 四条主干里有三条挂在自动命名网上(SW=`$2N665`、eFuse 输出=`$2N699`、BOOT=`$2N667`), 而且**每一路的编号都不一样**(CH1 是 `$2N…`、CH2 是 `$8N…`、CH4 是 `$12N…`)。 写死等于静默失效(`pcb1_rules.py` 文件头第 4 条踩过)。 这里按 `(位号模板, 引脚号)` 到 `pcb1-parts.json` 里现查。 ## 校核(下发前后走同一段代码) 1. 每条路径至少落在一个**本网焊盘**里,并列出它实际盖住了哪些焊盘 2. 逐段「胶囊 vs 轴对齐框」净距 ≥ 0.2 mm:异网焊盘 / 异网过孔 / 同层异网走线 / 本轮其它异网段 ⚠️ **线的端点不是铜的端点**:圆头端帽往外再探半个线宽(§26.11 那一条), 所以这里一律按胶囊算,不按线段算。 3. 拐角角度:< 90° 的尖角报出来(EasyEDA 的 45° 化只处理直角) 4. 载流:按 IPC-2221 外层 1 oz 反算每一段的 ΔT,超过 30 °C 的报出来 """ import io import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import pcb1_place as PL # noqa: E402 from eda import run # noqa: E402 PCB = '7e9415a16a909c51' MIL = PL.MIL L_TOP, L_BOT = 1, 2 CLR = 0.2 # pcb1_rules.MIN_CLEARANCE VIA_R = 0.305 # 过孔铜箔半径(外径 0.61) EPS = 1e-6 SNAP = os.path.join(HERE, '_pcb1_place_now.json') # ══════════════════ 母版路径(CH1 局部坐标,mm)══════════════════ # net: ('pad', 位号模板, 引脚号) —— 到 pcb1-parts.json 里按这一路的位号反查网名 # pts: [(x, y), ...];w 是整条路径的线宽(要变宽就拆成两条) R = [] def add(name, net, w, pts, i=0.0, layer=L_TOP, why=u''): u"""i = 这一段实际要过多少电流(A),0 = 细信号不算温升。""" R.append(dict(name=name, net=net, w=w, pts=pts, i=i, layer=layer, why=why)) # ── ① 热回路:+12V 走顶层,地在正下方的 Inner1 整平面上回流 ──────────── # §10.2-1「2 颗 10 µF MLCC 必须同层紧贴 IC 的 VIN/GND 引脚」。 # ⚠️ **地不另画顶层线**:Inner1 就在顶层下面 0.2104 mm,回流电流是镜像电流, # 它贴着顶层这条 +12V 走,回路面积 = 走线长 x 0.21 mm。 # 要是让 +12V 下到 Inner2(L3)再回来,回路高度变成 L2-L3 的 1.13 mm,**差 5 倍**。 _12V = ('pad', 'C%01', '1') add('12V-C101->U1', _12V, 1.0, [(4.001, 10.299), (6.600, 11.000)], i=1.6, why=u'C%01 的 +12V 脚直接上 U%1 的 PVIN(p6),全程顶层') add('12V-U1-tie', _12V, 0.4, [(6.600, 11.000), (6.600, 11.950)], i=1.1, why=u'把 PVIN 三个脚 p6/p5/p4 串起来;北端停在 11.95,' u'离 p3(GND, 底 12.361) 还有 0.211') # ⚠️ C%02 / C%03 到 U%1 的顶层直路是**堵死的**:C%01 的 GND 扇出孔 (5.475,12.300) # 和 U%1.2 的 (5.922,13.354) 把那条 1.7 mm 宽的走廊切成 0.148 mm。 # ⇒ 从西边绕一条母线,把三颗罐的 +12V 脚在顶层并成一个节点再一起进 U%1。 add('12V-bus', _12V, 0.8, [(2.550, 8.701), (2.550, 13.949)], i=1.1, why=u'西侧 +12V 母线:C%03 / C%01 / C%02 三颗的 +12V 脚并到一起') add('12V-bus-C103', _12V, 0.8, [(2.550, 8.701), (4.001, 8.701)], i=0.6, why=u'母线 -> C%03.2') add('12V-bus-C101', _12V, 0.8, [(2.550, 10.299), (4.001, 10.299)], i=1.1, why=u'母线 -> C%01.1') add('12V-bus-C102', _12V, 0.8, [(2.550, 13.949), (4.001, 13.949)], i=0.6, why=u'母线 -> C%02.1') # ── ② SW 节点:面积压到刚好够载流(§10.2-2)──────────────────────────── # ⚠️ U%1 的 SW 脚(p11/p12)夹在 p10(EN) 与 p13(BOOT) 中间,0.5 mm 间距, # 只能先用窄线往东出 1 mm,出了焊盘区再放宽。 # ⚠️ 窄到 **0.30 就到底了**,不是 0.28:`pcb1_rules.TRACK_RULES` 里 # SWNODE / PWR4A 的 `strokeWidthMin` 都是 0.30(= 11.8 mil)。 # 先按 0.28 下发过一次,DRC 立刻报 50 条**物理错误**「线宽 11mil,应该 ≥ 11.8mil」—— # 间距错误一条没有、连接性也降了,光看那两支会以为全过。 # 0.30 的线从 0.28 高的焊盘出来,上下各留 0.21,正好还够。 _SW = ('pad', 'L%01', '1') add('SW-p11', _SW, 0.30, [(10.224, 12.001), (11.200, 12.001)], i=2.0, why=u'U%1.11 出线(短桩)') # ⚠️ p12 的短桩往东多走 0.3(11.200 -> 11.500):`SW-run` 那条 1.2 宽的线, # 西端圆头端帽半径 0.6,起点放 11.200 时端帽离 **U%1.13(BOOT) 焊盘**只有 0.080。 # 起点东移到 11.500 之后是 0.347。短桩本身 0.30 宽、离 BOOT 焊盘 0.21,没变。 add('SW-p12', _SW, 0.30, [(10.224, 12.501), (11.500, 12.501)], i=2.0, why=u'U%1.12 出线(短桩)') add('SW-tie', _SW, 0.30, [(11.200, 12.001), (11.200, 12.501)], i=2.0, why=u'两个 SW 脚并起来(短桩)') # ⚠️⚠️ **SW 不再往南潜了(2026-09-06 第二十一轮)。** 老版本是 # `SW-down (11.200,12.250)->(11.750,11.600)` + `SW-run` 东行再 45° 抬回来, # 目的是从 C%05.1(BOOT) 焊盘**下面**绕过去(那时它的焊盘底在 12.732,缝只有 0.581)。 # 代价是那一潜正好压在 **p9(COMP) / p10(EN) 唯一的出线走廊**上 —— # 1.5 mm 宽的 `SW-run` 西端圆头端帽在 y = 11.5 那一行往西探到 x 11.007, # 而两个脚要并排出来至少需要到 x 11.62。⇒ 两个脚只能活一个,`EN` 五路全布不通 # (`pcb1_escape_audit.py` 实测:两层加起来只能走 0.28 mm²)。 # ⇒ **把 C%05 北移 0.6 mm**(见 `pcb1_place.CH1` 里那一段),BOOT 焊盘底变 13.332, # SW 就能平着走 y 12.500(1.2 宽,顶 13.10,离 BOOT 焊盘 0.232)。 # y < 11.9 整片空出来:COMP 走 y 11.001、EN 走 y 11.501,各走自己那一行。 # 顺带:SW 铜面积 10.9 -> 9.5 mm²、节点长度 9.6 -> 8.9 mm,**§10.2-2 要的就是小**。 # ⚠️ 立管 x 13.500 宽 1.50 ⇒ 铜跨 12.75~14.25,**顺路盖住 C%05.2(SW) 的焊盘** # (12.570~13.070),所以不用再单画一段去接自举电容。 add('SW-run', _SW, 1.20, [(11.500, 12.500), (13.500, 12.500)], i=4.0, why=u'东行段:平着走 p12 那一行,从 C%05.1(BOOT,底 13.332) 下面过,净距 0.232') add('SW-riser', _SW, 1.50, [(13.500, 12.500), (13.500, 16.600)], i=4.0, why=u'立管直上进 L%01 的 SW 脚(焊盘 12.501~14.501 / 16.301~19.701),' u'顺路盖住 C%05 的 SW 脚 (12.820,13.001)') # ── BOOT:自举电容,贴着 p13 平着往东 1.8 mm ─────────────────────────── _BOOT = ('pad', 'C%05', '1') # 警告:BOOT 不能和 SW 走同一条水平缝。原来两条都在 y≈12.5~13.0 那一带, # SW-down 放宽到 1.0 之后圆头端帽顶到 12.75,离 BOOT 只剩 0.101。 # 改成 BOOT 先往**北**抬到 13.75(U%1 北边是空的,p14 在 x<=9.391), # 平着过去再往南扎进 C%05.1,两条从此不共用任何一条缝。 # C%05 北移 0.6 之后 C%05.1 的焊盘是 y 13.332~13.872,(11.979,13.750) 直接落在里面, # 不用再往南扎一段。 add('BOOT', _BOOT, 0.30, [(10.224, 13.001), (10.900, 13.750), (11.979, 13.750)], why=u'U%1.13 -> C%05.1,从 U%1 北边绕过去;终点落在焊盘 (13.332~13.872) 正中') # ── ③ VOn:电感 -> 三颗输出电容 -> eFuse 输入 ───────────────────────── _VO = ('pad', 'L%01', '2') add('VO-up', _VO, 1.50, [(7.900, 18.400), (7.900, 19.400)], i=4.0, why=u'L%01.2 焊盘内往北') add('VO-diag', _VO, 1.50, [(7.900, 19.400), (6.100, 21.200), (5.200, 22.100)], i=4.0, why=u'两段 45° 转到 x=5.200 这一列') add('VO-col', _VO, 1.35, [(5.200, 22.100), (5.200, 28.400)], i=4.0, why=u'VO 立列:正好穿过 C%07.1 / C%06.1 的焊盘,北端进 U%14 的 p11/p12/p13') add('VO-C108', _VO, 0.50, [(5.200, 22.700), (7.979, 22.501)], i=0.5, why=u'支到 C%08.1(高频去耦,只过纹波)') add('VO-U14w', _VO, 0.30, [(5.200, 28.400), (4.300, 28.400), (4.001, 28.700), (4.001, 30.000)], i=1.0, why=u'补 U%14 西列的两个 VO 脚 p10/p9;绕开散热盘西缘 4.700') # ── ④ eFuse 输出 $2N699 -> 分流电阻上游 ─────────────────────────────── _F = ('pad', 'Rs%01', '2') add('F-row', _F, 1.00, [(5.399, 31.840), (3.650, 31.840)], i=2.5, why=u'U%14 北排三个输出脚;东端停在 5.550,离 p3($2N698, 西 6.099) 0.249') add('F-p78', _F, 0.60, [(4.001, 30.750), (4.001, 31.840)], i=1.5, why=u'补西列的 p8/p7;南端停在 30.650,离 p9(VO, 顶 30.200) 0.25') add('F-corner', _F, 1.20, [(3.650, 31.840), (3.050, 32.440)], i=4.0, why=u'45° 转北') # ⚠️ 北端 39.300 -> 40.100:Rs%01 北移 0.8(见 pcb1_place 里那一段)。 add('F-trunk', _F, 1.20, [(3.050, 32.440), (3.050, 40.100)], i=4.0, why=u'主干北上,走 U%8 西边;离 U%8.1 的 GND 扇出孔 (4.279,34.696) 0.324') # ── ⑤ Kelvin 四线(§10.2-4)────────────────────────────────────────── # ⚠️ 两条取样线**并排往北走 1.7 mm 再分开**,这是这一轮把 Rs%01 转 180° 换来的: # 转之前「西 = VO_P / 东 = $2N699」而 INA 的 IN+ 在西、IN− 在东,两条必然交叉。 # ⚠️ IN− 那条必须从**电阻本体下面**(两个焊盘之间)横过去,而且要绕到 # U%8.7 的 GND 扇出孔 (6.500,39.732) **北边** —— 它南边那条缝只有 0.208 mm。 # ⚠️ 这两条整体 +0.8(U%8 和 Rs%01 一起北移,相对关系一格没变)。 add('K-IN+', _F, 0.30, [(4.960, 38.409), (4.960, 39.900), (4.760, 40.100), (3.300, 40.100)], why=u'U%8.10(IN+) -> Rs%01.2 的焊盘。取样点在焊盘上,不在主干上') _VOP = ('pad', 'Rs%01', '1') add('K-IN-', _VOP, 0.30, [(5.500, 38.409), (5.500, 40.300), (5.700, 40.500), (8.600, 40.500)], why=u'U%8.9(IN−) -> Rs%01.1 的焊盘。**U%8.9 没有下到 VO_P 小岛的过孔**' u'(pcb1_pour.ROUTED_PADS 里挡掉的),不然取样点会落到岛上') # ── ⑥ FB:短、且远离 SW(§10.2-3)──────────────────────────────────── _FB = ('pad', 'R%01', '2') # ⚠️ 不能从 x≈6.07 直下:那条线正压在 R%08.1 ($2N655) 的焊盘上; # 往西绕也不行 —— C%03 的东缘 4.676 到它自己那个 GND 扇出孔的西缘 5.170 # 只剩 0.494 mm 的缝,0.25 的线要 0.65。⇒ 走 R%08 **东边**,到 y≈4.5 再往西折。 add('FB-U1', _FB, 0.25, [(7.750, 10.276), (7.750, 4.500), (7.150, 3.900), (6.066, 3.900), (6.066, 3.001)], why=u'U%1.7(FB) 往南出,贴着散热盘南爪西边 0.224 下来,全程在 SW 的**反面**') add('FB-R101', _FB, 0.25, [(6.066, 3.001), (3.432, 3.001)], why=u'R%07.1 -> R%01.2') add('FB-R102', _FB, 0.20, [(3.432, 3.001), (3.432, 4.000), (2.566, 4.866), (2.566, 5.201)], why=u'R%01.2 -> R%02.1,绕开 R%01.1(VO) 和 R%02.2(GND)') # ══════════════════ 几何 ══════════════════ def parts(): return {c['d']: c for c in json.load( io.open(os.path.join(HERE, 'pcb1-parts.json'), encoding='utf-8'))} PARTS = parts() def net_of(spec, n): u"""('pad', 位号模板, 引脚号) -> 这一路上那个焊盘的网名(现查,不写死)。""" _, tok, pin = spec des = PL.ch_designator(tok, n) p = PARTS.get(des) if not p: sys.exit(u'反查网名失败:没有位号 %s' % des) for num, _lx, _ly, _w, _h, net in p['pads']: if str(num) == pin: if not net: sys.exit(u'反查网名失败:%s.%s 没有网络' % (des, pin)) return net, '%s.%s' % (des, pin) sys.exit(u'反查网名失败:%s 没有引脚 %s' % (des, pin)) def xf(n, x, y): u"""母版局部坐标 -> 板坐标。rot 180 的两路是纯旋转,不是镜像(§26.3)。""" rot, bx, by = PL.CH_PLACE[n] return (bx + x, by + y) if rot == 0 else (bx - x, by - y) def board_paths(only=None): u"""展开成板上的实际路径 [(名字, 网名, 层, 线宽, [(x,y)...], 通道, 为什么)]。""" out = [] for n in sorted(PL.CH_PLACE): if only and n != only: continue for r in R: net, anchor = net_of(r['net'], n) pts = [xf(n, x, y) for x, y in r['pts']] out.append(dict(name='%s@CH%d' % (r['name'], n), net=net, layer=r['layer'], w=r['w'], pts=pts, ch=n, anchor=anchor, i=r['i'], why=r['why'].replace('%', str(n)))) return out COPPER = os.path.join(HERE, 'pcb-copper.json') def poly_pads(): u"""板上 POLYGON 焊盘的**真实轮廓**(板坐标 mm),键是 `位号.引脚`。 ⚠️ 非要真轮廓不可:U%1 的散热盘是个**十字** —— 中间 2.03 x 2.03 的方块, 上下各两根 0.24 mm 宽的指头伸到 ±2.16。按包围盒算的话, FB 那条从 U%1.7 往南出的线(x 7.75)会被判成压在散热盘上, 而实际上它离最近的那根指头还有 0.224 mm。反过来也一样危险: 包围盒宽出来的那一圈会**掩盖**真正贴到指头上的走线。 """ d = json.load(io.open(COPPER, encoding='utf-8')) out = {} for q in d['pads']: sh = q.get('shape') if not sh or sh[0] != 'POLYGON' or not q.get('des'): continue pts = [v for v in sh[1] if isinstance(v, (int, float))] out['%s.%s' % (q['des'], q['num'])] = list(zip( [v / MIL for v in pts[0::2]], [v / MIL for v in pts[1::2]])) return out POLY = poly_pads() def all_pads(): u"""板上全部焊盘 [(x0,y0,x1,y1,net,tag,poly_or_None)]。""" out = [] for d, (x, y, rot) in PL.POS.items(): a = math.radians(rot) ct, st = math.cos(a), math.sin(a) p = PARTS.get(d) if not p: continue for num, lx, ly, w, h, net in p['pads']: lx, ly, w, h = lx / MIL, ly / MIL, w / MIL, h / MIL if rot in (90, 270): w, h = h, w px, py = x + lx * ct - ly * st, y + lx * st + ly * ct tag = '%s.%s' % (d, num) out.append((px - w / 2.0, py - h / 2.0, px + w / 2.0, py + h / 2.0, net or '', tag, POLY.get(tag))) return out def seg_poly_dist(ax, ay, bx, by, poly): u"""线段到多边形的最短距离(端点在里面 -> 0)。""" def inside(px, py): c = False n = len(poly) for i in range(n): x1, y1 = poly[i] x2, y2 = poly[(i + 1) % n] if (y1 > py) != (y2 > py) and px < (x2 - x1) * (py - y1) / (y2 - y1) + x1: c = not c return c if inside(ax, ay) or inside(bx, by): return 0.0 d = 1e9 n = len(poly) for i in range(n): x1, y1 = poly[i] x2, y2 = poly[(i + 1) % n] d = min(d, seg_seg_dist(ax, ay, bx, by, x1, y1, x2, y2)) return d def seg_pad_dist(ax, ay, bx, by, pad): poly = pad[6] if poly: return seg_poly_dist(ax, ay, bx, by, poly) return seg_box_dist(ax, ay, bx, by, pad[:4]) def snapshot(refresh=False): if refresh or not os.path.exists(SNAP): import pcb1_pour as P return P.snapshot(refresh=True) return json.load(io.open(SNAP, encoding='utf-8')) def seg_box_dist(ax, ay, bx, by, b): u"""线段到轴对齐框的最短距离(0 = 相交)。""" x0, y0, x1, y1 = b def pt(px, py): dx = max(x0 - px, 0.0, px - x1) dy = max(y0 - py, 0.0, py - y1) return math.hypot(dx, dy) # 端点到框 + 框的四条边到线段 d = min(pt(ax, ay), pt(bx, by)) for cx, cy, dx, dy in ((x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)): d = min(d, seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy)) return d def seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy): def d_pt_seg(px, py, x1, y1, x2, y2): vx, vy = x2 - x1, y2 - y1 L = vx * vx + vy * vy t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - x1) * vx + (py - y1) * vy) / L)) return math.hypot(px - (x1 + t * vx), py - (y1 + t * vy)) def cr(ox, oy, px, py, qx, qy): return (px - ox) * (qy - oy) - (py - oy) * (qx - ox) d1 = cr(ax, ay, bx, by, cx, cy) d2 = cr(ax, ay, bx, by, dx, dy) d3 = cr(cx, cy, dx, dy, ax, ay) d4 = cr(cx, cy, dx, dy, bx, by) if d1 * d2 < 0 and d3 * d4 < 0: return 0.0 return min(d_pt_seg(ax, ay, cx, cy, dx, dy), d_pt_seg(bx, by, cx, cy, dx, dy), d_pt_seg(cx, cy, ax, ay, bx, by), d_pt_seg(dx, dy, ax, ay, bx, by)) def amps(w_mm, dT=20.0): u"""外层 1 oz 的载流(IPC-2221,常数 0.048 / 铜厚 1.37 mil)。""" A = w_mm / 0.0254 * 1.37 return 0.048 * (dT ** 0.44) * (A ** 0.725) def dT_for(w_mm, i_amp): a20 = amps(w_mm, 20.0) return 20.0 * (i_amp / a20) ** (1.0 / 0.44) if a20 > 0 else 999.0 # 短于这个长度的段不查温升。IPC-2221 那条式子假定的是**长而均匀**的导体, # 而 QFN 0.5 mm 间距的脚只能用 0.28 的窄线出 1 mm —— 两端都焊在大铜上, # 拿它去套公式会算出 265 °C 这种没有物理意义的数。真实做法就是这样出线。 STUB_LEN = 2.0 DT_LIMIT = 30.0 def check(paths, refresh=False, use_board=False): u"""下发前 / 下发后共用的一整套校核。返回 (错误列表, 统计)。""" errs, notes = [], [] pads = all_pads() snap = snapshot(refresh) vias = [(v[0] or '', v[1], v[2], (v[3] or 0.61) / 2.0) for v in snap.get('via', ())] lines = [(l[0] or '', l[1], l[2], l[3], l[4], l[5], l[6] or 0.0) for l in snap.get('line', ())] # 1) 落脚 + 组内连通。 # 警告:判据是**按 (通道, 网络) 分组**算的,不是每条路径各自算。 # 像 SW-tie / 12V-bus 这种中间段本来就不碰焊盘,逐条查会刷一屏假错误; # 真正要保证的是「这一组铜连成一片,而且落在本网的焊盘上」。 for p in paths: hit = [] for pad in pads: if pad[4] != p['net']: continue for i in range(len(p['pts']) - 1): ax, ay = p['pts'][i] bx, by = p['pts'][i + 1] if seg_pad_dist(ax, ay, bx, by, pad) < p['w'] / 2.0 - EPS: hit.append(pad[5]) break p['hit'] = sorted(set(hit)) groups = {} for p in paths: groups.setdefault((p['ch'], p['net']), []).append(p) for (ch, net), g in sorted(groups.items()): if not any(p['hit'] for p in g): errs.append(u'CH%d %-9s 整组一个本网焊盘都没盖到' % (ch, net)) par = list(range(len(g))) def find(i): while par[i] != i: par[i] = par[par[i]] i = par[i] return i for i in range(len(g)): for j in range(i + 1, len(g)): touch = False for a in range(len(g[i]['pts']) - 1): for b in range(len(g[j]['pts']) - 1): d = seg_seg_dist(g[i]['pts'][a][0], g[i]['pts'][a][1], g[i]['pts'][a + 1][0], g[i]['pts'][a + 1][1], g[j]['pts'][b][0], g[j]['pts'][b][1], g[j]['pts'][b + 1][0], g[j]['pts'][b + 1][1]) if d < (g[i]['w'] + g[j]['w']) / 2.0 - EPS: touch = True break if touch: break if touch: par[find(i)] = find(j) for i in range(len(g)): for j in range(i + 1, len(g)): if set(g[i]['hit']) & set(g[j]['hit']): par[find(i)] = find(j) ncomp = len(set(find(i) for i in range(len(g)))) if ncomp > 1: errs.append(u'CH%d %-9s 这一组铜断成 %d 块(%s)' % (ch, net, ncomp, ' '.join(p['name'].split('@')[0] for p in g))) # 2) 净距 def seg_clear(p, ax, ay, bx, by): m = p['w'] / 2.0 + CLR bad = [] for pad in pads: if pad[4] == p['net']: continue d = seg_pad_dist(ax, ay, bx, by, pad) - p['w'] / 2.0 if d < CLR - 1e-4: bad.append((d, u'焊盘 %s(%s)' % (pad[5], pad[4] or '-'))) for net, vx, vy, vr in vias: if net == p['net']: continue d = seg_box_dist(ax, ay, bx, by, (vx - vr, vy - vr, vx + vr, vy + vr)) \ - p['w'] / 2.0 if d < CLR - 1e-4: bad.append((d, u'过孔 %s @(%.2f,%.2f)' % (net or '-', vx, vy))) for net, lay, lx1, ly1, lx2, ly2, lw in lines: if net == p['net'] or lay != p['layer']: continue d = seg_seg_dist(ax, ay, bx, by, lx1, ly1, lx2, ly2) \ - p['w'] / 2.0 - lw / 2.0 if d < CLR - 1e-4: bad.append((d, u'走线 %s @(%.2f,%.2f)' % (net or '-', lx1, ly1))) for q in paths: if q is p or q['net'] == p['net'] or q['layer'] != p['layer']: continue for j in range(len(q['pts']) - 1): cx, cy = q['pts'][j] dx, dy = q['pts'][j + 1] d = seg_seg_dist(ax, ay, bx, by, cx, cy, dx, dy) \ - p['w'] / 2.0 - q['w'] / 2.0 if d < CLR - 1e-4: bad.append((d, u'本轮 %s(%s)' % (q['name'], q['net']))) return bad for p in paths: for i in range(len(p['pts']) - 1): ax, ay = p['pts'][i] bx, by = p['pts'][i + 1] for d, who in sorted(seg_clear(p, ax, ay, bx, by))[:3]: errs.append(u'%-22s 第%d段 净距 %.3f mm(要 %.2f)-> %s' % (p['name'], i + 1, d, CLR, who)) # 3) 尖角 for p in paths: for i in range(1, len(p['pts']) - 1): x0, y0 = p['pts'][i - 1] x1, y1 = p['pts'][i] x2, y2 = p['pts'][i + 1] a1 = math.atan2(y0 - y1, x0 - x1) a2 = math.atan2(y2 - y1, x2 - x1) ang = abs(math.degrees(a1 - a2)) % 360 ang = min(ang, 360 - ang) if ang < 89.0: errs.append(u'%-22s 第%d个拐点是 %.0f° 的尖角' % (p['name'], i, ang)) # 4) 载流 for p in paths: if p['i'] <= 0: continue p['dT'] = dT_for(p['w'], p['i']) if p['dT'] > DT_LIMIT and length(p) > STUB_LEN: errs.append(u'%-22s 线宽 %.2f mm 带 %.1f A 温升 %.0f °C(上限 %.0f)' % (p['name'], p['w'], p['i'], p['dT'], DT_LIMIT)) elif p['dT'] > DT_LIMIT: notes.append(u'%-22s %.2f mm / %.1f A / 长 %.2f mm —— 短桩,不套 IPC 公式' % (p['name'], p['w'], p['i'], length(p))) return errs, notes def length(p): return sum(math.hypot(p['pts'][i + 1][0] - p['pts'][i][0], p['pts'][i + 1][1] - p['pts'][i][1]) for i in range(len(p['pts']) - 1)) def report(paths): print(u'下板 PCB1 五路功率级手工布线(§10.2 的四条关键回路)') print(u' 母版 %d 条路径 x 5 路 = %d 条;全部顶层' % (len(R), len(paths))) ch1 = [p for p in paths if p['ch'] == 1] print(u'') print(u' %-16s %-9s %5s %5s %6s %7s %s' % (u'路径(CH1)', u'网络', u'线宽', u'电流', u'长度', u'ΔT', u'盖到的焊盘')) for p in ch1: dt = (u'%5.0f°C' % p['dT']) if p['i'] > 0 and 'dT' in p else u' -' print(u' %-16s %-9s %5.2f %5.1f %6.2f %7s %s' % (p['name'].split('@')[0], p['net'], p['w'], p['i'], length(p), dt, ' '.join(p['hit'])[:44])) tot = sum(length(p) for p in ch1) sw = sum(length(p) for p in ch1 if p['name'].startswith('SW')) sw_a = sum(length(p) * p['w'] for p in ch1 if p['name'].startswith('SW')) print(u'') print(u' 一路合计 %.1f mm;其中 **SW 节点 %.1f mm / 铜面积 %.1f mm²**(§10.2-2)' % (tot, sw, sw_a)) # ══════════════════ 下发 ══════════════════ def push(paths): seg = [] for p in paths: for i in range(len(p['pts']) - 1): (ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1] seg.append({'net': p['net'], 'lay': p['layer'], 'x1': ax * MIL, 'y1': ay * MIL, 'x2': bx * MIL, 'y2': by * MIL, 'w': p['w'] * MIL}) JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1500)); const S = %s; let n = 0; for (const s of S) { const ln = await eda.pcb_PrimitiveLine.create(s.net, s.lay, s.x1, s.y1, s.x2, s.y2, s.w); if (ln) n++; } await eda.pcb_Document.save(); return {n, totalLine: (await eda.pcb_PrimitiveLine.getAll()).length}; """ % (PCB, json.dumps(seg)) r = run(JS, timeout=900) if not r.get('success'): print(u'失败:', json.dumps(r, ensure_ascii=False)[:500]) sys.exit(1) print(u' 下发:新建走线 %d 段,板上走线总数 %d' % (r['result']['n'], r['result']['totalLine'])) return len(seg) def verify(n_expect=None): u"""按板上**实测**的线再核一遍。 警告:判据是**覆盖**,不是「端点对得上」。 EasyEDA 会在 T 形接点上把已有的线**劈成两条** —— 西侧那条 +12V 母线 (2.550, 8.701)->(2.550, 13.949) 下发之后,板上是 (2.550,8.701)-(2.550,10.299) 和 (2.550,10.299)-(2.550,13.949) 两条, 因为 C%01 那条支线正好接在中间。按端点比会报「40 段对不上」, 而板上的铜一点没少。⇒ 沿着计划的每一段每 0.2 mm 取一个点, 要求都被某条同网同层同线宽的实测线盖住。 """ snap = snapshot(refresh=True) on = [(l[0] or '', l[1], l[2], l[3], l[4], l[5], round(l[6] or 0, 3)) for l in snap.get('line', ())] paths = board_paths() miss, nseg = [], 0 for p in paths: # ⚠️ **不能只认「同宽」的线。** 一段窄线整个落在一条宽线的圆头端帽里时, # EasyEDA 会把它**直接吞掉**(实测:SW-p12 东端那 0.3 mm 落在 SW-run # 1.2 mm 的西端帽里,板上就没有那一小段了)—— 铜一点没少,按同宽比却报 5 条。 # 正确判据是「计划的那段铜**被实测铜包住**」:实测线不窄于计划线, # 且中心距 ≤ (o_w − p_w) / 2。 cand = [o for o in on if o[0] == p['net'] and o[1] == p['layer'] and o[6] >= p['w'] - 0.011] for i in range(len(p['pts']) - 1): nseg += 1 (ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1] L = math.hypot(bx - ax, by - ay) n = max(2, int(L / 0.2) + 1) bad = 0 for k in range(n + 1): t = k / float(n) px, py = ax + (bx - ax) * t, ay + (by - ay) * t if not any(_d_pt_seg(px, py, o[2], o[3], o[4], o[5]) <= (o[6] - p['w']) / 2.0 + 0.02 for o in cand): bad += 1 if bad: miss.append(u'%s 第%d段(%d/%d 个采样点没被盖住)' % (p['name'], i + 1, bad, n + 1)) print(u' 回读:板上 %d 条走线;本轮 %d 段里没被实测铜盖住的 %d 条' % (len(on), nseg, len(miss))) for m in miss[:10]: print(u' [!!] %s' % m) return miss def _d_pt_seg(px, py, x1, y1, x2, y2): vx, vy = x2 - x1, y2 - y1 L = vx * vx + vy * vy t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - x1) * vx + (py - y1) * vy) / L)) return math.hypot(px - (x1 + t * vx), py - (y1 + t * vy)) def clean(paths): u"""只删**本轮画的**那些线。 警告:不能按线宽删。板上同一个网络上还有扇出的短线(`pcb_fanout` 的 TRK_W_TIGHT 也用 0.20 / 0.30),按宽度一刀切会把它们一起端掉。 判据是**两个端点都落在计划路径的走廊里**(0.02 mm)—— EasyEDA 会在 T 形接点上劈线,所以劈出来的两段也照样被认出来。 """ snap = snapshot(refresh=True) kill = [] for idx, (net, lay, x1, y1, x2, y2, w) in enumerate( [(l[0] or '', l[1], l[2], l[3], l[4], l[5], round(l[6] or 0, 3)) for l in snap.get('line', ())]): for p in paths: if p['net'] != net or p['layer'] != lay or abs(p['w'] - w) > 0.01: continue for i in range(len(p['pts']) - 1): (ax, ay), (bx, by) = p['pts'][i], p['pts'][i + 1] if (_d_pt_seg(x1, y1, ax, ay, bx, by) < 0.02 and _d_pt_seg(x2, y2, ax, ay, bx, by) < 0.02): kill.append((net, lay, x1, y1, x2, y2, w)) break else: continue break if not kill: print(u' 清理:板上没有本轮画过的线') return 0 JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1500)); const K = %s, M = 39.3701, q = v => Math.round(v / M * 1e4) / 1e4; const del = []; for (const l of await eda.pcb_PrimitiveLine.getAll()) { for (const k of K) { if ((l.net || '') === k[0] && l.layer === k[1] && Math.abs(q(l.startX) - k[2]) < 0.005 && Math.abs(q(l.startY) - k[3]) < 0.005 && Math.abs(q(l.endX) - k[4]) < 0.005 && Math.abs(q(l.endY) - k[5]) < 0.005) { del.push(l.primitiveId); break; } } } if (del.length) await eda.pcb_PrimitiveLine.delete(del); await eda.pcb_Document.save(); return {n: del.length, left: (await eda.pcb_PrimitiveLine.getAll()).length}; """ % (PCB, json.dumps(kill)) r = run(JS, timeout=900) if not r.get('success'): print(u'失败:', json.dumps(r, ensure_ascii=False)[:400]) sys.exit(1) print(u' 清理:删掉本轮的 %d 条(认出 %d 条),板上还剩 %d 条走线' % (r['result']['n'], len(kill), r['result']['left'])) return r['result']['n'] def main(): only = None if '--ch' in sys.argv: only = int(sys.argv[sys.argv.index('--ch') + 1]) paths = board_paths(only) errs, notes = check(paths, refresh='--push' in sys.argv or '--verify' in sys.argv) report(paths) print(u'') if errs: print(u'!! %d 个问题:' % len(errs)) for e in errs[:40]: print(u' ' + e) if len(errs) > 40: print(u' ... 还有 %d 条' % (len(errs) - 40)) sys.exit(1) if notes: print(u' 提示 %d 条:' % len(notes)) for t in notes: print(u' · ' + t) print(u'✅ 全部校核通过(本网落脚 / 组内连通 / 净距 / 拐角 / 载流)') if '--verify' in sys.argv: verify() return if '--push' in sys.argv: if '--clean' in sys.argv: clean(paths) push(paths) verify() else: print(u'(未加 --push,没有下发)') if __name__ == '__main__': main()