文件
phonerail/.claude/pcb2_vo_vias.py
T
zhou yang和Claude Opus 5 2bfe308b30 初始导入:PhoneRail 五路手机直供电源板
原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。
这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。

纳入:
- 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本
- .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、
  pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源)
- 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾)
- firmware/ 的 A1 骨架与 site/ 文档站源码

.gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、
site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。

.gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存,
不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
2026-09-06 18:18:14 +08:00

435 行
20 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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()