# -*- coding: utf-8 -*- u"""五路 `VOn_P` 的**贴片电容接到 Inner1 电源岛** —— 每个焊盘旁边打一个过孔。 /c/Python314/python .claude/pcb2_route_dp.py --dump # 1. 先刷障碍表 /c/Python314/python .claude/pcb2_vo_vias.py # 2. 只算 + 本地净距校核 /c/Python314/python .claude/pcb2_vo_vias.py --push # 3. 下发 /c/Python314/python .claude/pcb2_pour.py --push # 4. **必须重铺**(新过孔要反焊盘) ## 为什么要有这一步 `VO1_P`~`VO5_P` 每个网络 10 个焊盘:**5 个通孔**(`C8n1.1` Ø10 电解正极、`TP8n1` 测试点、排母 3 针)本身就落在 Inner1 的岛上,不用管;剩下 **5 个贴片焊盘** (`C8n2`/`C8n3` 100µF 1206、`C8n4`/`C8n5` 22µF 1206、`C8n6` 100nF 0402) 只有顶层铜,**和岛之间没有任何通路** —— 这就是 §25.14 里连接性 51 条中的 31 条。 所以这一步和布线不是一回事:不需要找路径,只需要**在焊盘边上打一个过孔**, 用一小段线接过去,垂直下到 Inner1 的岛上。25 个焊盘 = 25 个过孔。 ## 选点判据(不是"看着差不多") | | | |---|---| | 方向 | 只取 8 个方向(0/45/90…),**不许出现斜的怪角度**(全板 45° 化,§25.14)| | 起步距离 | 过孔铜边到本焊盘矩形 **≥ 0.15 mm**(同网,不是 DRC 约束,是不想做 via-in-pad)| | 净距 | 过孔和那一小段线对**异网**焊盘/过孔/导线 ≥ 0.20 —— 直接借 `pcb2_route_dp.check()` | | 岛内余量 | 过孔中心离岛多边形边界 ≥ 0.61/2 + 0.30 = **0.605 mm**,免得铺出来只搭上一点点 | | 线宽 | `PWR4A` 最小 0.30;从 1.00 起往下退让,取第一个过的 | ⚠️ 过孔打在**路径末点**上 —— `expand()` 会静默丢掉末端过孔(§25.14 那条最阴的 bug)。 这里靠把末点写两遍绕开(`_path()` 里 `(vx,vy,L_BOT)` 后面再来一份)。 """ import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import pcb2_route_dp as R from pcb2_pour import ISLANDS, island_poly W_STEPS = [1.00, 0.80, 0.60, 0.50, 0.40, 0.30] # PWR4A 的 strokeWidthMin = 0.30 PAD_GAP = 0.15 # 过孔铜边到本焊盘的余量(同网,防 via-in-pad) EDGE_IN = R.VIA_D / 2.0 + 0.30 # 过孔中心到岛边界的余量 D_MAX = 3.5 D_STEP = 0.05 DRILL = 0.33 # 钻孔间距(规则 otherClearance / copperThickness1oz 都要求 >= 11.8mil # = 0.2997 mm),留 30 µm 余量。再大就过不了 J23 的屏蔽脚,见 place() 的注释 DIRS = [(math.cos(math.radians(a)), math.sin(math.radians(a))) for a in range(0, 360, 45)] # ── 哪些位号要打孔、各打几个 ───────────────────────────────────────── # 2026-09-06 补两类(上一版只有 25 个贴片电容): # # `C8n1.1` —— 换成 Rubycon 6.3TPV2200M10X10.5 之后**是贴片**(V-chip), # 原来当它是直插电解、以为正极自己就落在岛上,其实一点铜都不通。 # `J8n.1` —— 电池排线改成 4 焊盘直焊(§13.5 末尾)之后,P+ 也是贴片。 # `TP8n1` 那个通孔测试点已经取消 ⇒ **每路 VOn_P 现在只剩排母那 3 针是通孔**。 # # 个数按设计文档 §10.3「0.3 mm 孔单孔 1~1.5 A,功率网络每个焊盘打 3 个」: # · `J8n.1` 是这一路 **4 A 的唯一去路**(岛 → 排线 → 手机),必须 3 个; # · `C8n1.1` 是 2200 µF 的主储能,纹波电流也走它,给 3 个; # · `C8n2`~`C8n6` 只承担高频纹波,各 1 个 —— 全按 3 个来会往五块岛里 # 再钻 50 个反焊盘,最窄断面(下限 7.0 mm)扛不住。这是本项目对 §10.3 的 # 一处**有意偏离**,见 PROGRESS。 N_VIA = {} REFS = [] for _m in range(5): REFS.append('C8%d1' % _m) N_VIA['C8%d1' % _m] = 3 REFS += ['C8%d%d' % (_m, _k) for _k in range(2, 7)] REFS.append('J8%d' % (_m + 1)) N_VIA['J8%d' % (_m + 1)] = 3 CAPS = REFS def poly_of(net): for _name, n, main, neck in ISLANDS: if n == net: return island_poly(main, neck) raise KeyError(net) def inside(poly, x, y, margin): u"""点在多边形内,且离每条边 >= margin。岛都是轴对齐的凸/凹矩形拼接,用射线法 + 边距。""" c = False n = len(poly) for i in range(n): ax, ay = poly[i] bx, by = poly[(i + 1) % n] if (ay > y) != (by > y) and x < ax + (bx - ax) * (y - ay) / (by - ay): c = not c if not c: return False for i in range(n): ax, ay = poly[i] bx, by = poly[(i + 1) % n] if R.seg_pt(ax, ay, bx, by, x, y) < margin: return False return True def pad_dist(px, py, hw, hh, x, y): u"""点到轴对齐焊盘矩形的距离(0 = 在里面)。""" return math.hypot(max(abs(x - px) - hw, 0.0), max(abs(y - py) - hh, 0.0)) def _path(px, py, vx, vy, w): u"""末点上的过孔要把点写两遍,否则 expand() 会把它丢掉(§25.14)。""" return [(px, py, R.L_TOP, w), (vx, vy, R.L_TOP, w), (vx, vy, R.L_BOT, w), (vx, vy, R.L_BOT, w)] NEAR = 8.0 # 只拿焊盘 8 mm 以内的东西当障碍(全表 816+393+1373,全查太慢) def holes(pads): u"""每个**带钻孔的焊盘**的孔 -> 一段胶囊 (a, b, r)(圆孔就是 a == b)。 **两类孔的待遇不一样**(2026-09-06 用 DRC 一条条试出来的): | | 线能不能压上去 | 过孔能不能靠近 | |---|---|---| | 焊盘自己的孔(`SLOT` / `ROUND`,金属化)| **能** —— 它就是焊盘的一部分 | 不能,钻孔间距 | | 封装自带的非金属化孔(`npth=True`)| **不能**,DRC 报「挖槽区域 到 导线」| 不能 | 一开始把两类混在一起判,结果是「谁都连不上 J23 的屏蔽脚」—— 槽孔焊盘的**任何一点**离自己的槽都不足 0.80 mm,按那个判据连焊盘都碰不得。 槽的长轴按焊盘的长轴定(`_pcb2_route_geom.json` 里的 hw/hh 已经是转过的), `_pcb2_pads_full.json` 只存了孔的两个尺寸、没存角度,所以只能这么反推。 """ out = [] # ⚠️ 封装自带的非金属化孔(J23 那两个 Ø0.60)**API 一个都读不出来**, # 只能从内层铺铜的反焊盘反推 —— `pcb2_npth.py --write` 生成这份清单。 # 没有它的话,「从 A1B12 往东拉线打孔」这条最自然的走法会被 DRC # 以「挖槽区域 到 导线 距离 0mil」打回来,而净距校核一条都不报。 try: for cx, cy, r in json.load(open(os.path.join(HERE, '_pcb2_npth.json'), encoding='utf-8')): out.append({'net': None, 'npth': True, 'a': (cx, cy), 'b': (cx, cy), 'r': r}) except Exception: print(u' [!] 没有 _pcb2_npth.json —— 先跑 pcb2_npth.py --write,否则封装自带的孔看不见') full = json.load(open(os.path.join(HERE, '_pcb2_pads_full.json'), encoding='utf-8')) bykey = {} for ref, num, _net, _lay, _x, _y, _shape, hole in full: if hole: bykey[(ref, num)] = hole for p in pads: h = bykey.get((p['ref'], p['num'])) if not h: continue if h[0] == 'ROUND': r = float(h[1]) / R.MIL / 2.0 out.append({'net': p['net'], 'npth': False, 'a': (p['x'], p['y']), 'b': (p['x'], p['y']), 'r': r}) continue w, L = sorted((float(h[1]) / R.MIL, float(h[2]) / R.MIL)) # 槽宽 / 槽长 r, ext = w / 2.0, (L - w) / 2.0 if p['hw'] >= p['hh']: # 焊盘长轴在 x 上 out.append({'net': p['net'], 'npth': False, 'a': (p['x'] - ext, p['y']), 'b': (p['x'] + ext, p['y']), 'r': r}) else: out.append({'net': p['net'], 'npth': False, 'a': (p['x'], p['y'] - ext), 'b': (p['x'], p['y'] + ext), 'r': r}) return out def local(pads, vias, lines, px, py, hl=()): return ([p for p in pads if abs(p['x'] - px) < NEAR and abs(p['y'] - py) < NEAR], [v for v in vias if abs(v['x'] - px) < NEAR and abs(v['y'] - py) < NEAR], [l for l in lines if R.seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], px, py) < NEAR], [h for h in hl if abs(h['a'][0] - px) < NEAR and abs(h['a'][1] - py) < NEAR]) def fits(net, segs, via, lp, lv, ll, lh, mine, mvias): u"""几段线 + 一个过孔对**异网**的净距都 >= CLR? `segs` = [(lay, x0, y0, x1, y1, w), ...](本次要画的) `via` = (x, y) 或 None `mine` = 本轮已定的线 [(net, lay, x0, y0, x1, y1, w), ...] `mvias` = 本轮已定的孔 [(net, x, y), ...] `lh` = 附近的钻孔胶囊(`holes()` 的输出);**不分网络**,钻孔和铜是两条规则 ⚠️ 线的层必须逐段拿,**不能写死顶层** —— 底层的 0402 第一版就是这么错的,本地判完全过、到 `check()` 才炸。 """ Rv = R.VIA_D / 2.0 def seg_bad(lay, x0, y0, x1, y1, w): h, a, b = w / 2.0, (x0, y0), (x1, y1) for p in lp: if p['lay'] != 12 and p['lay'] != lay: continue if p['net'] != net: if R.seg_rect(a, b, p['x'], p['y'], p['hw'], p['hh']) - h < R.CLR - 1e-4: return True for v in lv: if v['net'] == net: continue if R.seg_pt(x0, y0, x1, y1, v['x'], v['y']) - v['r'] - h < R.CLR - 1e-4: return True for l in ll: if l['net'] == net or l['lay'] != lay: continue if R.seg_seg(a, b, (l['x0'], l['y0']), (l['x1'], l['y1'])) - h - l['w'] / 2.0 < R.CLR - 1e-4: return True for n2, l2, ax, ay, bx, by, w2 in mine: if n2 == net or l2 != lay: continue if R.seg_seg(a, b, (ax, ay), (bx, by)) - h - w2 / 2.0 < R.CLR - 1e-4: return True for n2, vx, vy in mvias: if n2 == net: continue if R.seg_pt(x0, y0, x1, y1, vx, vy) - Rv - h < R.CLR - 1e-4: return True for q in lh: # 线只躲**非金属化**孔;焊盘自己的孔是焊盘的一部分 if q['npth'] and R.seg_seg(a, b, q['a'], q['b']) - q['r'] - h < DRILL - 1e-4: return True return False for s in segs: if seg_bad(*s): return False if via is None: return True vx, vy = via for p in lp: # 过孔穿所有层,两个信号层的焊盘都要让 if p['net'] != net: if R.seg_rect((vx, vy), (vx, vy), p['x'], p['y'], p['hw'], p['hh']) - Rv < R.CLR - 1e-4: return False elif p['lay'] == 12: # ⚠️ **同网也要躲开通孔焊盘** —— 铜可以重叠,钻孔不行。 # 第一版跳过了同网,于是给 J23 的两个 GND 屏蔽脚打的孔正落在 J23_1/J23_4 # 的**焊盘里**:净距校核全过,DRC 却报「钻孔 到 钻孔 距离 0mil」。 if R.seg_rect((vx, vy), (vx, vy), p['x'], p['y'], p['hw'], p['hh']) - Rv < DRILL - 1e-4: return False for v in lv: if v['net'] != net: if math.hypot(vx - v['x'], vy - v['y']) - v['r'] - Rv < R.CLR - 1e-4: return False elif math.hypot(vx - v['x'], vy - v['y']) - v['r'] - Rv < DRILL - 1e-4: return False # 同网过孔之间也是钻孔间距说了算 for l in ll: if l['net'] == net: continue if R.seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], vx, vy) - Rv - l['w'] / 2.0 < R.CLR - 1e-4: return False for n2, l2, ax, ay, bx, by, w2 in mine: if n2 == net: continue if R.seg_pt(ax, ay, bx, by, vx, vy) - Rv - w2 / 2.0 < R.CLR - 1e-4: return False for n2, ox, oy in mvias: # ⚠️ 本轮新打的同网过孔也要按钻孔间距判 —— 漏了这一条,U34 相邻三个 GND 脚 # 的过孔排成一列、中心只隔 0.23 mm(三个钻孔互相打穿)。 need = R.CLR if n2 != net else DRILL if math.hypot(vx - ox, vy - oy) - 2 * Rv < need - 1e-4: return False for q in lh: if R.seg_pt(q['a'][0], q['a'][1], q['b'][0], q['b'][1], vx, vy) - q['r'] - Rv < DRILL - 1e-4: return False return True def place(pad, lay, w_steps, lp, lv, ll, lh, mine, mvias, inside_ok=None): u"""给一个焊盘找一个过孔位。 先试**一段直线**(只取 8 个方向,不会出现斜的怪角度); 不行再试**两段 45°**(第二段相对第一段只允许 ±45°,不允许直角)。 返回 (w, segs, (vx, vy)) 或 None。`inside_ok(x, y)` 可选,用来卡在铜岛内。 ⚠️ 线**默认从焊盘中心起步**(最稳的连接),但焊盘中间可能有钻孔 (J23 的 A1B12/B1A12 就是 `ROUND 0.36`),压上去 DRC 报「挖槽区域 到 导线」。 所以起点会沿出线方向**往外退**,一直退到焊盘边缘内 `INSET` 为止,取第一个过的。 """ px, py, hw, hh = pad['x'], pad['y'], pad['hw'], pad['hh'] RMIN = R.VIA_D / 2.0 + PAD_GAP # 退到离焊盘边 0.02 为止:J23 的 A1B12 是 1.30×0.60 的焊盘、正中一个 Ø0.36 的 # **非金属化**定位孔,孔边到焊盘边只有 0.47 mm,要塞下 0.2997 的净距 + 半线宽, # 起点必须一路退到边上(0.20 的线,实际余量 0.63−0.18−0.10−0.2997 = 50 µm)。 INSET = 0.02 def starts(dx, dy): u"""沿 (dx, dy) 从焊盘中心往外的候选起点:中心 -> 焊盘边缘内 0.05。""" t_edge = 1e9 if abs(dx) > 1e-9: t_edge = min(t_edge, hw / abs(dx)) if abs(dy) > 1e-9: t_edge = min(t_edge, hh / abs(dy)) t_max = max(0.0, t_edge - INSET) out, t = [], 0.0 while t <= t_max + 1e-9: out.append((round(px + t * dx, 4), round(py + t * dy, 4))) t += 0.05 if out[-1] != (round(px + t_max * dx, 4), round(py + t_max * dy, 4)): out.append((round(px + t_max * dx, 4), round(py + t_max * dy, 4))) return out def try_via(vx, vy, w, segs): if pad_dist(px, py, hw, hh, vx, vy) < RMIN: return None if inside_ok and not inside_ok(vx, vy): return None if not fits(pad['net'], segs, (vx, vy), lp, lv, ll, lh, mine, mvias): return None return (w, segs, (vx, vy)) for w in w_steps: d = 0.0 while d <= D_MAX + 1e-9: d += D_STEP for dx, dy in DIRS: vx, vy = round(px + d * dx, 4), round(py + d * dy, 4) for sx, sy in starts(dx, dy): if math.hypot(vx - sx, vy - sy) < 1e-6: continue got = try_via(vx, vy, w, [(lay, sx, sy, vx, vy, w)]) if got: return got # 一段直线不行:拐一个 45° for w in w_steps: for i, (dx, dy) in enumerate(DIRS): d1 = 0.0 while d1 <= 2.5 + 1e-9: d1 += 0.10 mx, my = round(px + d1 * dx, 4), round(py + d1 * dy, 4) for j in (i - 1, i + 1): ex, ey = DIRS[j % len(DIRS)] d2 = 0.0 while d2 <= D_MAX + 1e-9: d2 += D_STEP vx, vy = round(mx + d2 * ex, 4), round(my + d2 * ey, 4) for sx, sy in starts(dx, dy): if math.hypot(mx - sx, my - sy) < 1e-6: continue got = try_via(vx, vy, w, [(lay, sx, sy, mx, my, w), (lay, mx, my, vx, vy, w)]) if got: return got return None def solve(pads, vias, lines): u"""逐个贴片焊盘挑过孔位置。返回 {key: path}。""" byref = {} for p in pads: byref.setdefault(p['ref'], []).append(p) hl = holes(pads) routes, report, mine, mvias = {}, [], [], [] for ref in CAPS: got = [p for p in byref.get(ref, []) if p['net'].startswith('VO') and p['net'].endswith('_P')] if len(got) != 1: sys.exit(u'%s 上找不到唯一的 VOn_P 焊盘(找到 %d 个)' % (ref, len(got))) pad = got[0] net, px, py = pad['net'], pad['x'], pad['y'] poly = poly_of(net) lp, lv, ll, lh = local(pads, vias, lines, px, py, hl) # 一个焊盘上要几个孔(`N_VIA`)。**逐个放、每放一个就进 mine/mvias** —— # 后一个孔会自动躲开前一个(同网也按钻孔间距 0.33 判,见 fits())。 want = N_VIA.get(ref, 1) for k in range(want): # push()/check() 靠 netof(key) 取网络名,所以 '#' 前面必须是网络名 key = u'%s#%s' % (net, ref) if want == 1 else u'%s#%s-%d' % (net, ref, k + 1) # ⚠️ 出线画在**焊盘自己那一层**,不是写死顶层。 # v1 时代全板器件在顶层,写死 L_TOP 没暴露问题;v2 之后 # VOn_P 的 35 个焊盘**全在底层**,照旧写 L_TOP 会画出 35 条 # 悬在顶层、一头不搭焊盘的线 —— 而且 fits() 拿 lay 去筛障碍, # 连净距校核都是照着空荡荡的顶层算的,一条都不会报。 lay = pad['lay'] other = R.L_BOT if lay == R.L_TOP else R.L_TOP got2 = place(pad, lay, W_STEPS, lp, lv, ll, lh, mine, mvias, inside_ok=lambda x, y: inside(poly, x, y, EDGE_IN)) if not got2: report.append((ref if k == 0 else '%s#%d' % (ref, k + 1), net, None)) continue w, segs, (vx, vy) = got2 pts = ([(segs[0][1], segs[0][2], lay, w)] + [(sx1, sy1, lay, w) for _l, _x0, _y0, sx1, sy1, _w in segs]) routes[key] = pts + [(vx, vy, other, w), (vx, vy, other, w)] for l_, x0, y0, x1, y1, w_ in segs: mine.append((net, l_, x0, y0, x1, y1, w_)) mvias.append((net, vx, vy)) report.append((ref if k == 0 else '%s#%d' % (ref, k + 1), net, (w, math.hypot(vx - px, vy - py), vx, vy, len(segs)))) return routes, report def main(): pads, vias, lines = R.geom() ours = set('VO%d_P' % n for n in range(1, 6)) vias = [v for v in vias if v['net'] not in ours] lines = [l for l in lines if l['net'] not in ours] routes, report = solve(pads, vias, lines) fail = 0 for ref, net, r in report: if not r: print(u' [!!] %-6s %-7s 找不到能落孔的位置' % (ref, net)) fail += 1 else: w, d, vx, vy, ns = r print(u' %-6s %-7s 过孔 (%8.3f,%8.3f) 离焊盘中心 %.2f 线宽 %.2f %d 段' % (ref, net, vx, vy, d, w, ns)) segs = sum(len(R.expand(p)[0]) for p in routes.values()) nv = sum(len(R.expand(p)[1]) for p in routes.values()) print(u'') want = sum(N_VIA.get(r, 1) for r in CAPS) print(u'%d/%d 个过孔搞定(%d 个焊盘):%d 段导线 / %d 个过孔' % (len(routes), want, len(CAPS), segs, nv)) if nv != len(routes): sys.exit(u'!! 过孔数和焊盘数对不上 —— expand() 又把末端过孔吞了') n_sharp = len(R.sharp_corners(routes)) print(u'直角拐点 %d(每条只有一段直线,应当是 0)' % n_sharp) print(u'') print(u'净距校核(一般 %.2f mm):' % R.CLR) bad = R.check(routes, pads, vias, lines) if not bad: print(u' 全部通过 ✔') if '--push' in sys.argv: if bad or fail or n_sharp: sys.exit(u'\n!! 有未解决的问题,不下发。') print(u'') R.push(routes) print(u'⚠️ 下一步必须重铺:/c/Python314/python .claude/pcb2_pour.py --push') if __name__ == '__main__': main()