文件
phonerail/.claude/pcb_fanout.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

1142 行
60 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""扇出:给每个贴片焊盘在旁边打一个过孔,接到对应的平面层。
/c/Python314/python .claude/pcb_fanout.py [--net GND] [--push]
⚠️ **动过布局之后的正确顺序是三步,不能省也不能并**:
python .claude/pcb_fanout.py --clean # 1. 先清空板上的过孔与走线
python .claude/dump_pads.py # 2. 再重新抓障碍表
python .claude/pcb_fanout.py --push # 3. 最后规划 + 下发
`--clean --push` 写在一条命令里**会用旧的 `pcb-copper.json`**:清是清干净了,
可规划器看的还是那 459 个已经不存在的过孔,把它们当障碍绕 ——
2026-09-03 实测结果是 459 → 292 个过孔、5 个焊盘(五路 Buck 的 pin2)扇不出去,
而且散热盘的盘内阵列几乎全军覆没(27 个大焊盘只打进 4 个孔)。
反过来先 dump 再 `--clean --push` 也不行:那样板上会剩下两套过孔(实测 747 个)。
## 为什么先做扇出
板上 **391 个元件全在 Top 层**(1292 个焊盘在 layer 1,只有 30 个通孔),
所以 **Bottom 整层是空的**,信号有一整层可以走。
而 GND 一个网络就占 358 条连接(全板 1264 条的 28%)——
这些不该当信号线布,应该各打一个过孔下到 L2 地平面。
商业布线器对 BGA/QFN 也是先 fanout 再布线,顺序是对的。
## ⚠️ `pad_n_via()` 给的孔数**只够信号,不够功率**(2026-09-06 实测)
它按焊盘大小给 1 或 3 个。Ø0.3 过孔单孔只算 **1~1.5 A** ⇒ 上限 3~4.5 A。
`Rs6.2`(全板 +12V 平面**唯一的源**,要过 **8 A**)拿到 3 个孔就是个真错误,
而**四道验证一条都不管**:铺铜断面只量平面本身、这里只报「孔放下了没有」、
连接性 DRC 只看「连上没有」、逃逸审计只看「出得去吗」。
⇒ **凡是 >4 A 的「焊盘 → 平面」转换点,孔数必须手写**,填 `FORCE_ARRAY`(见下)。
这块板上只有 `Rs6.2` 一处;五路的 `VOn_P` 走底层小岛、排针针脚本身是通孔,不经过这一步。
## 几何约定(全部 mm)
- 过孔 0.6 / 0.3(规则里 `viaOuterdiameterDefault` 就是 0.61)
- 安全间距 0.2(本工程全表抬到 0.2,见 `pcb_rules.py`)
- 所以**过孔中心到别的焊盘边缘 ≥ 0.3 + 0.2 = 0.5**
- 连过去的短线宽 0.3,半宽 0.15,**线到别的焊盘边缘 ≥ 0.35**
## 三个坑(都实测过)
1. **焊盘的 `rotation` 是弧度**(π/2 = 90°),而且负角被按「度」归一化过:
−90° 存成 `358.4292`。解码:`rad = r if r < 180 else r - 360`。
(同一套 API 里 `getAllPinsByPrimitiveId` 给的却是**度**。)
2. **`POLYGON` 焊盘的点数组还是 mil**(J22 USB-C 的壳体脚,15 个),
`dump_pads.py` 的 q() 只转了顶层数字,没递归进去。这里单独换算。
3. **通孔焊盘本来就贯穿所有层**,不需要扇出,跳过。
⚠️ **判据是 `layer == 12`(多层),不是「`hole` 非空」。**
2026-09-06 第十九轮实测:EasyEDA 给**每一个 POLYGON 焊盘**都塞了一个假的
`hole`(恒等于 `0.6 × min(w,h)`,渲染出来根本没有孔)。原来那句
`not p['hole']` 于是把五路 Buck 的散热盘 U1~U5.15、U6/U7.17 **整批滤掉**,
§10.3 要求的散热过孔阵列一个都没打,而且那几个盘同时是 TPS54622 的 PGND。
失效是静默的:连接性 DRC 只多出几条,看不出和 footprint 的一个废字段有关。
⇒ 上游 `dump_pads.py` 已经把单铜层焊盘的假孔清成 None,这里再按层号挡一道。
"""
import io, json, math, os, sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from eda import run
PCB = '7e9415a16a909c51'
MIL = 39.3701
SRC = os.path.join(HERE, 'pcb-copper.json')
L_TOP, L_GND, L_PWR, L_BOT = 1, 15, 16, 2
# 哪些层上的贴片焊盘要扇出。下板 391 个元件全在顶层,所以默认只有顶层;
# **载板两面都是元件面**(J31/J32 + 整个 Hub 在底层),由 `pcb2_fanout.py` 覆盖成 (1, 2)。
FANOUT_LAYERS = (L_TOP,)
VIA_OD, VIA_ID = 0.6, 0.3
CLR = 0.2
TRK_W = 0.3
# 细间距 QFN 出线用的窄线宽。0.5 mm 间距、焊盘高 0.3 -> 相邻脚之间只有 0.70 mm,
# 0.3 mm 的线上下各余 0.20,正好卡在安全间距上,1e-4 mm 的坐标舍入就能判成撞。
# 规则里最小线宽 0.102,所以 0.2 / 0.15 都合法;GND 出线电流可忽略,细一点无所谓。
TRK_W_TIGHT = [0.3, 0.2, 0.15]
VIA_R = VIA_OD / 2.0
MARGIN_VIA = VIA_R + CLR # 过孔中心 -> 别的焊盘边缘
MARGIN_TRK = TRK_W / 2.0 + CLR # 走线中心线 -> 别的焊盘边缘
# ⚠️ 判定要留 1 nm 的容差。QFN 角脚(如 U14 pin1)的引线常常**正好压在**
# 相邻脚的间距边界上(4.75 = 5.10 - 0.35,不多不少),用 <= 判会当成撞车,
# 而它其实恰好满足 0.2 mm。没有这个 EPS,五路 eFuse 的角脚全部扇不出去。
EPS = 1e-6
# 散热盘:四周被引脚围死,外面放不下过孔 —— 按 §10.3 在**盘内**打过孔阵列
# 阈值 4.0 是量出来的,不是拍的:GND 贴片盘按尺寸归类只有这几档 ——
# 4.80x4.80=23.0(U32 Hub) / 2.10x3.00=6.3(出线座,过 4A) / 2.05x2.19=4.5 /
# 1.60x2.60=4.16(五路 eFuse 散热盘) ← 以上要盘内阵列
# 2.50x1.10=2.75(U31) / 1.49x1.73=2.58(1206 电容) / 1.41x1.35=1.90(0805) ← 以下不要
# 设成 2.0 会把 45 颗电容的焊盘也钻上孔,那不是散热盘。
THERMAL_AREA = 4.0 # mm^2 以上算散热/大功率盘
THERMAL_PITCH = 1.0 # §10.3「0.3 mm 孔,1 mm 间距」
# 盘太小时按 1.0 只落得下 1 个孔(U6/U7 的 1.70 x 1.70 就是),退到这一档再排一次。
# 0.65 的下限来自钻孔间距:0.65 - 0.3 = 0.35 > hole2Hole 0.3。
THERMAL_PITCH_MIN = 0.65
THERMAL_INSET = VIA_R + 0.1 # 过孔完全落在盘内
# 面积阈值挡不住的、但确实是散热盘的,在这里点名。
# U6/U7(WQFN-16 DAC)的 EP 是 1.70 x 1.70 = 2.89 mm²,卡在 2.75(U31) 和 4.16(eFuse) 中间:
# 把阈值降到 2.8 就等于拿 0.05 mm² 的余量去赌,不如点名。
# 不点名的后果实测过:这两个 EP 走普通搜索,拿到的是一条 **2.74 mm 长的 0.2 mm 引线**
# 加一个盘外过孔 —— 那不是散热盘的接地。
THERMAL_PADS = set(['U6.17', 'U7.17'])
BOARD = (0.0, 0.0, 160.0, 125.0)
R_CORNER = 5.0
EDGE_KEEP = 0.6 # 过孔中心离板边
# ── ESP32 天线禁铺(全层)────────────────────────────────────────────
# ⚠️ 2026-09-06 **改过**。v5(单板时代)U20 在右板边,这个框写的是 (151, 35.5, 160, 56.5);
# v6 把 U20 挪到了 (88.0, 18.2) rot 180(天线端转到 −Y 朝下板边),
# 而**右板边那个位置现在坐着 J22 调试 USB-C**(154.4, 52.0)——
# 不改的话既漏掉了真正的天线区,又会把 J22 的 GND / 壳体脚整片禁掉。
# 新框按封装本体推:本体局部 y ∈ [−9.35, +16.75]、焊盘只到 +9.35,
# ⇒ 天线是 +Y 端多出来的 7.4 mm;rot 180 之后落到 y ∈ [1.45, 8.85]、x ∈ [78.5, 97.5]。
# 留一点余量,并且实测过这个框里**一个元件都没有**(天线正下方是空的)。
ANT = (78.0, 0.8, 98.0, 8.9)
# 哪些网络扇到哪一层
# ⚠️ `+3V3_HUB` 2026-09-04 随 Hub 整个搬去载板了,下板上**这个网络已经不存在**,
# 留着它只会让 N_VIA / TRK_W_MAX 多两个查不到主人的条目。已删。
# 网络 -> 目标平面层。**必须和 `pcb1_pour.REGIONS` 一一对上** ——
# 这里出现一个没有铜面的网络,扇出就是打一堆接不到任何东西的悬空过孔,
# 而 DRC 只会报「断连」,看不出是扇出打错了地方。
#
# ⚠️ 2026-09-06 §26.8 定稿之后重对了一遍,删掉三类。**都不是漏写,是有意不上平面**:
# · `VO1`~`VO5` Buck 输出到 eFuse 只有约 12 mm、4 A,走**顶层 1.5~2.0 mm**
# (§10.2 本来就要求这一段回路面积最小)
# · `+5V` 去处全是排针上的 10 个通孔针,走**底层 1.2 mm 干线**;
# 外层 1 oz 带 2.8 A 只要 0.45 mm,占一块内层是浪费
# · 8 A 输入链三段(`VIN_RAW` / `VIN_FUSED` / `VIN_SW` 那三个自动命名网)
# 走**顶层宽铜 >= 3.5 mm**。内层 0.5 oz 要 18.2 mm,那个角落总共才 22 mm 高;
# 而且 Ø0.3 过孔单孔只有 1~1.5 A,一个转换点最多 3~4.5 A << 8 A
#
# ⚠️ 上一轮(第十五轮)刚把这三段的写死网名改成 `pcb1_rules.resolve_auto()` 反查,
# **那个改动本身是对的**,只是 §26.8 随后判定它们根本不该下到 Inner2。
# 反查逻辑仍然活在 `pcb1_rules.py` 和「顶层宽铜」那一步里。**别再把它们加回 TARGET。**
TARGET = {'GND': L_GND, '+12V': L_PWR, '+3V3': L_PWR}
for _n in range(1, 6):
TARGET['VO%d_P' % _n] = L_BOT # 分流之后 -> 出线座,铜面是 Bottom 上的五块小岛
# 每个焊盘打几个过孔。0.3 mm 孔的单孔载流约 1~1.5 A,4 A 的网络给 3 个。
# GND 靠地平面 + 散热盘阵列,一个就够。
N_VIA = dict((n, 1 if n == 'GND' else 3) for n in TARGET)
N_VIA['+3V3'] = 1
# 功率网络的出线宽度上限。真正用多宽由焊盘决定,见 pad_trk_w()。
TRK_W_MAX = dict((n, TRK_W if n == 'GND' else 1.0) for n in TARGET)
TRK_W_MAX['+3V3'] = 0.5 # 0.5 A 级,别用 1.0 挤 0402 抽头
TRK_W_NET = TRK_W_MAX # 兼容旧名字
# ── 板上**已有的走线**也是障碍 ────────────────────────────────────────
# ⚠️ 2026-09-06 加。原来的规划器只把「焊盘 + 过孔 + 钻孔」当障碍,**完全没看走线** ——
# 下板做扇出时板上除了板框一根线都没有,所以从来没暴露过。
# 载板 v5 是先布了 11 对差分 + 3 条 +3V3 车道(178 条线)再做扇出,
# 不登记的话过孔会直接压在 D± 上,而且几何验证抓不到(它只查网络连通性)。
EXIST_LAYERS = (1, 2, 15, 16) # 只有这四层是铜;3/4 丝印、9/10 装配、13 文档不算
EXIST_W_MAX = 1.2 # near() 查询时的外扩余量(板上最宽的线 << 1.2)
# ── 手工钉死的过孔位置 / 出线方向(由 pcb2_fanout.py 填)──────────────
# FORCE_VIA[(位号, 引脚号)] = (vx, vy) 直接用这个坐标,仍然做一遍净距校核
# ANG_RANGE[(位号, 引脚号)] = (a0, a1) 只在这个角度扇区(度,数学角)里找
FORCE_VIA = {}
ANG_RANGE = {}
# ── ⭐ 整片过孔阵列钉死:`FORCE_ARRAY[(位号, 引脚号)] = [(x, y), ...]` ────────
#
# 为什么要有它(2026-09-06 第二十三轮,两个问题一次解决):
#
# **1. `pad_n_via()` 给的 3 个孔扛不住 8 A。** `Rs6.2` 是全板 +12V 平面**唯一的源**
# —— 12V 进线 8 A 全部由它一个焊盘下到 Inner2。而 Ø0.3 过孔单孔只算 1~1.5 A,
# 3 个孔 = 3~4.5 A。这一条**四道验证一条都不管**:铺铜的断面校核只量平面本身
# (`北大块 @Rs6 东出 x=20 → 11.1 A`),连接性 DRC 只看「连上没有」,
# 扇出自己只报「失败 0 个」。⇒ 从此**凡是 >4 A 的焊盘→平面转换点都要手写阵列**。
#
# **2. 自动搜出来的孔位挡了别人的活路。** Rs6.2 那 3 个孔里有两个被规划器甩到了
# **西边** (7.40,94.40) / (7.91,95.16),两根 1 mm 宽的引线(端帽还要再往西探 0.5)
# 正好压在 U13 南排五个脚唯一的出线通道上,把 `SCL` / `SDA` / `INA_ALERT`
# 全封死 —— `pcb1_escape_audit.py` 报「U13.2 被围死」。
# 规划器不知道「这块地方要留给别人」,它只知道自己够不够净距。
#
# 钉死之后**照样逐个走全套净距校核**(`search(at=...)`),钉错了当场报失败。
FORCE_ARRAY = {
# Rs6.2 → Inner2 的 +12V 平面:12 个孔(约 12 A),摆在焊盘**正南**那片空地
# (x 5.2~14.0 / y 85.5~91.5 实测什么都没有),北边留给 U13 出线。
('Rs6', '2'): [(x, y) for y in (91.0, 90.0, 89.0)
for x in (8.0, 8.9, 9.8, 10.7)],
}
# ── 五路通道里**必须五路一模一样**的那几个孔(2026-09-06 第十九轮)────────
# 起因:`pcb1_route_pwr.py` 的路径写在 CH1 母版的局部坐标里,再仿射到五路。
# 但扇出规划器的候选角度是按**板坐标**排的(`angs` 那一行),
# 而 CH2/CH3 整路转了 180° —— 同一个焊盘在 CH1 往东扇、在 CH2 就往西扇。
# 五路的摆位一模一样,扇出结果却不一样,于是母版里算好的走廊在 CH2/CH3 上被堵死。
#
# ⚠️ 这不是「差不多就行」的事:净距校核只会报「CH2 的 F-trunk 撞到一个 GND 过孔」,
# 看上去像布线画错了,实际上错的是**上游的扇出不对称**。
#
# ⇒ 把和手工走廊相关的几个焊盘按 CH1 的解钉死,其余的仍然自动搜。
# 坐标写在母版局部坐标里,下面按 pcb1_place.CH_PLACE 展开到五路。
# 钉死之后**照样走全套净距校核**(`search(at=...)`),钉错了会当场报失败。
_CH_FORCE_LOCAL = {
('C%01', '2'): (5.475, 12.300), # 让开西侧的 +12V 母线(局部 x 2.15~2.95)
('C%02', '2'): (5.475, 15.950),
('C%03', '1'): (5.475, 6.700),
# ⚠️ 33.896 -> 34.696:U%8 在母版里整体北移了 0.8(2026-09-06 第二十一轮)。
('U%__8', '1'): (4.279, 34.696), # 让开 $2N699 主干(局部 x 2.45~3.65)
('R%02', '2'): (4.324, 5.200), # 让开 FB 的 R%01.2 -> R%02.1 那一段
# ⭐ 2026-09-06 第二十三轮:**这一条是「扇出不对称」的教科书样本**。
# `C%06.2` 的 GND 孔在 CH1/CH4/CH5(rot 0)往**东**落到局部 (10.02, 27.10),
# 在 CH2/CH3(rot 180)却往**西**落到 (6.75, 27.10) —— 正好压在 eFuse `U%8`
# 脚 14 / 15(局部 (6.25/6.75, 28.80))唯一的南向出线口上。
# 后果:`pcb1_escape_audit` 报 U15.14/15、U16.14/15「被围死」,而 CH1/CH4/CH5
# 的同一个脚完全正常 —— 五路摆位一模一样,**错的是上游扇出**。
('C%06', '2'): (10.020, 27.100),
}
def _fill_ch_force():
try:
import pcb1_place as _P
except Exception:
return
for (tok, pin), (lx, ly) in _CH_FORCE_LOCAL.items():
for n, (rot, bx, by) in _P.CH_PLACE.items():
des = _P.ch_designator(tok, n)
xy = (bx + lx, by + ly) if rot == 0 else (bx - lx, by - ly)
FORCE_VIA[(des, pin)] = xy
if os.path.basename(sys.argv[0] if sys.argv else '') != 'pcb2_fanout.py':
_fill_ch_force()
# ── 调试:某个焊盘为什么扇不出去 ───────────────────────────────────────
# `FANOUT_WHY=U6.15 python .claude/pcb_fanout.py --net +3V3`
# 会把这个焊盘所有候选孔位**卡在哪一道校核上**按次数打出来。
# 「周围太挤」这四个字没有任何信息量,而每次卡住的原因都不一样:
# 有时是相邻脚的铜箔,有时是上一个网络刚放下的过孔,有时只是引线蹭到 0.01 mm。
DEBUG_PAD = os.environ.get('FANOUT_WHY') or None
# ── 宁可并线也不要自己那个过孔的焊盘 ──────────────────────────────────
# `PREFER_LINK` 里的焊盘直接跳过「找孔位」,落到第三遍去并到旁边的同网焊盘上。
#
# ⚠️ 为什么要有这个:0.5 mm 间距 QFN 的**中间脚只有正北一条出路**(左右两边被邻脚
# 的间距区夹死)。U6 北排四个脚里,pin14 是 GND、pin15 是 +3V3:
# GND 那一遍先跑,pin14 顺利在正北打了个孔 —— 那个孔的铜箔正好卡在 pin15
# 唯一的出线走廊上,于是 pin15 既打不了孔、也并不过去,整个脚断在那里。
# 而 pin14 南边 0.25 mm 就是自己的散热盘 U6.17(自带 Ø1.02 热过孔,本来就通全层),
# 并过去 0.45 mm 完事,**比它自己占一个过孔还短**。
# ⇒ 把 pin14 让出来。这是「谁更没得选谁先占」,不是特例补丁。
PREFER_LINK = set(['U6.14', 'U7.14'])
# 整批**不参与扇出**的焊盘(由别的脚本自己接管)。返回 True = 跳过。
def SKIP_PAD(p):
u"""`pcb1_pour.ROUTED_PADS` 里的几个是**有意不进平面**的,见 §10.4:
U13.8 / U13.9 是 5 mΩ 分流器的 Kelvin 取样脚,满量程才 20 mV ——
给它们打一个下到 +12V 平面的过孔,取样就白做了。
⚠️ 这几个焊盘几何上大多已经落在铜面的挖空处,`_pour_filter` 也会滤掉它们。
这里再挡一道不是重复:**滤掉是几何巧合,挡掉才是设计意图**。
铜面边界哪天挪 1 mm,前者就静默失效了。
"""
try:
import pcb1_pour as _P
except Exception:
return False
return '%s.%s' % (p.get('des'), p.get('num')) in _P.ROUTED_PADS
# 第三遍「并到旁边的同网络焊盘」允许的最长并线。原来没有上限 ——
# 实测把 C745 pin2 一路并到 9.78 mm 外的 J64 pin3,那已经不是扇出而是一条乱布的线。
LINK_MAX = 3.0
def pad_trk_w(p, net):
u"""这个焊盘该用多宽的出线。
⚠️ 不能给整个网络定一个宽度。`+12V` 上既有 1210 的 Cin(焊盘 1.49 x 1.73),
也有 R104 那种 0402 分压抽头(0.57 x 0.54)—— 后者比 1.0 mm 的线还窄,
一刀切 1.0 会让 29 个焊盘直接扇不出去(实测)。
按焊盘短边的 0.9 倍取,再夹到 [0.3, 本网上限],就是人手布线的做法。
"""
if net == 'GND':
return TRK_W
hx, hy = pad_halfsize(p)
w = min(2 * hx, 2 * hy) * 0.9
return max(0.3, min(TRK_W_MAX.get(net, 1.0), w))
def pad_n_via(p, net):
u"""这个焊盘打几个过孔。小焊盘(0402 抽头、QFN 细脚)打一个就够 ——
它们本来就不载功率电流,多打的过孔只会把平面打成筛子、挡住后面的信号线。"""
if net == 'GND':
return 1
hx, hy = pad_halfsize(p)
return 3 if min(2 * hx, 2 * hy) >= 0.8 else 1
# ⚠️ 这里原来跟着一段上一轮遗留的 `for _n in PWR_NETS: TARGET[_n] = L_PWR`,
# 把 VOn_P 的目标层从 L4 又改回 L3,而且把 +5V/+3V3/+3V3_HUB 也拉了进来 ——
# 那三条根本没有铜面,扇过去就是一堆挂空的过孔。已删。
# ── 焊盘几何 ───────────────────────────────────────────────────────────
def pad_halfsize(p):
u"""返回焊盘在**自身坐标系**下的半宽半高 (hx, hy)。"""
s = p['shape']
if not s:
return (0.3, 0.3)
k = s[0]
if k in ('RECT', 'OVAL', 'ELLIPSE'):
return (s[1] / 2.0, s[2] / 2.0)
if k == 'POLYGON':
pts = [v for v in s[1] if isinstance(v, (int, float))]
xs, ys = pts[0::2], pts[1::2]
# ⚠️ POLYGON 的点没被 dump 转换过,还是 mil
return ((max(xs) - min(xs)) / MIL / 2.0, (max(ys) - min(ys)) / MIL / 2.0)
return (0.3, 0.3)
def pad_poly(p):
u"""POLYGON 焊盘的真实轮廓(板坐标 mm);不是多边形则返回 None。
⚠️ **散热盘不是它的包围盒。** U1~U5 的 EP 是个十字:中间 2.03 x 2.03 的方块,
上下各伸出两根 0.24 mm 宽的指头到 ±2.16 —— 包围盒 2.03 x 4.32 里,
y 方向两头那一截**大部分是空的**。按包围盒撒阵列会把过孔打到铜外面去,
而净距校核和连接性 DRC 都不会响(那儿本来就没别人)。
"""
sh = p.get('shape')
if not sh or sh[0] != 'POLYGON':
return None
pts = [v for v in sh[1] if isinstance(v, (int, float))]
return list(zip([v / MIL for v in pts[0::2]], [v / MIL for v in pts[1::2]]))
def poly_has(poly, x, y):
n, c = len(poly), False
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
c = not c
return c
def disc_in_poly(poly, x, y, r, n=12):
u"""半径 r 的圆整个落在多边形里(圆心 + 圆周 n 个点都在里面)。"""
if not poly_has(poly, x, y):
return False
for i in range(n):
a = 2 * math.pi * i / n
if not poly_has(poly, x + r * math.cos(a), y + r * math.sin(a)):
return False
return True
def pad_box(p):
u"""焊盘的轴对齐外框 (x0,y0,x1,y1)。旋转全是 0/±90/180,AABB 是精确的。"""
hx, hy = pad_halfsize(p)
r = p['rot']
rad = r if r < 180 else r - 360.0 # 见文件头坑 1
deg = round(rad * 180.0 / math.pi) % 180
if deg == 90:
hx, hy = hy, hx
return (p['x'] - hx, p['y'] - hy, p['x'] + hx, p['y'] + hy)
HOLE_CLR = 0.3 # 钻孔到钻孔(规则里 otherClearance.hole2Hole = 0.3)
def hole_box(p):
u"""焊盘钻孔的轴对齐外框;没有孔返回 None。
⚠️ 孔有自己的偏移和旋转,跟焊盘的**不一定一致**。J22/J23 的 USB-C 壳体脚
铜箔 1.2 x 1.8、槽 0.8 x 1.5,槽的长轴与铜箔垂直,**槽伸出铜箔外 0.15 mm**。
只按铜箔建模的话过孔会直接压在槽上 —— DRC 报「挖槽区域 到 过孔 距离 0」。
"""
h = p.get('hole')
if not h:
return None
if h[0] == 'ROUND':
r = h[1] / 2.0
hx = hy = r
else: # SLOT: [w, l]
hx, hy = h[1] / 2.0, h[2] / 2.0
r = p.get('hrot', 0) or 0
rad = r if r < 180 else r - 360.0
if round(rad * 180.0 / math.pi) % 180 == 90:
hx, hy = hy, hx
cx = p['x'] + (p.get('hox') or 0)
cy = p['y'] + (p.get('hoy') or 0)
return (cx - hx, cy - hy, cx + hx, cy + hy)
def seg_seg_dist(ax, ay, bx, by, cx, cy, dx_, dy_):
u"""两条线段的最短距离;相交返回 0。"""
def d_pt_seg(px, py, x1, y1, x2, y2):
ex, ey = x2 - x1, y2 - y1
L2 = ex * ex + ey * ey
t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((px - x1) * ex + (py - y1) * ey) / L2))
return math.hypot(px - x1 - t * ex, py - y1 - t * ey)
def cr(ox, oy, px, py, qx, qy):
return (px - ox) * (qy - oy) - (py - oy) * (qx - ox)
d1, d2 = cr(cx, cy, dx_, dy_, ax, ay), cr(cx, cy, dx_, dy_, bx, by)
d3, d4 = cr(ax, ay, bx, by, cx, cy), cr(ax, ay, bx, by, dx_, dy_)
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (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 inflate(b, m):
return (b[0] - m + EPS, b[1] - m + EPS, b[2] + m - EPS, b[3] + m - EPS)
def pt_in_box(x, y, b):
return b[0] <= x <= b[2] and b[1] <= y <= b[3]
def seg_hits_box(x1, y1, x2, y2, b):
u"""线段是否与轴对齐矩形相交(含端点在内)。"""
if pt_in_box(x1, y1, b) or pt_in_box(x2, y2, b):
return True
# Liang-Barsky
dx, dy = x2 - x1, y2 - y1
t0, t1 = 0.0, 1.0
for pq in ((-dx, x1 - b[0]), (dx, b[2] - x1), (-dy, y1 - b[1]), (dy, b[3] - y1)):
p, q = pq
if p == 0:
if q < 0:
return False
else:
t = q / p
if p < 0:
if t > t1:
return False
if t > t0:
t0 = t
else:
if t < t0:
return False
if t < t1:
t1 = t
return True
def inside_board(x, y, keep=EDGE_KEEP):
x0, y0, x1, y1 = BOARD
if not (x0 + keep <= x <= x1 - keep and y0 + keep <= y <= y1 - keep):
return False
r = R_CORNER
for cx, cy in ((x0 + r, y0 + r), (x1 - r, y0 + r), (x0 + r, y1 - r), (x1 - r, y1 - r)):
if (x - cx) * (1 if cx < (x0 + x1) / 2 else -1) < 0 and \
(y - cy) * (1 if cy < (y0 + y1) / 2 else -1) < 0:
if math.hypot(x - cx, y - cy) > r - keep:
return False
return True
# ── 空间索引 ───────────────────────────────────────────────────────────
CELL = 2.0
class Grid(object):
def __init__(self):
self.g = {}
def _cells(self, b):
for i in range(int(math.floor(b[0] / CELL)), int(math.floor(b[2] / CELL)) + 1):
for j in range(int(math.floor(b[1] / CELL)), int(math.floor(b[3] / CELL)) + 1):
yield (i, j)
def add(self, b, obj):
for c in self._cells(b):
self.g.setdefault(c, []).append((b, obj))
def near(self, b):
seen, out = set(), []
for c in self._cells(b):
for item in self.g.get(c, ()):
if id(item[1]) not in seen:
seen.add(id(item[1])); out.append(item)
return out
CLEAN_JS = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1200));
const vs = await eda.pcb_PrimitiveVia.getAllPrimitiveId();
if (vs.length) await eda.pcb_PrimitiveVia.delete(vs);
// ⚠️ 只删信号层的导线。板框那 4 条在 layer 11,删了板子就没边框了。
const ls = (await eda.pcb_PrimitiveLine.getAll()).filter(l => l.layer !== 11);
if (ls.length) await eda.pcb_PrimitiveLine.delete(ls.map(l => l.primitiveId));
await eda.pcb_Document.save();
return {vias: vs.length, lines: ls.length,
leftVia: (await eda.pcb_PrimitiveVia.getAll()).length,
leftLine: (await eda.pcb_PrimitiveLine.getAll()).length};
"""
def clean():
r = run(CLEAN_JS % PCB, timeout=600)
if not r.get('success'):
print(u'失败:', json.dumps(r, ensure_ascii=False)[:400]); sys.exit(1)
x = r['result']
# 板框在 layer 11,而且是**一条闭合多段线**(不是 PrimitiveLine)——
# 2026-09-06 清掉早年那 4 条直线之后,这里剩下的导线数应该是 0。
print(u'清掉:过孔 %d / 导线 %d;剩下 过孔 %d / 导线 %d(都应为 0)'
% (x['vias'], x['lines'], x['leftVia'], x['leftLine']))
JS_COUNT = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1500));
return {vias: (await eda.pcb_PrimitiveVia.getAll()).length};
"""
def guard_not_pushed_twice():
u"""板上已经有过孔就不许再 `--push` —— 这个脚本**只加不删,不幂等**。
2026-09-06 实测:`--net all --push` 连跑两遍,板上变成 **888 个过孔**,
每一个都有一个同坐标同网的副本;而**短线数没变**(444),
因为 EasyEDA 把共线重叠的新线并进了旧线里(skill §9v 记的就是这个现象)。
连接性 DRC 也不报 —— 重复的过孔和焊盘同网,电气上「没错」。
唯一看得出来的判据是**回读过孔数**,所以把它做成硬闸门。
要重推就照规矩来:`--clean` → `dump_pads.py` → `--push`,一次跑完。
"""
r = run(JS_COUNT % PCB, timeout=300)
if not r.get('success'):
print(u'失败:', json.dumps(r, ensure_ascii=False)[:400])
sys.exit(1)
n = r['result']['vias']
if n:
print(u'❌ 板上已经有 %d 个过孔了,**没有下发任何东西**。' % n)
print(u' 这个脚本只加不删:再推一遍会在同坐标上叠出一整套副本,')
print(u' 而短线数不变、DRC 也不报 —— 只有回读过孔数看得出来。')
print(u' 要重推请一次跑完:')
print(u' pcb_fanout.py --clean → dump_pads.py → pcb_fanout.py --net all --push')
sys.exit(1)
def main():
if '--clean' in sys.argv:
clean()
if '--push' not in sys.argv:
return
if '--push' in sys.argv and '--only' not in sys.argv and '--clean' not in sys.argv:
# `--only` 是「板上已有整轮扇出、只补一两个漏网的」,那种就是要往上加,不设闸。
guard_not_pushed_twice()
net_filter = 'GND'
if '--net' in sys.argv:
net_filter = sys.argv[sys.argv.index('--net') + 1]
d = json.load(io.open(SRC, encoding='utf-8'))
if net_filter == 'all':
# ⚠️ 必须一个进程、共用同一份 d 跑完:前一个网络刚放下的过孔
# 要立刻进 d['vias'],成为后一个网络的障碍。
# 分成多个进程跑就得每次重新 dump_pads,否则不同网络的过孔会压在一起。
for n in ['GND'] + [x for x in TARGET if x != 'GND']:
print(u'')
print(u'════ %s ════' % n)
one_net(n, d)
return
one_net(net_filter, d)
def _pour_filter(net):
u"""返回一个「这个点在不在本网自己的铜面里」的判定函数;本网没有铜面则 None。
⚠️ 2026-09-06 改。原来这里指着 **`pcb_pour.py`** —— 那是**单板时代 v5** 的铜面几何。
下板拆板重摆之后铜面换成了 `pcb1_pour.REGIONS` 的 v6 分块,而这个引用没人动过。
失效方式和写死网名是同一类,**静默**:`pcb_pour.POUR_PARTIAL` 里只有 `+5V`/`+3V3`,
别的网络一律返回 None、照常扇出,只是把过孔打在 v5 那一版铜面的位置上;
DRC 干干净净,要到铺完铜回读断面才看得出来。⇒ 事实来源只留 `pcb1_pour.py` 一处。
**每个网络都要过这一关**,不再分「完整覆盖 / 只盖一部分」——
Inner2 是分割电源面,连 `+12V` 都只盖三块 L/⌐ 形;
GND 底填也只到板框内缩 0.5,不是「整板」。
判据是**过孔中心**在不在多边形里,不要求整个过孔都在里面:
铜面内缩 0.5、过孔离板边 0.6(EDGE_KEEP),板边那一圈 GND 过孔的中心在铜里、
铜环却探出去 0.3 —— 那是正常的,铺铜照样连得上。
"""
import pcb1_pour as P
lay = TARGET.get(net)
polys = [r['poly'] for r in P.REGIONS if r['net'] == net and r['layer'] == lay]
if not polys:
return None
return lambda x, y: any(P.inside(t, x, y) for t in polys)
def one_net(net_filter, d):
pads = d['pads']
if d['vias']:
print(u'注意:板上已有 %d 个过孔,它们也会被当障碍' % len(d['vias']))
grid = Grid()
for p in pads:
grid.add(pad_box(p), p)
# 第二张网格:钻孔与挖槽。铜箔挡不住它们(见 hole_box 的注释)
holes = Grid()
for p in pads:
hb = hole_box(p)
if hb:
holes.add(hb, p) # ⚠️ 第二位存**孔的主人**,并线时要认出「自家的孔」
for v in d['vias']:
# ⚠️ 过孔要按**两个半径**分别登记:
# 钻孔半径 0.15 进 holes(孔到孔的间距),
# **焊盘半径 0.3 进 grid**(铜到铜的间距)。
# 只登记钻孔的后果实测过:1.0 mm 的功率出线贴着 GND 过孔的铜箔走,
# DRC 报 10 条「导线到过孔 3.4mil,应 >= 7.9mil」—— 差的正好是 0.15 mm。
hr = v['hole'] / 2.0
holes.add((v['x'] - hr, v['y'] - hr, v['x'] + hr, v['y'] + hr), None)
pr = v.get('dia', VIA_OD) / 2.0
grid.add((v['x'] - pr, v['y'] - pr, v['x'] + pr, v['y'] + pr),
{'net': v['net'], 'x': v['x'], 'y': v['y'], '_via': True})
# ⚠️ 判据是**层号**:layer 12 = 多层(真通孔),1/2 = 单铜层(贴片)。
# 别再用 `not p['hole']` —— 见文件头第 3 条。
todo = [p for p in pads
if p['net'] == net_filter and p['layer'] in FANOUT_LAYERS]
stale = [p for p in todo if p['hole']]
if stale:
print(u' ⚠ %d 个贴片焊盘还带着 hole(%s ...)—— pcb-copper.json 是旧的,'
u'先重跑 dump_pads.py' % (len(stale),
' '.join('%s.%s' % (q.get('des'), q['num']) for q in stale[:4])))
# `--only R1,R2.3` 只补这几个焊盘(板上已经有整轮扇出、只想补一两个漏网的时候用)。
# 写 `位号` = 该器件全部焊盘;写 `位号.引脚` = 只那一个脚。
if '--only' in sys.argv:
want = set(sys.argv[sys.argv.index('--only') + 1].split(','))
todo = [p for p in todo
if (p['des'] or '') in want or '%s.%s' % (p['des'], p['num']) in want]
print(u' --only:这一轮只扇 %d 个焊盘 %s'
% (len(todo), ' '.join('%s.%s' % (p['des'], p['num']) for p in todo)))
skipped = [p for p in todo if SKIP_PAD(p)]
if skipped:
todo = [p for p in todo if not SKIP_PAD(p)]
who = {}
for p in skipped:
who[p['des'] or '?'] = who.get(p['des'] or '?', 0) + 1
print(u' 跳过 %d 个焊盘(由别的脚本接管):%s'
% (len(skipped), ' '.join('%s×%d' % kv for kv in sorted(who.items()))))
# ⚠️ 铜面只盖到 `pcb1_pour.REGIONS` 画的地方。铜面外的焊盘扇出去也接不到铜,
# 那是一个悬空过孔 —— DRC 反而多一条断连,还平白在两个平面上戳个洞。
keep = _pour_filter(net_filter)
if keep is not None:
before = len(todo)
todo = [p for p in todo if keep(p['x'], p['y'])]
if len(todo) != before:
print(u' 铜面(pcb1_pour)覆盖:%d 个焊盘里 %d 个在铜面内,其余 %d 个靠走线'
% (before, len(todo), before - len(todo)))
layer = TARGET.get(net_filter)
if layer is None:
print(u'网络 %s 没有目标平面层' % net_filter); sys.exit(1)
print(u'%s:贴片焊盘 %d 个要扇出到 layer %d' % (net_filter, len(todo), layer))
placed = [] # (pad, vx, vy) —— vx/vy 为 None 表示盘内阵列,不用引线
fail = []
via_boxes = Grid() # 已放的过孔,互相之间也要留间距
# ⚠️ 出线之间的间距原来完全没查(只查了线对焊盘、线对钻孔)。
# $1N223 与 $1N227 两条 1.0 mm 出线因此贴到 0.157 mm,DRC 报了出来。
trk_grid = Grid() # 已放的出线:(x1,y1,x2,y2,w,net)
def trk_ok(sx, sy, vx, vy, tw, net, lay):
u"""出线 vs 出线 / 板上已有走线。**同层**才比(索引 6 是层号)。"""
m = tw / 2.0 + CLR
for b, o in trk_grid.near((min(sx, vx) - m - EXIST_W_MAX, min(sy, vy) - m - EXIST_W_MAX,
max(sx, vx) + m + EXIST_W_MAX, max(sy, vy) + m + EXIST_W_MAX)):
if o[5] == net or o[6] != lay:
continue
need = tw / 2.0 + o[4] / 2.0 + CLR
if seg_seg_dist(sx, sy, vx, vy, o[0], o[1], o[2], o[3]) < need - EPS:
return False
return True
def via_ok_trk(vx, vy, net):
u"""过孔 vs 板上已有走线。过孔贯穿 1/2/15/16,**不分层**都要让。"""
for b, o in trk_grid.near((vx - VIA_R - CLR - EXIST_W_MAX, vy - VIA_R - CLR - EXIST_W_MAX,
vx + VIA_R + CLR + EXIST_W_MAX, vy + VIA_R + CLR + EXIST_W_MAX)):
if o[5] == net:
continue
if seg_seg_dist(vx, vy, vx, vy, o[0], o[1], o[2], o[3]) < VIA_R + o[4] / 2.0 + CLR - EPS:
return False
return True
def trk_add(sx, sy, vx, vy, tw, net, lay):
m = tw / 2.0
trk_grid.add((min(sx, vx) - m, min(sy, vy) - m,
max(sx, vx) + m, max(sy, vy) + m), (sx, sy, vx, vy, tw, net, lay))
# 板上已有的走线(差分对、+3V3 车道……)先全部登记成障碍
n_exist = 0
for l in d.get('lines', ()):
if l['layer'] not in EXIST_LAYERS:
continue
trk_add(l['x1'], l['y1'], l['x2'], l['y2'], l['w'], l.get('net', ''), l['layer'])
n_exist += 1
if n_exist:
print(u'注意:板上已有 %d 条走线,它们也会被当障碍(同层比线、过孔不分层)' % n_exist)
# ── 散热盘先做:盘内过孔阵列 ──
def thermal_pts(p, pitch):
u"""按 pitch 在盘内排一圈候选孔位,外加**中心那一个**。
偶数栅格(U1 的 2 x 2)中心正好是空的,而散热盘最该打孔的就是正中间。
补进来之前先按钻孔间距和阵列里已有的孔比一遍。
"""
b = pad_box(p)
ux0, uy0 = b[0] + THERMAL_INSET, b[1] + THERMAL_INSET
ux1, uy1 = b[2] - THERMAL_INSET, b[3] - THERMAL_INSET
if ux1 < ux0 or uy1 < uy0:
return []
nx = max(1, int((ux1 - ux0) / pitch) + 1)
ny = max(1, int((uy1 - uy0) / pitch) + 1)
cx, cy = (ux0 + ux1) / 2.0, (uy0 + uy1) / 2.0
cx0, cy0 = cx - (nx - 1) * pitch / 2.0, cy - (ny - 1) * pitch / 2.0
pts = [(cx0 + i * pitch, cy0 + j * pitch)
for i in range(nx) for j in range(ny)]
if all(math.hypot(cx - q[0], cy - q[1]) >= VIA_ID + HOLE_CLR - EPS for q in pts):
pts.append((cx, cy))
return pts
thermal = []
for p in list(todo):
hx, hy = pad_halfsize(p)
# ⚠️ 盘内阵列只给 GND 的散热盘。功率网络的大焊盘(Rs101 那种 2512 分流)
# 在盘里打孔会引起吃锡,焊点强度受影响 —— 它们的过孔一律放到盘外。
if net_filter != 'GND':
continue
if (4 * hx * hy < THERMAL_AREA
and '%s.%s' % (p.get('des'), p['num']) not in THERMAL_PADS):
continue
poly = pad_poly(p) # 十字形的 EP 不能按包围盒撒(见 pad_poly 的注释)
best, best_pitch = [], THERMAL_PITCH
for pitch in (THERMAL_PITCH, THERMAL_PITCH_MIN):
cand = []
for vx, vy in thermal_pts(p, pitch):
if poly and not disc_in_poly(poly, vx, vy, VIA_R + 0.05):
continue # 落在铜外(十字的两个凹角)
hb = (vx - VIA_ID / 2.0, vy - VIA_ID / 2.0,
vx + VIA_ID / 2.0, vy + VIA_ID / 2.0)
clash = False
for b, o in holes.near(inflate(hb, HOLE_CLR)):
if pt_in_box(vx, vy, inflate(b, VIA_ID / 2.0 + HOLE_CLR)):
clash = True; break
for q in cand: # 阵列内部自己也要够钻孔间距
if math.hypot(vx - q[0], vy - q[1]) < VIA_ID + HOLE_CLR - EPS:
clash = True; break
if not clash:
cand.append((vx, vy))
if len(cand) > len(best):
best, best_pitch = cand, pitch
for vx, vy in best:
placed.append((p, vx, vy, vx, vy, TRK_W)) # 盘内,不用引线
via_boxes.add((vx - VIA_R, vy - VIA_R, vx + VIA_R, vy + VIA_R), (vx, vy))
thermal.append((p, len(best), best_pitch))
todo.remove(p)
if thermal:
print(u' 散热/大焊盘 %d 个走盘内阵列,共 %d 个过孔:'
% (len(thermal), sum(n for _, n, _ in thermal)))
for p, n, pitch in thermal[:8]:
hx, hy = pad_halfsize(p)
print(u' %-6s pin%-4s %.2f x %.2f mm 间距 %.2f -> %d 个%s'
% (p['des'] or '?', p['num'], hx * 2, hy * 2, pitch, n,
u' (多边形盘)' if pad_poly(p) else u''))
if len(thermal) > 8:
print(u' ... 还有 %d 个' % (len(thermal) - 8))
def search(p, n_ang, max_dd, tw=TRK_W, at=None):
u"""在 p 周围找一个能放过孔的位置。返回 (vx, vy, sx, sy),
(sx, sy) 是引线在焊盘上的**起点**。
⚠️ 起点不能一律取焊盘中心。QFN 角脚的中心常常正好压在相邻脚的间距边界上
(U14 pin1 中心 x=4.7501,pin2 的边界 4.750001 —— 差 0.1 µm,
那是 dump 量化到 1e-4 mm 的舍入噪声)。靠调大 EPS 去糊弄间距是错的,
正确做法是**从焊盘上离过孔最近的那一点出线**,真人布线也是这么走的。
"""
dbg = DEBUG_PAD == '%s.%s' % (p.get('des'), p['num'])
why = {}
def _no(k, vx, vy):
if dbg:
why.setdefault(k, []).append((vx, vy))
hx, hy = pad_halfsize(p)
base = math.hypot(hx, hy) + MARGIN_VIA
m_trk = tw / 2.0 + CLR
pb = pad_box(p)
ins = 0.05
bx0, bx1 = pb[0] + min(ins, hx / 2), pb[2] - min(ins, hx / 2)
by0, by1 = pb[1] + min(ins, hy / 2), pb[3] - min(ins, hy / 2)
px, py = p['x'], p['y']
angs = [i * 2 * math.pi / n_ang for i in range(n_ang)]
# 只许往某个方向扇(C747/C748 的 GND 脚南边是 +3V3 进线,只能往北打)
rng = ANG_RANGE.get((p.get('des') or '', str(p['num'])))
if rng:
a0, a1 = math.radians(rng[0]) % (2 * math.pi), math.radians(rng[1]) % (2 * math.pi)
def _in(t):
t %= 2 * math.pi
return (a0 <= t <= a1) if a0 <= a1 else (t >= a0 or t <= a1)
angs = [t for t in angs if _in(t)]
def _cands():
u"""候选过孔中心。`at` 非空 = 手工钉死的位置,只试这一个(仍然走全套校核)。"""
if at is not None:
yield at[0], at[1]
return
for dd in [v / 100.0 for v in range(0, int(max_dd * 100), 5)]:
r = base + dd
for a in angs:
yield px + r * math.cos(a), py + r * math.sin(a)
for _dd in (0,):
for vx, vy in _cands():
if not inside_board(vx, vy):
_no(u'出板边', vx, vy); continue
if pt_in_box(vx, vy, inflate(ANT, VIA_R)):
_no(u'ESP32 天线禁铺区', vx, vy); continue
# 焊盘在铜面里不代表**过孔**也在:铜面边界上的焊盘,
# 往外那几个角度的候选位置会掉到铜面外去(+3V3 南西块的西边界就是)。
if keep is not None and not keep(vx, vy):
_no(u'掉到本网铜面外', vx, vy); continue
sx = min(max(vx, bx0), bx1) # 焊盘上离过孔最近的点
sy = min(max(vy, by0), by1)
vb = (vx - VIA_R, vy - VIA_R, vx + VIA_R, vy + VIA_R)
ok = True
for b, o in grid.near(inflate(vb, CLR)):
if o is p:
continue
if pt_in_box(vx, vy, inflate(b, MARGIN_VIA)):
ok = False
_no(u'过孔铜箔 vs 焊盘 %s.%s' % (o.get('des'), o.get('num')), vx, vy)
break
if ok:
# 过孔的钻孔 vs 别的钻孔/挖槽:孔边到孔边 >= 0.3
hb = (vx - VIA_ID / 2.0, vy - VIA_ID / 2.0,
vx + VIA_ID / 2.0, vy + VIA_ID / 2.0)
for b, o in holes.near(inflate(hb, HOLE_CLR)):
if pt_in_box(vx, vy, inflate(b, VIA_ID / 2.0 + HOLE_CLR)):
ok = False; _no(u'钻孔 vs 钻孔/挖槽', vx, vy); break
if ok:
# 过孔铜箔 vs 挖槽(槽也要留安全间距)
for b, o in holes.near(inflate(vb, CLR)):
if pt_in_box(vx, vy, inflate(b, VIA_R + CLR)):
ok = False; _no(u'过孔铜箔 vs 挖槽', vx, vy); break
if ok:
for b, o in via_boxes.near(inflate(vb, VIA_OD + CLR)):
if math.hypot(vx - o[0], vy - o[1]) < VIA_OD + CLR - EPS:
ok = False; _no(u'贴到这一轮刚放的过孔', vx, vy); break
if ok and not via_ok_trk(vx, vy, p['net']):
ok = False; _no(u'过孔 vs 板上已有走线', vx, vy)
if ok:
sb = (min(sx, vx) - m_trk, min(sy, vy) - m_trk,
max(sx, vx) + m_trk, max(sy, vy) + m_trk)
for b, o in grid.near(sb):
if o is p or o['net'] == p['net']:
continue
if seg_hits_box(sx, sy, vx, vy, inflate(b, m_trk)):
ok = False
_no(u'引线 vs 焊盘 %s.%s' % (o.get('des'), o.get('num')), vx, vy)
break
if ok:
for b, o in holes.near(sb): # 导线 vs 挖槽/钻孔
if seg_hits_box(sx, sy, vx, vy, inflate(b, m_trk)):
ok = False; _no(u'引线 vs 挖槽/钻孔', vx, vy); break
if ok and not trk_ok(sx, sy, vx, vy, tw, p['net'], p['layer']):
ok = False; _no(u'引线 vs 引线/板上已有走线', vx, vy)
if ok:
return (vx, vy, sx, sy, tw)
if dbg:
print(u' [why] %s.%s (%.3f, %.3f) 线宽 %.2f:%d 个候选孔位全被否'
% (p.get('des'), p['num'], px, py, tw, sum(len(v) for v in why.values())))
for k, v in sorted(why.items(), key=lambda kv: -len(kv[1])):
print(u' %5d × %-34s 例:(%.3f, %.3f)' % (len(v), k, v[0][0], v[0][1]))
return None
want = N_VIA.get(net_filter, 1)
multi = 0
for p in todo:
got = 0
if '%s.%s' % (p.get('des'), p['num']) in PREFER_LINK:
fail.append(p) # 直接落到第三遍去并线,见 PREFER_LINK
continue
tw0 = pad_trk_w(p, net_filter)
# ⭐ 整片阵列钉死的焊盘:只打表里那几个孔,一个都不自动搜(见 FORCE_ARRAY)
fa = FORCE_ARRAY.get((p.get('des') or '', str(p['num'])))
if fa:
for vx, vy in fa:
best = search(p, 16, 2.6, tw0, at=(vx, vy))
if not best:
print(u' !! 阵列孔位 %s pin%s @ (%.2f, %.2f) 净距不过,跳过'
% (p['des'] or '?', p['num'], vx, vy))
continue
placed.append((p,) + best)
via_boxes.add((best[0] - VIA_R, best[1] - VIA_R,
best[0] + VIA_R, best[1] + VIA_R), best)
trk_add(best[2], best[3], best[0], best[1], best[4], p['net'], p['layer'])
got += 1
if got == 0:
fail.append(p)
multi += got
print(u' 阵列:%s.%s 钉死 %d 个孔,放下 %d 个(≈%.1f A)'
% (p['des'], p['num'], len(fa), got, got * 1.0))
continue
# 连打 pad_n_via 个:每放一个就登记成障碍,下一个自然挪到旁边
for _k in range(pad_n_via(p, net_filter)):
# 手工钉死的孔位(第一个过孔用;多过孔的网络后面的仍然自动找)
fv = FORCE_VIA.get((p.get('des') or '', str(p['num']))) if _k == 0 else None
if fv is not None:
best = search(p, 16, 2.6, tw0, at=fv)
if not best:
print(u' !! 钉死的孔位 %s pin%s @ (%.2f, %.2f) 净距不过,改用自动'
% (p['des'] or '?', p['num'], fv[0], fv[1]))
best = search(p, 16, 2.6, tw0)
else:
best = search(p, 16, 2.6, tw0)
if not best:
break
placed.append((p,) + best)
via_boxes.add((best[0] - VIA_R, best[1] - VIA_R,
best[0] + VIA_R, best[1] + VIA_R), best)
trk_add(best[2], best[3], best[0], best[1], best[4], p['net'], p['layer'])
got += 1
if got == 0:
fail.append(p)
multi += got
if want > 1:
print(u' 第一遍:%d 个焊盘要 %d 个过孔,实际放下 %d 个(平均 %.1f)'
% (len(todo), want, multi, multi / float(len(todo) or 1)))
# ── 第二遍:放宽到 32 个角度、4 mm 半径再找一次 ──
if fail:
n0 = len(fail)
again = []
for p in fail:
best = None
if '%s.%s' % (p.get('des'), p['num']) in PREFER_LINK:
again.append(p) # ⚠️ 第二遍也要让开,否则它在这里又把孔找回来了
continue
# PWR4A / PWR12V 的最小线宽已降到 0.3(见 pcb_rules.py 的注释),
# 所以这里可以退到 0.3,但**不能再细**,否则又是线宽违规。
tw0 = pad_trk_w(p, net_filter)
for tw in (TRK_W_TIGHT if net_filter == 'GND' else [tw0, 0.3]):
# 72 个角(5°)。原来是 32 个(11.25°)—— 实测 R710 pin2 的唯一出路
# 在 230°,正好落在 225° 与 236.25° 之间的缝里,整个第二遍都摸不到它。
best = search(p, 72, 4.0, tw)
if best:
break
if best:
placed.append((p,) + best)
via_boxes.add((best[0] - VIA_R, best[1] - VIA_R,
best[0] + VIA_R, best[1] + VIA_R), best)
trk_add(best[2], best[3], best[0], best[1], best[4], p['net'], p['layer'])
else:
again.append(p)
fail = again
print(u' 第二遍(72 角 / 4 mm):%d 个里救回 %d 个' % (n0, n0 - len(fail)))
# ── 第三遍:并到旁边的同网络焊盘上,不给它自己的过孔 ──
# QFN 的角脚(eFuse pin1/pin16、U32 pin13)四周被别的脚围死,
# 但它们紧挨着**同为 GND 的散热盘**,散热盘已经有过孔阵列了。
# 这时候正确做法是一条短线并过去,而不是硬挤一个过孔出来。
links = []
if fail:
# ⚠️ 2026-09-06 加第三类锚点:**这一轮已经扇出成功、身上已经有过孔的同网焊盘**。
# 原来只认「通孔 / 散热盘」,于是 0.5 mm 间距 QFN 的中间脚一旦被邻脚的过孔
# 堵住正北那条唯一出路,就只能报失败 —— 哪怕 0.8 mm 外就趴着一颗自己的去耦电容,
# 而那颗电容的焊盘刚刚才打过一个过孔。真人这时候画的是一条 0.4 mm 的短线并过去。
# (U6.14 / U6.15 / U7.15 三个脚就是这么救回来的。)
has_via = set(id(v[0]) for v in placed)
anchors = [q for q in pads
if q['net'] == net_filter and q is not None
and (q['hole'] or 4 * pad_halfsize(q)[0] * pad_halfsize(q)[1] >= THERMAL_AREA
or id(q) in has_via)]
again = []
for p in fail:
px, py = p['x'], p['y']
best = None
# ⚠️ 并线也要能退到窄线宽。0.5 mm 间距 QFN 那一带,0.3 的线常常**差几微米**
# 过不去:U6.15 并到 C607.1 时,线心到 C607.2(GND 焊盘)有 0.346 mm,
# 0.3 的线要 0.35 —— 差 0.004 mm,整个脚就断在那里。
# 并线走的是 mA 级的地 / 3V3,规则里最小线宽 0.102,退到 0.2 完全合法。
for lw in (TRK_W, 0.2, 0.15):
m_lnk = lw / 2.0 + CLR
if best:
break
for a in anchors:
if a is p:
continue
b = pad_box(a)
# ⚠️ 瞄**边缘上的最近点**,不要往盘里缩 —— 往里缩会让线斜出去,
# 正好蹭到相邻脚的间距区(U14 pin1 就是这么失败的)。
# 落点定在边上,再沿原方向探进 0.15 mm 保证真的搭上。
ex = min(max(px, b[0]), b[2])
ey = min(max(py, b[1]), b[3])
# 起点同样取源焊盘上离目标最近的点,理由见 search() 的注释
sb0 = pad_box(p)
shx, shy = pad_halfsize(p)
sx = min(max(ex, sb0[0] + min(0.05, shx / 2)), sb0[2] - min(0.05, shx / 2))
sy = min(max(ey, sb0[1] + min(0.05, shy / 2)), sb0[3] - min(0.05, shy / 2))
dist = math.hypot(ex - sx, ey - sy)
if dist < 1e-6 or dist > LINK_MAX:
continue
# ⚠️ 并线画在 p 自己那一层:目标要么是通孔焊盘,要么同层,
# 否则画出来的是一条**不通**的线(2026-09-06 实测:
# C745 pin2 一路并到 9.78 mm 外的 J64 pin3 去了)。
if not a['hole'] and a['layer'] != p['layer']:
continue
tx = ex + (ex - sx) / dist * 0.15
ty = ey + (ey - sy) / dist * 0.15
if best and dist >= best[0]:
continue
bad = False
sb = (min(sx, tx) - m_lnk, min(sy, ty) - m_lnk,
max(sx, tx) + m_lnk, max(sy, ty) + m_lnk)
for bb, o in grid.near(sb):
if o is p or o is a or o['net'] == p['net']:
continue
if seg_hits_box(sx, sy, tx, ty, inflate(bb, m_lnk)):
bad = True; break
if not bad:
for bb, o in holes.near(sb): # 并线 vs 挖槽 / 钻孔
# ⚠️ **起止两个焊盘自己的孔不算障碍**。并线的终点本来就落在 a 上,
# 而 a 常常正是一个自带钻孔的焊盘(QFN 散热盘里那个 Ø1.02
# 热过孔、直插焊盘的孔)—— 把它当障碍,等于「这条线不许接到
# 它要接的那个焊盘上」。2026-09-06 实测:U6.14 唯一的出路是
# 并到自己的散热盘 U6.17,就是被 U6.17 自己的孔挡掉的。
if o is a or o is p:
continue
if seg_hits_box(sx, sy, tx, ty, inflate(bb, m_lnk)):
bad = True; break
if not bad and not trk_ok(sx, sy, tx, ty, lw, p['net'], p['layer']):
bad = True
if not bad:
best = (dist, tx, ty, a, sx, sy, lw)
if best:
links.append((p, best[1], best[2], best[3], best[4], best[5], best[6]))
else:
again.append(p)
fail = again
if links:
print(u' 第三遍(并到同网络的散热盘/通孔):%d 个' % len(links))
for p, tx, ty, a, sx, sy, lw in links:
print(u' %-6s pin%-4s -> %-6s pin%-4s 线长 %.2f mm / 线宽 %.2f'
% (p['des'] or '?', p['num'], a['des'] or '?', a['num'],
math.hypot(tx - sx, ty - sy), lw))
print(u' 放下 %d 个过孔,失败 %d 个' % (len(placed), len(fail)))
if fail:
print(u' 放不下的(周围太挤,要手工处理):')
for p in fail[:20]:
print(u' %-8s pin%-4s (%.2f, %.2f)' % (p['des'] or '?', p['num'], p['x'], p['y']))
if len(fail) > 20:
print(u' ... 还有 %d 个' % (len(fail) - 20))
if placed:
ds = [math.hypot(v[1] - v[3], v[2] - v[4]) for v in placed]
thin = [v for v in placed if v[5] < TRK_W - 1e-9]
print(u' 引线长度 中位 %.2f / 最长 %.2f mm' % (sorted(ds)[len(ds) // 2], max(ds)))
if thin:
print(u' 其中 %d 条用了窄线出线(细间距 QFN):%s'
% (len(thin), ' '.join('%s pin%s@%.2fmm' % (v[0]['des'] or '?', v[0]['num'], v[5])
for v in thin[:6])))
# `--dump`:把这一轮的规划写成 json,下发前可以逐条核(尤其是钉死的孔位)
if '--dump' in sys.argv:
out = os.path.join(HERE, '_fanout_plan_%s.json' % net_filter.strip('+$'))
io.open(out, 'w', encoding='utf-8').write(json.dumps(
{'net': net_filter, 'layer': layer,
'vias': [{'des': p['des'], 'pin': p['num'], 'px': p['x'], 'py': p['y'],
'vx': round(vx, 4), 'vy': round(vy, 4),
'sx': round(sx, 4), 'sy': round(sy, 4),
'w': tw, 'lay': p['layer'],
'len': round(math.hypot(vx - sx, vy - sy), 4)}
for p, vx, vy, sx, sy, tw in placed],
'links': [{'des': p['des'], 'pin': p['num'], 'to': a['des'], 'toPin': a['num'],
'sx': round(sx, 4), 'sy': round(sy, 4),
'tx': round(tx, 4), 'ty': round(ty, 4),
'len': round(math.hypot(tx - sx, ty - sy), 4)}
for p, tx, ty, a, sx, sy, lw in links],
'fail': [{'des': p['des'], 'pin': p['num'], 'x': p['x'], 'y': p['y']} for p in fail]},
ensure_ascii=False, indent=1))
print(u' -> %s' % out)
# 本轮的过孔并回共享的障碍表 —— --net all 时下一个网络要看得见它们
for _p, _vx, _vy, _sx, _sy, _tw in placed:
d['vias'].append({'net': net_filter, 'x': _vx, 'y': _vy,
'hole': VIA_ID, 'dia': VIA_OD})
if '--push' not in sys.argv:
print(u'(未加 --push,没有下发)')
return
# ⚠️ 盘内阵列的过孔不要画引线:长度为 0 的导线是 skill §8a 那个坑的 PCB 版
# ⚠️ `lay` = **焊盘自己那一层**。原来 JS 里写死 layer 1,下板全是顶层器件所以没事;
# 载板底层有 J31/J32 和整个 Hub,写死 1 会把它们的出线画到顶层去 —— 悬空。
payload = [{'net': p['net'], 'x': vx * MIL, 'y': vy * MIL, 'lay': p['layer'],
'px': sx * MIL, 'py': sy * MIL, 'via': 1, 'w': tw * MIL,
'trk': 1 if math.hypot(vx - sx, vy - sy) > 0.05 else 0}
for p, vx, vy, sx, sy, tw in placed]
payload += [{'net': p['net'], 'x': tx * MIL, 'y': ty * MIL, 'lay': p['layer'],
'px': sx * MIL, 'py': sy * MIL, 'via': 0, 'trk': 1,
'w': lw * MIL}
for p, tx, ty, a, sx, sy, lw in links]
# ⚠️ 填参顺序:模板里先 OD 后 ID。曾按 (VIA_ID, VIA_OD) 填,结果 459 个过孔
# 全建成「孔 0.61 / 焊盘 0.305」—— 孔比焊盘还大,板子直接报废。
# API 本身没问题:按文档顺序 create 再 getAll 读回就是 0.3048 / 0.6096。
JS = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1200));
const V = %s, OD = %f, ID = %f;
let nv = 0, nl = 0;
for (const v of V) {
if (v.via) {
const via = await eda.pcb_PrimitiveVia.create(v.net, v.x, v.y, ID, OD);
if (via) nv++;
}
if (v.trk) {
const ln = await eda.pcb_PrimitiveLine.create(v.net, v.lay || 1, v.px, v.py, v.x, v.y, v.w);
if (ln) nl++;
}
}
await eda.pcb_Document.save();
return {nv, nl, totalVias: (await eda.pcb_PrimitiveVia.getAll()).length};
""" % (PCB, json.dumps(payload), VIA_OD * MIL, VIA_ID * MIL)
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,板上过孔总数 %d'
% (r['result']['nv'], r['result']['nl'], r['result']['totalVias']))
if __name__ == '__main__':
main()