# -*- 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()