文件

2652 行
138 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""R2 刀片 PCB 的**自建布线器**(M4c-4)。
/c/Python314/python .claude/r2/router.py # 只算 + 打印,不下发
/c/Python314/python .claude/r2/router.py --push # 下发导线 + 过孔
/c/Python314/python .claude/r2/router.py --clear # 清掉本脚本布的线(保留 GND 缝合过孔)
## 为什么要自己写
嘉立创 EDA 的自动布线在这台客户端上**跑不出一根铜**(方案 §24.6):
`pcb_Document.autoRouting` 这个 API 根本不存在;GUI 的对话框能开能配,
点「运行」之后立刻关闭、铜线数 0,不报错不弹进度。⇒ 只能自己布。
## 做法
**0.25 mm 网格 + 8 邻接 A\\***,两层(L1 顶 / L4 底),换层收过孔代价。
- 网格选 0.25 是因为**金手指 1.0 mm 间距正好落在格点上**(32.5 / 0.25 = 130),
64 根指的扇出不用做特殊处理。
- 障碍按「导线半宽 + 净距」膨胀 ⇒ 一条 0.2 mm 的线会把 0.25 外的相邻格点也占掉,
自动保证平行线中心距 ≥ 0.5 mm。**不用另外写间距检查**。
- 焊盘中心大多不在格点上(摆位是 0.001 精度),所以每个焊盘先出一小段
**引出脚**(pad 中心 → 最近的可用格点),再交给 A\\*。
## ⛔ 三条硬约束(写死在代码里,不是注释)
1. **只准布 L1 / L4**。L2 是「完整地平面,不许割断」(§7.1),L3 是 `VO_P` / `+12V` 的岛。
2. **金手指区(y < 6.0)除了 J1 自己的引出脚,一根线都不许有** ——
那一带外层铜的邻居是 0.3 mm 间距的指,蹭上就是短路,而 DRC 只报间距不报"接错了指"。
3. **缺口(防呆键)四周 0.5 mm 内不许有铜**,缺口本身是铣穿的。
## `GND` / `VO_P` / `+12V` 不走 A\\*,走铜面
- `GND`:顶/底/L2/L3 四块铜面 + 196 个缝合过孔已经接好;**只有 J1 的 39 根地指**
在铜面下沿(y=11.5)以下够不着,给它们各出一段引出脚 + 一个过孔。
- `VO_P`:L3 上有整块 `VO-L3` 岛(4 A 走平面,比走线好)。每个 `VO_P` 焊盘
出一小段引出脚 + 一个过孔扎进岛里。
- `+12V`:`V12-L3` 岛在功率列上半段,够不着金手指。金手指那五根 + F1 之间
用底层一条 0.8 mm 的走廊连过去,两端各一个过孔。
"""
import heapq
import io
import json
import math
import os
import sys
sys.stdout.reconfigure(encoding='utf-8')
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place),
# 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份,
# 而且**不报错**,只在调到 R2 专有的函数时才炸。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
import pcb_blade as B # noqa: E402
import fp_edge # noqa: E402
import pcb_pour as P # noqa: E402
import b4_edge # noqa: E402 针位表的唯一事实来源
PCB = B.PCB
MIL = B.MIL
PADS = os.path.join(HERE, '_blade_pads.json')
# ⛔ 2026-09-11 起**布线运算只在 10.5.5.13 上跑**(CLAUDE.md 硬规则),入口 `remote_blade.sh`。
# 服务器上没有桥接 ⇒ 板上已有的过孔改读本机抓的快照(`--snapshot-vias` 写)。
OFFLINE = bool(os.environ.get('DCDC_ROUTE_SERVER')) or '--offline' in sys.argv
VIAS_SNAP = os.path.join(HERE, '_blade_board_vias.json')
G = 0.25 # 网格(mm)
NX = int(round(B.BX1 / G)) + 1 # 2026-09-14 晚:网格从 x=0 起、覆盖到板框右沿 BX1(左沿 BX0 之外按板边封)
NY = int(round(B.BH / G)) + 1
# 布线用的净距。设计规则是 0.15;这里留一点余量给网格吸附。
# ⚠ 0.18 -> 0.16:通道宽度 = 线宽 + 2×净距,0.2+0.36=0.56 在这块板上处处卡壳,
# 0.15+0.32=0.47 才挤得过去。仍高于规则的 0.15。
CLR = 0.16
STUB_CLR = 0.15 # 引出脚(缩窄段)用规则值本身,见 pad_nodes
EDGE_CLR = 0.35 # 到板边 / 缺口
# ⚠ M6(2026-09-09)0.6/0.3 -> 0.5/0.25:细间距扇出的过孔要能塞进 0.5 mm 节距的
# 引出脚之间(0.5 − 0.25 − 0.075 = 0.175 ≥ 0.15)。规则下限 0.3/0.2,嘉立创四层做得出。
VIA_D, VIA_HOLE = 0.6, 0.3 # `--viafan` 那一版用过 0.5/0.25,默认仍 0.6/0.3(与缓存一致)
# 换层代价(格)。⚠ 2026-09-08 器件翻面之后这条注释反过来了:
# 现在 **73 件在底层、顶层几乎全空**,换层代价定高了等于逼所有线挤在底层。
# 12 格 = 3 mm 还是太贵,降到 8(= 2 mm)。
VIA_COST = 8
# 焊盘周围的「过路费」:半径(mm)与代价(格)。见 build_grid 末尾的说明。
PAD_PEN_R = 0.55
PAD_PEN = 10
LAY = (1, 2) # 只准这两层
FINGER_KEEP_Y = 6.0 # 金手指区上沿:A* 不许进
# 线宽(mm)—— 和 pcb_rules.py 的规则表对齐
# 信号线宽。⚠ 0.2 -> 0.15:这块板的信号全是逻辑 / I²C,0.15 载流绰绰有余,
# 而 0.05 的差在 0.5~0.65 mm 间距的封装周围就是「过得去」和「过不去」的分界。
# 规则里 PWR1A 的下限就是 0.15,嘉立创四层能做到 0.09。
W_DEFAULT = 0.15
WIDTH = {'+12V': 0.8, '+12V_F': 0.8, 'VO': 1.5, 'VO_P': 1.5,
'+5V': 0.6, 'VBUS': 0.6, '+3V3': 0.4}
WIDTH_AUTO = {'SW': 2.0, 'VO_F': 1.5}
# `--w5v 0.3`(2026-09-14 BLADE-PCB_1):+5V 在新摆位上 0.6 宽从金手指到 U5 找不到路,补线时临时收窄
# (刀片 +5V 只喂 U5→手机 VBUS 检测/USB,远低于 0.3 mm 外层 1 oz 的载流;规则 PWR5V 下限 0.20)。
if '--w5v' in sys.argv:
WIDTH['+5V'] = float(sys.argv[sys.argv.index('--w5v') + 1]) # 自动命名网,按 pcb_rules 的探针反查
# ⛔ **全局最优先布的网**(跨线宽档,比宽线还先)。
# `priority` 只在同一档宽度里排序,对「这一档整个被前面几档挤没了」无能为力;
# `FIRST_NETS` 是真的第一个布。BOOT 当初就是靠它解决的。
# ⇒ 把**已知难布的**全放进来:难的先挑路,容易的绕着走。
FIRST_NETS = (
# ⛔ 2026-09-11(90×68):`+12V` 排**最最前面**。服务带压到 11.34 之后,五根 12V 指(x 28~31)
# 北边是电池槽(y 18.14)、B 面正上方是泄放支路 R19/Q2,第一版让 `+3V3`(本表里靠后、但也在表里)
# 先布,一根 y 15.5 的顶层长线把槽下那条走廊封死 ⇒ 五根指全「没布通」、F1.1 一根铜都没有。
# M7 那条 0.8 的 12V 是 B 面 x≈36 北上(从阻尼电容两盘之间穿)→ y≈60 顶层东去 F1,
# 只要它先挑路就走得通。
'+12V',
'$1N138', '$1N126', # BOOT / FB:Buck 的命门
# ⛔ M6(2026-09-09):这五个网的焊盘会被**自家邻脚的引出脚**封死 ——
# U2.15 被 EFUSE_EN 的孔 (89.5,31) 卡到只剩 0.04 mm、U2.17 被 $1N158 的斜线拦、
# U2.2 被 $1N164 的孔拦;R16.1 / R8.2 四面是别的网的过孔。手工绕都绕不出来
# (+3V3 的 y=30.75 母线加缝合孔那一排把口袋封死)。只能让它们**最先**布。
# ⇒ 试过把它们排到最前面:这五个通了,但换来 20 处别的没通(EFUSE_EN/$1N164/
# $1N158 的 U2 脚、KEY_VUP/VDN 的 U4 脚反过来被封死)。**不是顺序问题,是 U2/U4
# 周围一共就那么几个出口**。默认不排前(留着这行注释别再试一遍)。
# ⚠ M7:`KEY_PWR/VUP/VDN` 三根挨着排最前面。它们接的是 `U4`(TSSOP-24) 北排
# **相邻的三只脚**(p4/p5/p6,0.65 mm 间距,不是 0.25 网格的整数倍),
# 三根引出脚互相挤,谁后布谁出不来(实测「KEY_VUP: U4.5 没布通」)。
'KEY_PWR', 'KEY_VUP', 'KEY_VDN',
'VBUS_EN', 'EFUSE_EN', '$1N164', 'USB_DPJ', '$1N151', 'SCL',
'INT#', 'PRSNT#', '+3V3', 'VBUS', 'EFUSE_FLT', 'EFUSE_PG',
'SDA', 'INA_ALERT', 'BUCK_PG', 'USB_DP', 'USB_THERM', 'EDL_EN',
'$1N147', '$1N158', '$1N173', '$1N136',
)
# `--first A,B,C`:服务器变体里临时把这几个网插到 `+12V` 之后、其余 FIRST_NETS 之前(不改文件)。
# ⚠ 变体参数会原样写进远程 run.sh,`$1N173` 在那里会被 bash 当成 `$1` 展开 ⇒ 网名里的 `$` 写成 `@`。
if '--first' in sys.argv:
_extra = [n.replace('@', '$') for n in sys.argv[sys.argv.index('--first') + 1].split(',') if n]
_head = tuple(n for n in FIRST_NETS[:1] if n == '+12V')
FIRST_NETS = _head + tuple(_extra) + tuple(n for n in FIRST_NETS if n not in _extra and n not in _head)
# ⛔ 2026-09-11 晚(方案 §35):`+12V` 也改成「每个焊盘打孔进 L3 岛」—— 原来那根 L4 x≈42.5 的 12V 竖线
# 把整块板东西切成两半(90×68 第一版没布通的 5 个网全被它挡着)。§7.1 本来就写 L3 = 12V + VO 灌铜。
PLANE_VIA_NETS = ('VO_P', '+12V') # 靠 L3 的岛成网,每个焊盘打孔进岛
PLANE_ISLANDS = {'VO_P': P.VO_RECTS, '+12V': P.V12_RECTS}
# 大电流焊盘多打几个孔:一个 Ø0.3 孔扛不住 4 A(原来 Rs1.1 的 VO_P 只靠 Kelvin 线上那一个孔进岛)
VIAS_PER_PAD = {('VO_P', 'J5'): 3, ('VO_P', 'Rs1'): 3, ('VO_P', 'C60'): 2, ('+12V', 'F1'): 2}
# `--gndvia`(2026-09-11 晚):细间距器件的 GND 脚各打一个孔下 L2 地平面(QFN 散热盘优先盘中孔)。
# 刀片一直 `SKIP = ('GND',)` 靠铺铜,而 0.35 / 0.5 节距的盘之间铺铜进不去 —— 90×68 第一版
# DRC 连接性里 27 条就是这些(J4 ×13、U2 1/16/EP、U3 1/2/7、U1 2/3、U4 20/21、Q4.2、K4.1、C13.2)。
GND_VIA_REFS = ('J4', 'J3', 'U1', 'U2', 'U3', 'U4', 'U5', 'U6', 'U7',
'Q1', 'Q2', 'Q3', 'Q4', 'Q5', 'K4', 'C13')
PLANE_ISLANDS['GND'] = [(B.BX0 + 0.8, FINGER_KEEP_Y + 1.0, B.BX1 - 0.8, B.BH - 0.8)] # L2 整层
# 细间距芯片的「禁换层圈」(见 build_grid):焊盘外接框再外扩 VIA_KEEP_M。`--vkeep 0` 关掉做对照。
VIA_KEEP_REFS = ('U1', 'U2', 'U3', 'U4')
# ⚠ 默认**关**(0):服务器第六轮 —— U2 角上有了预扇出(QFN_FAN)之后,禁换层圈反而把 U2 西侧所有线
# 逼在 L4 上挤出 x 77,断 6 个网;关掉只断 2 个。留着开关做对照。
VIA_KEEP_M = float(sys.argv[sys.argv.index('--vkeep') + 1]) if '--vkeep' in sys.argv else 0.0
if VIA_KEEP_M <= 0:
VIA_KEEP_REFS = ()
ISLAND_NETS = ('VO_P', '+12V') # 连通性判据里不按「一整块铜」要求的网(靠 L3 岛)
# ⛔ **Kelvin 取样**(方案 §7.2 第四条):INA226 的两个取样脚必须用**专线**落到
# `Rs1` 的两个焊盘上,不许「就近扎个孔进 VO_P 平面」——
# 走平面的话,取样点变成了平面上的某一处,量到的是「分流电阻 + 一段铜箔」的压降,
# 电流读数会随铜箔温度漂,而**DRC / 连通性检查全过**,只有实测才发现。
# 所以这两条在所有网络之前单独布,布完再让 VO_P 的平面过孔接管大电流。
KELVIN = [(('U3', '9'), ('Rs1', '1'), 0.3),
(('U3', '10'), ('Rs1', '2'), 0.3)]
KELVIN_PADS = set()
for _a, _b, _w in KELVIN:
KELVIN_PADS.add(_a)
KELVIN_PADS.add(_b)
# 取样**脚**(U3 那头)不许打平面孔;Rs1 那头是大电流焊盘,要打(Kelvin 线落在盘内侧,孔在外侧)
KELVIN_SENSE = {_a for _a, _b, _w in KELVIN}
PIN_TIES = [(('U3', '8'), ('U3', '9'), 0.3)] # 0.3 = PWR4A 线宽下限 # 见 plan_all 里「同网邻脚短接」
KELVIN_SENSE |= {_a for _a, _b, _w in PIN_TIES} # 短接的脚也不打平面孔
SKIP = ('GND',) # 地靠铜面,只单独处理 J1 的地指
# ⛔ **不按焊盘封顶线宽**的网。默认空(刀片一直是全封顶的)。
# 底板的七对 D± 必须保持 1.2 mm 的干线口径 —— 阻抗是按线宽算的(方案 §6.3),
# 被 U32 那颗 0.5 mm 间距 QFN 的 0.30 焊盘封顶就等于把阻抗方案作废,
# 而 DRC / 连通性 / 几何四道判据**一条都不会响**。缩颈交给 `stub_w`(只缩引出脚)。
NO_CAP = ()
# ⛔ **D± 构造**(2026-09-11,刀片 90×68):两对 USB 不交给 A*,按几何直接画成耦合的一对。
# 旧版 A* 分开布:USB_DP 被焊盘封顶撑到 0.531、USB_DM 0.15,60~63 mm / 6~7 孔,
# DPJ/DMJ 长度差 14.8 mm —— 一半是绕 J2(已删),一半是 U7 手性(已对调,见 b3b_usb.py)。
# 走法:金手指 B14/B15 顶层直上 → 并拢成 W 0.28 / S 0.18 → 尾插槽上沿以北 45° 东让 H4 →
# 北上到 DP_TOP → 东去到 U7 正上方 → 南下、在 U7 上排(1/3 脚)北边各一个孔下 B 面;
# U7 下排(6/4 脚)→ J4 的 4/6 脚在 B 面 ~2.5 mm,BM24 0.35 节距 ⇒ 全程 0.18。
# 中心线折线 + 左右各偏 (W+S)/2、斜接(miter)⇒ 45° 拐角处线距不变。
DP_NETS = ('USB_DP', 'USB_DM', 'USB_DPJ', 'USB_DMJ')
DP_LAY = 1 # 金手指 → U7 走顶层
DP_W, DP_S = 0.28, 0.18 # pcb_rules.DIFF(以 L2 整层地为参考)
DP_WJ = 0.18 # U7 → J4(pcb_rules 差分线宽下限相应放到 0.15)
DP_Y_CONV = 9.0 # 两根从各自的指直上到这里开始 45° 并拢
# ⛔ 2026-09-11 晚(方案 §35):主板转 180° 之后 U7 / J4 在左上角、U7 在 J4 正南 ⇒ 走法改成
# 金手指直上 → 并拢 → 45° 斜着往西北 → 直上进 U7 **下排**(不用再绕到 U7 另一面,也不绕 H4)。
DP_DIAG_Y0 = 12.5 # 斜段起点(金手指 launch 行 11.75 以北);2026-09-14 晚主板南移 2.5 后 13.0 → 12.5,否则斜段越过 U7 前的并拢点
DP_VIA_DY = 1.25 # 换层孔在 U7 下排焊盘中心以南多少(孔边离焊盘 0.41)
DP_JK = 0.8 # U7→J4 那段最后竖直进 J4 焊盘的长度(两根中心距 0.35)
def call(js, timeout=300):
r = run(js, timeout=timeout)
if not r.get('success'):
print(u'失败: ' + json.dumps(r, ensure_ascii=False)[:600])
sys.exit(1)
return r['result']
def load_pads():
d = json.load(io.open(PADS, encoding='utf-8'))
for p in d:
if not p[4]:
p[4] = 2.2
if not p[5]:
p[5] = 2.2
return d
# ── 网格 ──────────────────────────────────────────────────────────────
def gx(x):
return int(round(x / G))
def gy(y):
return int(round(y / G))
def wx(i):
return round(i * G, 4)
def wy(j):
return round(j * G, 4)
class Grid(object):
u"""两层占用图。每个格点记**一个字节的归属**:0=空闲,1..250=只被这个网占着,255=被两个以上的网占着。
⛔ **第一版是「blk 位图 + own 集合」,判据写错了**:
`free()` 里「本网占过」优先于「被别人占着」,而**膨胀区也算本网占过** ——
于是一个离本网焊盘 0.93 mm、同时**落在别人焊盘正中**的格点被判成可进,
A* 就在 `U2` 的散热焊盘上打了个过孔。自算净距 −0.300,DRC 报「过孔到焊盘」。
换成「归属字节」之后,只要有第二个网碰过这个格点就永久变 255,谁也进不去。
两套:`blk/own` 按导线半宽膨胀,`blkv/ownv` 按过孔半径膨胀(换层判据)。
⚠ 只有一套的话,A* 会在「线过得去、过孔过不去」的格点上换层,
DRC 报「钻孔到焊盘」,而路径本身看着完全正常。
"""
def __init__(self):
self.own = {l: bytearray(NX * NY) for l in LAY}
self.ownv = {l: bytearray(NX * NY) for l in LAY}
# 「过路费」层:不封死,只是让 A* 别贴着焊盘走(见 build_grid 的 PAD_PEN)
self.pen = {l: bytearray(NX * NY) for l in LAY}
# 协商式拥塞(PathFinder)用:occ = 当前有几个网压在这一格,hist = 历史拥塞代价。
# 两者**跨宽度档共享**(它们只跟格点有关,跟线宽无关),所以是外面挂进来的引用。
self.occ = None
self.hist = None
self._id = {}
def nid(self, net):
u"""网名 -> 1..250 的归属号。超出就并到 250(当成同一个「其它」,偏保守)。"""
if net is None:
net = '#'
v = self._id.get(net)
if v is None:
v = len(self._id) + 1
# ⛔ 饱和是**静默**的:溢出之后不同的网共用一个号,
# 彼此判成「同一个网」,可以随便叠着走,而检查器之外没人会报。
if v > 250:
raise RuntimeError(u'归属号超过 250 个(%d 个不同的网/障碍),'
u'占用图会静默失效' % v)
self._id[net] = v
return v
def idx(self, i, j):
return j * NX + i
def _mark(self, arr, l, x0, y0, x1, y1, k, exact=False):
# ⚠ 边界默认用 floor/ceil,不用 round:round 最坏会**少封半格(0.125 mm)**,
# 那正好是 1.5 mm 宽的两根线互相压 0.02~0.19 mm 的来源。
#
# ⛔ 但 `exact=True`(**只给焊盘用**)必须按「格点中心落在框里才算被占」:
# floor/ceil 最坏多封**一整格 0.25 mm**,而 0.65 mm 间距的 TSSOP
# 邻脚之间本来就只剩 0.34 mm 的缝 —— 多封一格这条缝就没了,
# 于是「空板上出口数 = 0」,报「U4.5 出不来」,看着像板子太挤。
# 焊盘框传进来时**已经膨胀了 halfw(半线宽 + 净距)**,所以
# 「中心在框外」= 线放这儿合法,不需要再留保守量。
# 走线 / 过孔 / 板边仍然保守 —— 那几类是沿途扫描的,少封半格会真的压上。
if exact:
i0 = max(0, int(math.ceil(x0 / G - 1e-9)))
i1 = min(NX - 1, int(math.floor(x1 / G + 1e-9)))
j0 = max(0, int(math.ceil(y0 / G - 1e-9)))
j1 = min(NY - 1, int(math.floor(y1 / G + 1e-9)))
a = arr[l]
for j in range(j0, j1 + 1):
base = j * NX
for i in range(i0, i1 + 1):
o = base + i
v = a[o]
a[o] = k if v == 0 else (v if v == k else 255)
return
i0 = max(0, int(math.floor(x0 / G)))
i1 = min(NX - 1, int(math.ceil(x1 / G)))
j0 = max(0, int(math.floor(y0 / G)))
j1 = min(NY - 1, int(math.ceil(y1 / G)))
a = arr[l]
for j in range(j0, j1 + 1):
base = j * NX
for i in range(i0, i1 + 1):
cur = a[base + i]
if cur == 0:
a[base + i] = k
elif cur != k:
a[base + i] = 255
def block_rect(self, l, x0, y0, x1, y1, net=None, via=False):
self._mark(self.ownv if via else self.own, l, x0, y0, x1, y1, self.nid(net))
def block_both(self, l, x0, y0, x1, y1, extra, net=None, exact=False, exact_v=None):
u"""同一个障碍,分别按导线口径和过孔口径打进两张图。
`exact_v`:过孔图单独用精确模式(格点中心在框内才封)。孔只落在格点上,保守取整(向外多封一格)
对它没有意义,反而让细线旁 0.625 这种合法落孔点也没了。None = 跟 `exact` 一样。"""
k = self.nid(net)
self._mark(self.own, l, x0, y0, x1, y1, k, exact)
self._mark(self.ownv, l, x0 - extra, y0 - extra, x1 + extra, y1 + extra, k,
exact if exact_v is None else exact_v)
def penalize(self, l, x0, y0, x1, y1, val):
u"""给一片格点加「过路费」(不是封死)。"""
i0 = max(0, int(math.floor(x0 / G)))
i1 = min(NX - 1, int(math.ceil(x1 / G)))
j0 = max(0, int(math.floor(y0 / G)))
j1 = min(NY - 1, int(math.ceil(y1 / G)))
a = self.pen[l]
for j in range(j0, j1 + 1):
base = j * NX
for i in range(i0, i1 + 1):
o = base + i
if a[o] < val:
a[o] = val
def reset_pen(self):
for l in LAY:
self.pen[l] = bytearray(NX * NY)
def cost(self, l, i, j):
o = j * NX + i
c = self.pen[l][o]
if self.occ is not None:
c += PF_PRESENT[0] * self.occ[l][o] + PF_HIST_W * self.hist[l][o]
return c
def free(self, l, i, j, net):
if i < 0 or j < 0 or i >= NX or j >= NY:
return False
v = self.own[l][self.idx(i, j)]
return v == 0 or v == self.nid(net)
def free_via(self, i, j, net):
u"""两层都要按过孔口径让得开,才准在这里换层。"""
if i < 0 or j < 0 or i >= NX or j >= NY:
return False
k = self.nid(net)
for l in LAY:
v = self.ownv[l][self.idx(i, j)]
if v != 0 and v != k:
return False
return True
def load_vias():
u"""读回板上已有的过孔(主要是 196 个 GND 缝合过孔)。
⚠ **不读的话它们就不在占用图里**,A* 会直接从过孔身上压过去 ——
DRC 报「导线到过孔」,而路径看上去完全正常。
"""
if OFFLINE:
if os.path.exists(VIAS_SNAP):
return json.load(io.open(VIAS_SNAP, encoding='utf-8'))
print(u' [!] 离线且没有过孔快照 %s,按板上无过孔算' % os.path.basename(VIAS_SNAP))
return []
return call("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2500));"
"return (await eda.pcb_PrimitiveVia.getAll()||[])"
" .map(v=>[v.x/%s, v.y/%s, String(v.net||''), (v.diameter||%s)/%s]);"
% (PCB, MIL, MIL, VIA_D * MIL, MIL), timeout=300)
def build_grid(pads, halfw, vias=(), vkeep=True):
u"""halfw = 本次布线的「线半宽 + 净距」,障碍按它膨胀;
同时按过孔口径(VIA_D/2 + CLR)再膨胀一份,供换层判据用。"""
g = Grid()
extra = max(0.0, VIA_D / 2.0 + CLR - halfw)
# 板边(四条带)+ 金手指区,都归到 '#edge' 这个归属号上
for l in LAY:
m = EDGE_CLR + halfw
for (a, b, c, d) in ((-1, -1, B.BX1 + 1, m),
(-1, B.BH - m, B.BX1 + 1, B.BH + 1),
(-1, -1, B.BX0 + m, B.BH + 1),
(B.BX1 - m, -1, B.BX1 + 1, B.BH + 1),
(-1, -1, B.BX1 + 1, FINGER_KEEP_Y)):
g.block_both(l, a, b, c, d, extra, net='#edge')
# 缺口
nx0, _a, nx1, ny1 = P.notch_cut()
for l in LAY:
g.block_both(l, nx0 - halfw, 0, nx1 + halfw, ny1 + halfw, extra)
# 焊盘
for p in pads:
ref, num, x, y, w, h, lay, net = p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7]
if lay not in LAY:
continue
# ⛔ 只按**网**标归属,别按「网+位号+脚号」——
# 361 个焊盘会把 250 个归属号占满,之后所有网并到同一号上,
# 互相判成「同一个网」,整块板的线就叠着走了(自算净距一次报 319 条)。
g.block_both(lay, x - w / 2 - halfw, y - h / 2 - halfw,
x + w / 2 + halfw, y + h / 2 + halfw, extra, net=net, exact=True)
# ⛔ 2026-09-11 晚(方案 §35):细间距芯片周围一圈**不许换层**(只封过孔图,线照走)。
# U2(WQFN 0.5)从 M6 起欠的永远是那几只:先布的网把换层孔落在脚正前方,一个 Ø0.6 孔
# 挡掉相邻两只脚的出口;服务器第二轮换顺序 = 换一批脚断(p2/p15/p20 ↔ p3/p14/p18)。
# 逼每根线先在器件面扇出 VIA_KEEP_M 再换层。平面孔(VO_P / +12V / GND)那张图不加(vkeep=False)。
if vkeep:
k = g.nid('#vkeep')
for ref in VIA_KEEP_REFS:
ps = [p for p in pads if p[0] == ref]
if not ps:
continue
x0 = min(p[2] - p[4] / 2 for p in ps) - VIA_KEEP_M
x1 = max(p[2] + p[4] / 2 for p in ps) + VIA_KEEP_M
y0 = min(p[3] - p[5] / 2 for p in ps) - VIA_KEEP_M
y1 = max(p[3] + p[5] / 2 for p in ps) + VIA_KEEP_M
for l in LAY:
g._mark(g.ownv, l, x0, y0, x1, y1, k)
# ⛔ 排线穿板槽:铣穿的内槽,两层都不许进(还要给铜到板边的净距)
for (a, b, c2, d2) in B.mill_cuts():
k = EDGE_CLR + halfw
for l in LAY:
g.block_both(l, a - k, b - k, c2 + k, d2 + k, extra, net='#edge')
# ⛔ 主板定位孔(NPTH):两层都不许压。孔壁到铜按 HOLE_D/2 + 净距。
for (hx, hy) in B.phone_holes().values():
r = B.HOLE_D / 2.0 + halfw + 0.15
for l in LAY:
g.block_both(l, hx - r, hy - r, hx + r, hy + r, extra)
# 已有过孔(通孔,两层都占)
for (vx, vy, vnet, vd) in vias:
r = vd / 2.0 + halfw
for l in LAY:
g.block_both(l, vx - r, vy - r, vx + r, vy + r, extra, net=vnet)
# ⛔ **焊盘周围收「过路费」**。这是 28 处接不上的真正解法:
# 把失败那一刻的占用图打出来看,每个「出不来」的脚周围**全是别的网的铜** ——
# 先布的干线贴着焊盘走,顺手把后面那些脚的出口全占了。封死不行(有些线
# 本来就得从焊盘边过),所以改成加钱:贴着焊盘走要多花 PAD_PEN,
# A* 于是优先绕开焊盘阵,把出口留给它们自己的脚。
pen_pads(g, pads, halfw)
return g
def pen_pads(g, pads, halfw):
u"""给这些焊盘周围收「过路费」(先清空再打)。"""
g.reset_pen()
for p in pads:
if p[6] not in LAY:
continue
r = PAD_PEN_R + halfw
g.penalize(p[6], p[2] - p[4] / 2 - r, p[3] - p[5] / 2 - r,
p[2] + p[4] / 2 + r, p[3] + p[5] / 2 + r, PAD_PEN)
VIA_EXACT_M = 0.04 # 过孔图精确模式下线的采样余量:按 G/2 采样成方块,两点之间最多差 ~0.031
# route_net 布线途中给**刚布的线 / 刚打的孔**封过孔图时,用和开轮建图(grid_now / route_groups)同一套口径。
# False = 2026-09-11 以前的旧口径(孔 0.92 保守、线少半宽),只留作对照(route_base --old-viamap)。
VIA_MAP_FIX = True
# ⛔ route_net 途中给**引出脚**占位时,挑形状(直线 / L 形)要用和 route_groups **真正发线**同一张图(gt)。
# 2026-09-11 底板第 27 轮(route_base --dbg-nets + reach_audit --dbg-cmp):原来占位用主图 g(种子那条干脆不传图),
# 真正发出去的用 gt,两边挑出的形状不一样 —— 失败那一刻比最终图多封了 L1 458 格 / L4 276 格,全在各网焊盘附近
# (SDA / VDAC5 / INA_ALERT / SCL …),EFUSE_EN4/5、UP_VBUS_DET 就被这些「影子引出脚」挡死,最终图上重放却布得通。
# False = 旧行为,只留作对照(route_base --old-stub)。
STUB_SAME_GRID = True
# 坐在通孔上的落点(形状 'VIA')孔心两层都算出口,见 route_net._nodes(route_base --via-launch 才开)。
# 2026-09-11 底板第 29 轮:影子孔 59 → 25 颗,但离线断开 29 → 32、EFUSE_EN4/5 仍不通 ⇒ 默认关,代码留着。
VIA_LAUNCH_BOTH = False
def _stub_grid(g, gt):
return (gt if gt is not None else g) if STUB_SAME_GRID else None
def _block_stub(g, l, ax, ay, bx, by, halfw, sw, net):
u"""引出脚占位。新口径的过孔图和干线同一套(孔心离线心 ≥ VIA_D/2 + CLR + 线半宽,精确)。"""
if STUB_SAME_GRID:
block_track(g, l, ax, ay, bx, by, halfw + sw / 2.0, net,
extra=max(0.0, VIA_D / 2.0 + CLR - halfw), exact_v=True)
else:
block_track(g, l, ax, ay, bx, by, halfw + sw / 2.0, net)
def block_track(g, l, x0, y0, x1, y1, halfw, net, extra=0.0, exact_v=None):
u"""把一段已布的线打进占用图(两张都打)。`exact_v=True`:过孔图用精确模式 + VIA_EXACT_M 余量。"""
if exact_v:
extra += VIA_EXACT_M
steps = int(max(abs(x1 - x0), abs(y1 - y0)) / (G / 2)) + 1
for k in range(steps + 1):
t = k / float(steps)
x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
g.block_both(l, x - halfw, y - halfw, x + halfw, y + halfw, extra, net=net, exact_v=exact_v)
_NB8 = [(1, 0, 1.0), (-1, 0, 1.0), (0, 1, 1.0), (0, -1, 1.0),
(1, 1, 1.414), (1, -1, 1.414), (-1, 1, 1.414), (-1, -1, 1.414)]
# 每层的**优选走线方向**:'v' = 纵向(沿 y),'h' = 横向(沿 x)
LAYER_DIR = {2: 'v', 1: 'h'} # 2 = 底层 = 器件面(纵向);1 = 顶层(横向)
CROSS_COST = 3.0 # 逆着优选方向走的倍率(斜线按分量算)
def _along(l, di, dj):
u"""这一步是不是顺着本层的优选方向。斜线两个分量各占一半,算「半顺」。"""
d = LAYER_DIR.get(l)
if d is None:
return True
if di and dj: # 斜线:一半顺、一半逆
return False
return (dj != 0) if d == 'v' else (di != 0)
def astar(g, starts, goals, net, halfw):
u"""8 邻接 A*,两层。starts/goals 是 [(layer, i, j)]。返回 [(layer,i,j)] 路径。"""
goalset = set(goals)
if not goalset:
return None
gj = [(l, i, j) for (l, i, j) in goals]
def h(n):
_l, i, j = n
return min(max(abs(i - a[1]), abs(j - a[2])) +
0.414 * min(abs(i - a[1]), abs(j - a[2])) for a in gj)
open_ = []
gsc = {}
came = {}
for s in starts:
if not g.free(s[0], s[1], s[2], net):
continue
gsc[s] = 0.0
heapq.heappush(open_, (h(s), s))
# ⛔ **两层正交**:器件面(底层 L4,73 件都在这儿)**纵向**、
# 另一面(顶层 L1,几乎空着)**横向**。2026-09-08 用户定的,
# 和 R1「顶层横向、底层纵向」是同一条约定。
# 做法是**加钱不是封死** —— 逆着方向走要贵 `CROSS_COST` 倍,
# 于是拐弯自然变成「换层拐」,两层互补,拥塞大幅下降;
# 但引出脚那种非横即竖走不了的地方仍然出得去。
nb = {l: [(di, dj, c * (1.0 if _along(l, di, dj) else CROSS_COST))
for (di, dj, c) in _NB8] for l in LAY}
seen = set()
while open_:
_f, cur = heapq.heappop(open_)
if cur in seen:
continue
seen.add(cur)
if cur in goalset:
path = [cur]
while path[-1] in came:
path.append(came[path[-1]])
return path[::-1]
l, i, j = cur
base = gsc[cur]
for di, dj, c in nb[l]:
n = (l, i + di, j + dj)
if n in seen or not g.free(l, i + di, j + dj, net):
continue
ng = base + c + g.cost(l, i + di, j + dj)
if ng < gsc.get(n, 1e18):
gsc[n] = ng
came[n] = cur
heapq.heappush(open_, (ng + h(n), n))
other = LAY[1] if l == LAY[0] else LAY[0]
n = (other, i, j)
if n not in seen and g.free(other, i, j, net) and g.free_via(i, j, net):
ng = base + VIA_COST
if ng < gsc.get(n, 1e18):
gsc[n] = ng
came[n] = cur
heapq.heappush(open_, (ng + h(n), n))
return None
def to_segments(path):
u"""路径 -> [(layer, x0,y0, x1,y1)] + 过孔 [(x,y)],合并共线段。"""
segs, vias = [], []
run_start = path[0]
prev = path[0]
for cur in path[1:]:
if cur[0] != prev[0]: # 换层
if run_start != prev:
segs.append((prev[0], wx(run_start[1]), wy(run_start[2]),
wx(prev[1]), wy(prev[2])))
vias.append((wx(prev[1]), wy(prev[2])))
run_start = cur
prev = cur
continue
d1 = (prev[1] - run_start[1], prev[2] - run_start[2])
d2 = (cur[1] - prev[1], cur[2] - prev[2])
if run_start != prev and (d1[0] * d2[1] - d1[1] * d2[0] != 0 or
(d1[0] * d2[0] + d1[1] * d2[1]) <= 0):
segs.append((prev[0], wx(run_start[1]), wy(run_start[2]),
wx(prev[1]), wy(prev[2])))
run_start = prev
prev = cur
if run_start != prev:
segs.append((prev[0], wx(run_start[1]), wy(run_start[2]),
wx(prev[1]), wy(prev[2])))
return segs, vias
# ── 焊盘引出脚 ────────────────────────────────────────────────────────
def pad_rect(p, inf=0.0):
return (p[2] - p[4] / 2 - inf, p[3] - p[5] / 2 - inf,
p[2] + p[4] / 2 + inf, p[3] + p[5] / 2 + inf)
def seg_hits_rect(x0, y0, x1, y1, r):
u"""线段是否与矩形相交(粗判:采样)。"""
n = max(2, int(math.hypot(x1 - x0, y1 - y0) / 0.05))
for k in range(n + 1):
t = k / float(n)
x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
if r[0] <= x <= r[2] and r[1] <= y <= r[3]:
return True
return False
def pad_nodes(g, p, halfw, others, stub_half=None, gt=None):
# ⚠ `others` 必须按 **CLR + 引出脚半宽** 膨胀过再传进来。第一版按 0.05 膨胀,
# 于是引出脚可以贴着隔壁焊盘 0.05 mm 走过去 —— DRC「贴片焊盘到导线」一次报 158 条。
u"""这个焊盘可以起步的格点,按「离盘心近 + 引出脚不蹭别的盘」排序。"""
lay, net = p[6], p[7]
# ⛔ `others` 传进来的是**未膨胀**的焊盘框,在这里按**引出脚**的口径膨胀。
# 用干线口径(1.5 mm 线 → 0.93)膨胀的话,隔壁焊盘的膨胀区会把
# 0.5 mm 间距 QFN 的所有出口堵死 —— 报「出不来」,看着像拥塞,
# 其实是拿干线的宽度去量那一小段缩窄的引出脚。
if stub_half is None:
stub_half = W_DEFAULT / 2.0
# ⚠ 引出脚用**规则净距 0.15**,不用干线那个留了余量的 CLR=0.18。
# 那 0.03 的余量是给 A* 干线的;最后 0.5 mm 缩窄段上再留,
# 0.35 mm 间距的连接器就一个出口都没有了。router_check 判的也是 0.15。
inf = STUB_CLR + stub_half
# ⛔ **自身焊盘按「同一个矩形」排除,不能按「谁包住我的盘心」排除。**
# 膨胀之后,0.35 mm 间距的 `J4` 隔壁脚的框正好把本脚盘心吞进去
# (50.01 那只脚膨胀后是 49.64~50.38,本脚盘心 49.66 在里面),
# 于是**隔壁脚被当成自己跳过**,引出脚就贴着它走了 —— 净距 0.047,
# 而 pad_nodes 全程不报错。这一条 2026-09-08 实测踩到两次(USB_THERM / CC1)。
own = pad_rect(p)
others = [(r[0] - inf, r[1] - inf, r[2] + inf, r[3] + inf) for r in others
if max(abs(r[0] - own[0]), abs(r[1] - own[1]),
abs(r[2] - own[2]), abs(r[3] - own[3])) > 1e-9]
for ring in range(0, 5): # 一圈找不到就往外扩,最多 4 圈(1 mm)
out = _pad_nodes_ring(g, p, halfw + ring * G, others, lay, net, gt, stub_half)
if out:
return out
return []
def _oth_inflated(p, others, stub_half):
u"""把同层焊盘框按**引出脚口径**膨胀,并剔掉本盘自己那一个(按矩形本身,不是按包住盘心)。"""
inf = STUB_CLR + stub_half
own = pad_rect(p)
return [(r[0] - inf, r[1] - inf, r[2] + inf, r[3] + inf) for r in others
if max(abs(r[0] - own[0]), abs(r[1] - own[1]),
abs(r[2] - own[2]), abs(r[3] - own[3])) > 1e-9]
def _axis_exit(p, stub_half):
u"""沿焊盘**长轴**向外的两个出口点(本盘自己的「车道」,两侧没有邻盘)。"""
d = STUB_CLR + stub_half
if p[5] >= p[4]:
r = p[5] / 2.0 + d
return [(p[2], p[3] + r), (p[2], p[3] - r)]
r = p[4] / 2.0 + d
return [(p[2] + r, p[3]), (p[2] - r, p[3])]
def stub_path(p, X, Y, oth, stub_half, g=None, net=None):
u"""盘心 → 落点的引出脚折线;走不通返回 None。
⛔ **先直线,走不通再走 L 形**:`J4`(0.35 mm 间距、盘 0.18 宽)盘心到邻盘边
只有 0.26 mm,任何斜着出去的引出脚都会蹭上邻盘(实测 0.047 mm),
于是「一个出口都找不到」。但**沿着焊盘长轴直着出去**是安全的 ——
那是本盘自己的车道,两侧的邻盘在左右不在前后。出了盘阵再拐就没事了。
`U1`/`U2`/`U7` 那几只内侧脚同理。
⛔ **候选还要过占用图那一关**(`g` / `net`,2026-09-09 M7 补):
这里原来只判「蹭没蹭到别的**焊盘**」,不判过孔和已布的铜 ——
于是 L 形的拐点可以正好贴在别人的过孔边上:实测 `U2.15`($1N147) 的引出脚
离 `EFUSE_EN` 的孔净距 **−0.204**、`U2.2`(EFUSE_PG) 离 `$1N164` 的孔同样,
DRC 报「导线到过孔 0 mil」四条,而 A*、线与线、引出脚自己那三关全过。
"""
def ok(pts, strict):
for a, b in zip(pts, pts[1:]):
if any(seg_hits_rect(a[0], a[1], b[0], b[1], r) for r in oth):
return False
if strict and g is not None and not _stub_clear(
g, p[6], a[0], a[1], b[0], b[1],
net if net is not None else p[7], pad_rect(p)):
return False
return True
cand = [[(p[2], p[3]), (X, Y)]]
for (ex, ey) in _axis_exit(p, stub_half):
cand.append([(p[2], p[3]), (ex, ey), (X, Y)])
# ⛔ 占用图那一关只用来**排序**,不用来否决:拿它当硬条件的话,
# 「落点挑得出来(`pad_nodes` 也用 `gt`)、引出脚却发不出来」,
# 实测多报 3~4 个脚「一根铜都没有」——**丢一只脚比蹭 0.05 mm 严重得多**。
# ⇒ 先要「既不蹭盘也不蹭孔」的;没有的话退回老判据(只不蹭盘)。
for strict in (True, False):
for pts in cand:
if ok(pts, strict):
return pts
return None
def stub_emit(p, X, Y, others, stub_half, w, net, g=None):
u"""发引出脚的线段(1 段或 L 形 2 段)。判据和 `stub_path` 是同一个,不许各写一份。
⚠ 宽度不能低于**规则下限**:引出脚缩窄是为了从细间距里挤出来,但 DRC 不认这个理由
(`SWNODE` 下限 0.3,而 `U1` 的 SW 脚只有 0.279,按脚缩就报「物理错误」)。
比焊盘宽一点点只是稍微出头,间距还够。
⚠ `g` 传进来就顺带过一遍占用图(避过孔 / 已布的铜),不传就退回「只避焊盘」。
**真正落到板上的那一次调用一定要传**,见 `plan_all` 里的注释。
"""
w = max(w, rule_min(net, AUTO_CACHE))
pts = stub_path(p, X, Y, _oth_inflated(p, others, stub_half), stub_half, g, net)
if pts is None:
pts = [(p[2], p[3]), (X, Y)]
out = []
for a, b in zip(pts, pts[1:]):
if math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6:
out.append((p[6], a[0], a[1], b[0], b[1], w, net))
return out
def _stub_clear(g, lay, px, py, qx, qy, net, own=None):
u"""引出脚沿途的格点必须对本网可进。
⛔ `own` = 本焊盘自己的框:**落在自己盘里的采样点要跳过**。
引出脚从盘心出发,而盘心那一格常常被隔壁那根线的避让区盖住
(`U1.13` 旁边 0.5 mm 就是 2 mm 宽的 SW 直连段)—— 于是**第一个采样点**
就判死,报「出不来」,而它左边明明整片是空的。
焊盘里本来就有铜,这一段不该参与净距判断。
"""
n = max(2, int(math.hypot(qx - px, qy - py) / (G / 2.0)))
for k in range(n + 1):
t = k / float(n)
x, y = px + (qx - px) * t, py + (qy - py) * t
if own is not None and own[0] <= x <= own[2] and own[1] <= y <= own[3]:
continue
if not g.free(lay, gx(x), gy(y), net):
return False
return True
def _pad_nodes_ring(g, p, halfw, others, lay, net, gt=None, stub_half=0.1):
x0, y0, x1, y1 = pad_rect(p, halfw)
out = []
for j in range(max(0, gy(y0)), min(NY - 1, gy(y1)) + 1):
for i in range(max(0, gx(x0)), min(NX - 1, gx(x1)) + 1):
if not g.free(lay, i, j, net):
continue
X, Y = wx(i), wy(j)
# ⚠ 自己的焊盘框已经在 pad_nodes 里**按矩形本身**剔掉了。
# 直线走不通还要试 L 形(见 stub_path),否则细间距的脚全报「出不来」。
if stub_path(p, X, Y, others, stub_half) is None:
continue
# ⛔ 引出脚还要躲**已经布好的铜**。只查焊盘的话,引出脚会从先布的
# 导线/过孔身上压过去(自算净距报 `USB_DP` 的指脚 vs `VBUS` −0.097)。
# ⚠ 只拿**已布的铜**那张图查,不能拿主占用图查:
# 细间距器件上相邻焊盘的膨胀区互相重叠,格点会变成
# 255(多网占用),连自己的引出脚都进不去,整网报「出不来」。
# 焊盘那一层已经由上面的 `others` 按引出脚口径管过了。
if gt is not None and not _stub_clear(gt, lay, p[2], p[3], X, Y, net,
pad_rect(p)):
continue
out.append((math.hypot(X - p[2], Y - p[3]), (lay, i, j)))
out.sort()
return [n for _d, n in out]
def stub_w(p, width):
u"""引出脚宽度按焊盘尺寸封顶。
⛔ 不封顶的话,`SWNODE` 2.0 mm 的线要接进 `U1`(0.5 mm 间距 QFN)和 `C5`(0402),
引出脚直接盖住隔壁两个脚 —— 自算净距 −0.73,DRC 报「贴片焊盘到导线」。
干线仍然是全宽(A* 的路径离别人的焊盘有 halfw 的余量),**只有最后接进焊盘
那一小段缩窄**,这也是手工布板的做法。
⚠ 下限 **0.15,不是 0.2**:`J4`(BM24-50DS)下排 0.35 mm 间距、焊盘 0.18 宽,
盘心到隔壁盘边只有 0.26 mm。要 0.15 的净距,引出脚半宽就得 ≤ 0.11 ——
0.2 宽的脚(半宽 0.1 + 规则 0.15 = 0.25)**只剩 0.01 余量**,
grid 一吸附就蹭上(实测 USB_THERM 对 J4.16 只有 0.047)。
0.15 宽 ⇒ 0.15 + 0.075 = 0.225,余 0.035。嘉立创四层线宽下限 0.09,做得出来。
"""
return round(max(0.15, min(width, p[4], p[5])), 3)
# 「已经被铜连在一起」的虚拟落点:位号是这些的,`route_net` 把它们**全部当种子**
# (多源 Prim),彼此之间不布线。目前只有底板的 `+3V3` 槽墙(方案 R2 §30)。
PRE_WIRED = ('WALL',)
def _is_launch(p):
u"""扇出/金手指扇出留下的**虚拟焊盘**(落点)。"""
return str(p[1]).startswith('fan:') or p[1] in ('launch', 'wall')
def route_net(g, net, pads_of_net, halfw, width, others_by_layer, log, gt=None, mutate=True):
u"""对一个网络做 Prim:逐个把还没连上的焊盘接进来。
⚠ **落点(虚拟焊盘)不做「引出脚别蹭邻盘」那道检查**:落点就在焊盘阵的边上,
邻盘膨胀后的框必然把它包住,于是每个候选格点都被判死 —— 报「出不来」,
而它明明已经是扇出阶段验证过的合法落点了。
"""
def _oth(p):
return [] if _is_launch(p) else others_by_layer.get(p[6], [])
def _nodes(p):
u"""出口格点。落点(虚拟焊盘)若就坐在一颗通孔上(形状标 'VIA'),**孔心在另一层也算出口** ——
否则 A* 要换层就在旁边 0.28 mm 再打一颗孔:布线途中一直占着地方(走线图半径 ~0.7),
最后才被 dedupe_vias 合并,最终图上看不见(2026-09-11 底板第 28 轮每轮 ~59 颗,挡死了 U20 西侧几个网)。"""
ns = pad_nodes(g, p, halfw, _oth(p), stub_w(p, width) / 2.0, gt)
if VIA_LAUNCH_BOTH and len(p) > 8 and p[8] == 'VIA':
i, j = gx(p[2]), gy(p[3])
ol = LAY[1] if p[6] == LAY[0] else LAY[0]
if g.free(ol, i, j, net) and (ol, i, j) not in ns:
ns = ns[:1] + [(ol, i, j)] + ns[1:]
return ns
segs, vias, stubs = [], [], []
if len(pads_of_net) < 2:
return segs, vias, stubs
todo = list(pads_of_net)
# 种子挑**出口最多**的那个焊盘(最自由),不是第一个碰到的
cand = []
for k, p in enumerate(todo):
ns = _nodes(p)
if ns:
cand.append((len(ns), k, ns))
if not cand:
log.append(u'%s: 一个焊盘都出不来' % net)
return segs, vias, stubs
# ⛔ 有 WALL 落点时**种子只能是 WALL**(2026-09-11,底板第 19 轮实测):种子若挑了普通焊盘(出口最多的 C9),
# src = 它的出口 ∪ 全部 WALL 格点 —— 两者之间没有铜。各焊盘接到哪边都算「接上了」,
# 结果电源那一簇(L2/C9/C10/R2/风扇上拉)自成一棵树、和槽那边断开,布线器还报布通。
walls = [c for c in cand if todo[c[1]][0] in PRE_WIRED]
if walls:
cand = walls
cand.sort(reverse=True)
_n, seed, starts = cand[0]
seedpad = todo.pop(seed)
# ⛔ **种子的引出脚必须画到 A* 真正起步的那个出口上**,不能画到 `starts[0]`。
# 下面 `src` 放的是种子的**全部**出口(那是对的,只放一个会自锁);
# 可引出脚只画一条 —— A* 从别的出口起步时,铜就和焊盘**断开**了,
# 而布线器只看「路找到没有」,**一个字都不会报**。
# `KPAD_PWR` 就是这么断的:K1 的引出脚停在 (7.75,34.25),
# 干线却从 (8.75,33.25) 起步,中间 1.4 mm 是空的。
# ⇒ 改成**惰性**:谁真被用上了,就给谁补一条引出脚。
# 出口格点 → 它属于哪只焊盘(种子 + 下面摘掉的全部 WALL 落点)。
# ⛔ 2026-09-11 底板第 20 轮:原来只有**种子那一只**会补引出脚;其余 WALL 落点的出口只进了 src,
# A* 从它们出发的线离构造铜差 0.12 mm(出口在落点外 0.625),+3V3 断成 3 块,布线器照报布通。
seed_nodes = {n: seedpad for n in starts}
stubbed = set()
def note_seed(node):
if node not in seed_nodes or node in stubbed:
return
sp = seed_nodes[node]
stubbed.add(node)
stubs.append((sp, node, stub_w(sp, width)))
if mutate:
for (_l, ax, ay, bx, by, sw, _n) in stub_emit(
sp, wx(node[1]), wy(node[2]), _oth(sp),
stub_w(sp, width) / 2.0, stub_w(sp, width), net, _stub_grid(g, gt)):
_block_stub(g, sp[6], ax, ay, bx, by, halfw, sw, net)
# ⛔ 种子集合要放**全部出口**,不能只放第一个。
# 只放一个的话,后面每个焊盘都得 A* 到那一个格点;它一旦被先布的线围住,
# 整个网就全线报「没布通」(`+3V3` 14 个脚里 11 个失败就是这么来的),
# 而看上去像是板子太挤。
src = set(starts)
# ⛔ **多源种子**:`PRE_WIRED` 的落点落在**同一块已经存在的铜**上(3V3 槽墙那种),
# 它们彼此之间不需要一根线。全部一次性进 `src` 并从 `todo` 里摘掉。
# 不这么做的话,Prim 会把「落点」当普通节点,逼 A* 在铜上再布一条线把它们串起来
# —— 实测 `+3V3` 因此从最北那个落点一路蜿蜒到板子最南(100 mm,横穿五条走廊,
# 正好压在 I²C 的出脚道上),而**布线器只报「布通了」**。
# ⇒ 一只脚就近接到墙上,这才是「铜墙」的本意。
for p in list(todo):
if p[0] in PRE_WIRED:
for n in _nodes(p):
src.add(n)
seed_nodes.setdefault(n, p) # 路径真从这个出口出发时,给这只落点补引出脚
todo.remove(p)
todo.sort(key=lambda q: math.hypot(q[2] - seedpad[2], q[3] - seedpad[3]))
done, fail = set(), {}
# 走两遍:第二遍时 src 已经长大,第一遍够不着的往往就通了
for p in todo + [q for q in todo]:
if id(p) in done:
continue
goals = _nodes(p)
if not goals:
fail[id(p)] = u'%s: %s.%s 出不来' % (net, p[0], p[1])
continue
path = astar(g, list(src), goals[:24], net, halfw)
if not path:
fail[id(p)] = u'%s: %s.%s 没布通' % (net, p[0], p[1])
continue
s, v = to_segments(path)
note_seed(path[0]) # 这条路真正用的是哪个种子出口
segs += [(l, a, b, c, d, width, net) for (l, a, b, c, d) in s]
vias += [(x, y, net) for (x, y) in v]
stubs.append((p, path[-1], stub_w(p, width)))
# ⚠ 回填占用图要按 **halfw + 本线半宽**,不能只按 halfw:
# halfw 已经含了「后来那根线的半宽 + 净距」,但**这根线自己也有宽度**。
# 只按 halfw 填的话,同一档宽度里两根 1.5 mm 的线会被排到中心距 1.5,
# 实际净距 0 —— DRC 一口气报出 598 条,全是宽线互相压。
keep = halfw + width / 2.0
done.add(id(p))
for n in path:
src.add(n)
if mutate:
g.block_rect(n[0], wx(n[1]) - keep, wy(n[2]) - keep,
wx(n[1]) + keep, wy(n[2]) + keep, net=net)
for (l, a, b, c, d) in s:
if mutate:
# ⛔ 2026-09-11 底板:过孔图口径要和 grid_now / route_groups 开轮建图**同一套**(孔心离线心 ≥
# VIA_D/2 + CLR + 线半宽,精确模式)。原来 extra 减的是 keep,丢了线自己的半宽。
if VIA_MAP_FIX:
block_track(g, l, a, b, c, d, keep, net,
extra=max(0.0, VIA_D / 2.0 + CLR - halfw), exact_v=True)
else:
block_track(g, l, a, b, c, d, keep, net,
extra=max(0.0, VIA_D / 2.0 + CLR - keep))
if gt is not None:
block_track(gt, l, a, b, c, d, CLR + W_DEFAULT / 2.0 + width / 2.0, net)
for (vx, vy) in v: # 自己刚打的过孔
# ⛔ 净距那一项不能漏:`VIA_D/2 + halfw` 只保证「铜刚好挨上」,
# 后布的线就能贴到过孔边(DRC:导线到过孔 0 mil,实测 12 条)。
r = VIA_D / 2.0 + CLR + halfw
if mutate:
# ⛔ 2026-09-11 底板:过孔图总口径应为 VIA_D + CLR(精确),和开轮建图一致。
# 原来 extra = VIA_D/2 + CLR − halfw ⇒ 总口径 VIA_D + 2·CLR = 0.92 且保守取整 ——
# 同一轮里先打的孔,对后布的网禁区大一圈;第 26 轮 EFUSE_EN4/5、UP_VBUS_DET、PRSNT1#
# 在最终占用图上重放都布得通(reach_audit --replay),布线当时却全报「没布通」。
for l2 in LAY:
if VIA_MAP_FIX:
g.block_both(l2, vx - r, vy - r, vx + r, vy + r,
max(0.0, VIA_D / 2.0 - halfw), net=net, exact_v=True)
else:
g.block_both(l2, vx - r, vy - r, vx + r, vy + r,
max(0.0, VIA_D / 2.0 + CLR - halfw), net=net)
# ⛔ **刚打的过孔也要进 `gt`(引出脚校验图)**。`gt` 是每一轮开头建一次的,
# 只含开轮时已有的过孔 —— 同一轮里先布的网打的孔,对后布的网的引出脚
# 就是隐形的。实测两处:`U2.15`($1N147) 的引出脚贴到 `EFUSE_EN` 的孔上
# (净距 −0.204),`U2.2`(EFUSE_PG) 贴到 `$1N164` 的孔上。
# 线与线、A* 那几关全过,只有 DRC 的「导线到过孔」报得出来。
if gt is not None:
rt = VIA_D / 2.0 + CLR + W_DEFAULT / 2.0
for l2 in LAY:
gt.block_rect(l2, vx - rt, vy - rt, vx + rt, vy + rt, net=net)
# 引出脚(盘心 -> 第一个格点,可能是 L 形)也要占位,
# 否则后面的线会从它身上压过去
if mutate:
for (_l, ax, ay, bx, by, sw, _n) in stub_emit(
p, wx(path[-1][1]), wy(path[-1][2]), _oth(p),
stub_w(p, width) / 2.0, stub_w(p, width), net,
_stub_grid(g, gt) if STUB_SAME_GRID else g):
_block_stub(g, p[6], ax, ay, bx, by, halfw, sw, net)
# ⛔ **失败必须往外报**。这个 `fail` 字典以前收完就扔了 —— 于是
# 「某个脚没布通」全程静默:网上只剩一小段引出脚(`BUCK_PG` 0.02 mm),
# 脚本打印「0 条没搞定」,`getNetLength()` 也非 0(有那一小段),
# 只有 EasyEDA 的连接性 DRC 会报,而那一支平时被当成「还没布线」忽略掉。
# 2026-09-08 就是这么漏掉 20 多个连接的。
for p in todo:
if id(p) in done:
continue
log.append(fail.get(id(p), u'%s: %s.%s 没接上' % (net, p[0], p[1])))
return segs, vias, stubs
# ── J1 金手指扇出(不走 A*,几何是确定的)───────────────────────────────
BUS_Y = {1: 7.0, 2: 8.2} # 顶层地母线 / 底层地母线(错开,过孔互不挡)
LAUNCH_Y = 11.75 # 信号指爬到这一行,交给 A*(要落在 0.25 网格上)
STUB_Y0 = 4.0 # 引出脚从指的中段起(压在焊盘上)
BUS_W, STUB_W = 0.4, 0.2
FINGER_W = {'+12V': 0.35, '+5V': 0.3}
def finger_geom():
u"""返回 (pin -> (x, layer, net))。x 直接用封装的事实来源。"""
out = {}
# ⚠ 针位表在 `b4_edge.PIN_NET`(原理图那一份),几何在 `fp_edge`(封装那一份)。
# 两份各是各的事实来源,**不许在这里抄第三份**。
for pin, net in b4_edge.PIN_NET.items():
row = pin[0]
out[pin] = (round(B.J1_X + fp_edge.PIN_X[pin], 4), fp_edge.LAYER[row], net)
return out
def gnd_runs(fg):
u"""每一排里连续的地指分段。"""
runs = {}
for row, lay in (('A', 2), ('B', 1)):
cur = []
for n in range(1, 33):
pin = '%s%d' % (row, n)
if fg[pin][2] == 'GND':
cur.append(pin)
else:
if cur:
runs.setdefault(lay, []).append(cur)
cur = []
if cur:
runs.setdefault(lay, []).append(cur)
return runs
def finger_escape():
u"""地指走母线 + 过孔;信号指直上到 LAUNCH_Y。
⚠ **过孔只能打在「另一排同 x 是地或悬空」的位置**:过孔是通孔,
打在信号指正上方会把另一层那根信号的引出脚堵死。
这一条不写进代码的话,`B14/B15`(USB D±)和 `A16`(VDAC)会被地过孔挡住,
而 DRC 只会报「这根线没布通」,指不到根因。
"""
fg = finger_geom()
other = {}
for n in range(1, 33):
other['A%d' % n] = fg['B%d' % n][2]
other['B%d' % n] = fg['A%d' % n][2]
segs, vias, launch = [], [], {}
# 地指
for lay, runs in sorted(gnd_runs(fg).items()):
y = BUS_Y[lay]
for run in runs:
xs = [fg[p][0] for p in run]
x0, x1 = min(xs), max(xs)
ok = [fg[p][0] for p in run if other[p] in ('GND', 'NC')]
if not ok: # 整段都被对排的信号占着 -> 母线往外伸 1 mm
x1 = x1 + 1.0
ok = [x1]
segs.append((lay, x0, y, x1, y, BUS_W, 'GND'))
for p in run:
segs.append((lay, fg[p][0], STUB_Y0, fg[p][0], y, STUB_W, 'GND'))
for k, vx in enumerate(ok):
if k % 2 == 0 or len(ok) <= 2: # 每隔一根打一个,够用
vias.append((vx, y, 'GND'))
# 信号指:爬到 LAUNCH_Y 之后**立刻换到空的那一面**。
# ⛔ M5 时代这句是「换到**底层**」,理由是「83 件全在 A 面,底层是空的」——
# **M6 把 83 件全翻到了 B 面(底层)之后这句就反了**,而注释没跟着改:
# 于是 19 根金手指信号全被灌进器件面,跟 78 个件的焊盘抢那几条通道,
# A* 布不出来就退化成「直连段」——直接一条铜横穿金手指区,
# 压在 16 根地指的引出脚上(DRC:`PRSNT#` 到 `GND` 净距 0,16 条)。
# ⚠ **试过反过来(换到顶层):更差**,20 条没搞定(原来 6 条)——
# 78 个件的焊盘全在底层,信号换到顶层之后每根线都要再换回来,
# 每根多两个过孔,扇出区先堵死。⇒ 仍然换到底层,
# `PRSNT#` 那条横穿金手指区的铜另找原因(它是 A* 失败后的退化直连)。
# ⚠ 例外:同一个 x 上 A/B 两排都是**不同网的信号**时不能换 ——
# 通孔会把另一排那根线在底层的去路堵死。这样的位置只有两个
# (1 号针 A1=PRSNT# / B1=+12V,11 号针 A11=EFUSE_EN / B11=INT#),
# 让 B 排那根留在顶层,交给 A* 自己想办法。
sig = lambda n: n not in ('GND', 'NC')
for pin, (x, lay, net) in sorted(fg.items()):
if not sig(net) or net in DP_NETS: # D± 由 dp_blade() 从指上直接画
continue
w = FINGER_W.get(net, STUB_W)
segs.append((lay, x, STUB_Y0, x, LAUNCH_Y, w, net))
onet = other[pin]
clash = sig(onet) and onet != net
if lay == 1 and not clash:
vias.append((x, LAUNCH_Y, net))
launch.setdefault(net, []).append((2, x, LAUNCH_Y))
else:
launch.setdefault(net, []).append((lay, x, LAUNCH_Y))
# ⛔ 2026-09-11 晚(方案 §35):+12V 改走 L3 岛。B1(对排 A1 是 PRSNT#,不能打孔)在 L1 上横接到
# B2 的 launch 孔;对排也是 +12V 的 B2 / B3 在 launch 孔下面再各补一个孔(1.45 A 不压在两个 Ø0.3 孔上)。
v12 = [(pin, x) for pin, (x, lay, net) in fg.items() if net == '+12V' and lay == 1]
if v12:
xs = sorted(x for _p, x in v12)
segs.append((1, xs[0], LAUNCH_Y, xs[-1], LAUNCH_Y, FINGER_W['+12V'], '+12V'))
for pin, x in v12:
if other[pin] == '+12V':
vias.append((x, LAUNCH_Y - 1.25, '+12V'))
return segs, vias, launch
def _offset(pts, d):
u"""折线向**左**(前进方向的左手边)偏 d,拐点用斜接 —— 偏出来的两根在 45° 拐角处线距不变。"""
def nrm(a, b):
dx, dy = b[0] - a[0], b[1] - a[1]
L = math.hypot(dx, dy)
return (-dy / L, dx / L)
out = []
n = len(pts)
for i in range(n):
if i == 0:
nx, ny = nrm(pts[0], pts[1])
elif i == n - 1:
nx, ny = nrm(pts[-2], pts[-1])
else:
a, b = nrm(pts[i - 1], pts[i]), nrm(pts[i], pts[i + 1])
s = 1.0 + a[0] * b[0] + a[1] * b[1]
nx, ny = (a[0] + b[0]) / s, (a[1] + b[1]) / s
out.append((round(pts[i][0] + d * nx, 4), round(pts[i][1] + d * ny, 4)))
return out
def dp_blade(pads):
u"""两对 D± 的确定几何 → (segs, vias)。走法与各常量的由来见 `DP_NETS` 上面的注释。
手性(为什么不交叉):金手指 DP 在西、向北出 ⇒ DP 在前进方向左手边;右转两次(东、南)
仍在左手边 ⇒ 向南进 U7 上排时 DP 在东 = 3 脚(USB_DP)✓;U7 直通到下排 4 脚(DPJ,东)
→ J4 的 6 脚(DPJ,51.58 在东)✓。任何一个焊盘网名对不上就直接断言失败,不画。
"""
pm = {(q[0], q[1]): q for q in pads}
fdp, fdm = pm[('J1', 'B14')], pm[('J1', 'B15')]
u1, u3, u4, u6 = pm[('U7', '1')], pm[('U7', '3')], pm[('U7', '4')], pm[('U7', '6')]
jm, jp = pm[('J4', '4')], pm[('J4', '6')]
want = [(fdp, 'USB_DP'), (fdm, 'USB_DM'), (u1, 'USB_DM'), (u3, 'USB_DP'),
(u4, 'USB_DPJ'), (u6, 'USB_DMJ'), (jm, 'USB_DMJ'), (jp, 'USB_DPJ')]
for q, n in want:
assert q[7] == n, u'D± 构造:%s.%s 是 %s,应为 %s(原理图 / 摆位改过?)' % (q[0], q[1], q[7], n)
# ⛔ 2026-09-11 晚(方案 §35):U7 在 J4 **正南**、rot 180 ⇒ 下排 3(DP 西) / 1(DM 东) 朝南对着金手指,
# 上排 4(DPJ 西) / 6(DMJ 东) 朝北对着 J4 的 6(DPJ 西) / 4(DMJ 东)。
# 手性:金手指 DP 在西、向北出 ⇒ DP 在左手边;先左转(西北)再右转(北)仍在左手边 = 西 = 3 脚 ✓,
# 两个转角一左一右,内外侧长度互相抵掉。
assert fdp[2] < fdm[2] and u3[2] < u1[2] and u4[2] < u6[2] and jp[2] < jm[2], u'D± 手性对不上'
assert abs(u1[3] - u3[3]) < 1e-3 and u1[3] < u6[3] < jm[3], u'U7 要在 J4 正南、下排在南'
h = (DP_W + DP_S) / 2.0
cx0 = (fdp[2] + fdm[2]) / 2.0
y1 = DP_Y_CONV + (fdm[2] - fdp[2]) / 2.0 - h
ux = (u1[2] + u3[2]) / 2.0
yv = u1[3] - DP_VIA_DY
fan = (u1[2] - u3[2]) / 2.0 - h
ys = yv - fan - 0.25
yd1 = DP_DIAG_Y0 + (cx0 - ux) # 45° 斜段走完时的 y
assert yd1 < ys - 0.5, u'D± 斜段走完 (%.2f) 越过了 U7 前的并拢点 (%.2f):DP_DIAG_Y0 往下调' % (yd1, ys)
c = [(cx0, y1), (cx0, DP_DIAG_Y0), (ux, yd1), (ux, ys)]
paths = {
'USB_DP': [(fdp[2], STUB_Y0), (fdp[2], DP_Y_CONV)] + _offset(c, +h)
+ [(u3[2], ys + fan), (u3[2], yv)],
'USB_DM': [(fdm[2], STUB_Y0), (fdm[2], DP_Y_CONV)] + _offset(c, -h)
+ [(u1[2], ys + fan), (u1[2], yv)],
}
# 主板定位孔(NPTH)离中心线要够远:孔半径 + 半对距 + 半线宽 + 净距
for hn, (hx, hy) in B.phone_holes().items():
dmin = min(_pt_seg(hx, hy, a[0], a[1], b[0], b[1]) for a, b in zip(c, c[1:]))
assert dmin > B.HOLE_D / 2.0 + h + DP_W / 2.0 + 0.3, u'D± 中心线离孔 %s 只有 %.2f' % (hn, dmin)
segs, vias = [], []
for net, pts in paths.items():
for a, b in zip(pts, pts[1:]):
if math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6:
segs.append((DP_LAY, a[0], a[1], b[0], b[1], DP_W, net))
for net, q in (('USB_DP', u3), ('USB_DM', u1)):
vias.append((q[2], yv, net))
segs.append((2, q[2], yv, q[2], q[3], DP_W, net))
yk = jm[3] - DP_JK # 2026-09-11 晚:J4 在 U7 北边
u5 = pm[('U7', '5')]
if u6[2] < jp[2]:
# ⛔ 2026-09-14 BLADE-PCB_1:J4 的 6/4 脚整个在 U7 上排的**东北**(R 版是正北)。
# 老走法「竖直 → 45° → 竖直 DP_JK」让 DPJ 从 U7.5(VBUS) 焊盘顶上擦过(净距 −0.09)、
# 两根斜段起点不齐(线距 0.067)。改成两根**平行 45° 斜线**直接扎进 J4 焊盘:
# DPJ 的截距 c = y − x 由 VBUS 焊盘左上角 + 净距定;DMJ 在它东南方平移一个节距(c 差 0.35·√2),
# DMJ 先从 U7.6 竖直爬到自己那条斜线上。
# ⚠ 一根斜线不够:躲 VBUS 焊盘左上角要 c ≥ c1,躲 J4 上 DPJ 西邻的 GND 焊盘(J4.8)右下角要 c ≤ c2,
# BLADE-PCB_1 上 c1 > c2 ⇒ DPJ 在过了 U7.6 之后插一段 (c1−c2) 长的**水平错位**;DMJ 一根斜线走 c2 那条的平行线。
r2 = math.sqrt(2.0)
keep = (DP_WJ / 2.0 + 0.15 + 0.02) * r2
g8 = [q for q in pads if q[0] == 'J4' and q[6] == jp[6] and abs(q[3] - jp[3]) < 1e-3
and jp[2] - 0.5 < q[2] < jp[2] - 1e-3]
c1 = (u5[3] + u5[5] / 2.0) - (u5[2] - u5[4] / 2.0) + keep
c2 = min([(q[3] - q[5] / 2.0) - (q[2] + q[4] / 2.0) - keep for q in g8] + [c1])
cm = c2 - (jm[2] - jp[2]) * r2
for net, u, j, c0 in (('USB_DPJ', u4, jp, c1), ('USB_DMJ', u6, jm, cm)):
ys = u[2] + c0
ye = j[2] + (c2 if net == 'USB_DPJ' else c0)
assert u[3] - u[5] / 2.0 < ys and ye < j[3] + j[5] / 2.0, \
u'D±(J4 段)%s 斜线起止 %.2f / %.2f 落不进焊盘' % (net, ys, ye)
if net == 'USB_DPJ' and c1 - c2 > 1e-6:
xa = u6[2] + 0.3
pts = [(u[2], u[3]), (u[2], ys), (xa, xa + c1), (xa + (c1 - c2), xa + c1), (j[2], ye), (j[2], j[3])]
else:
pts = [(u[2], u[3]), (u[2], ys), (j[2], ye), (j[2], j[3])]
for a, b in zip(pts, pts[1:]):
if math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6:
segs.append((2, round(a[0], 4), round(a[1], 4), round(b[0], 4), round(b[1], 4),
DP_WJ, net))
return segs, vias
for net, u, j in (('USB_DMJ', u6, jm), ('USB_DPJ', u4, jp)):
pts = [(u[2], u[3]), (u[2], yk - abs(u[2] - j[2])), (j[2], yk), (j[2], j[3])]
for a, b in zip(pts, pts[1:]):
segs.append((2, round(a[0], 4), round(a[1], 4), round(b[0], 4), round(b[1], 4),
DP_WJ, net))
return segs, vias
# ── 主流程 ────────────────────────────────────────────────────────────
def rule_width(net, auto):
if net in WIDTH:
return WIDTH[net]
for k, n in auto.items():
if n == net:
return WIDTH_AUTO.get(k, W_DEFAULT)
return W_DEFAULT
# 各线宽规则的**下限**(和 pcb_rules.TRACK_RULES 的 strokeWidthMin 对齐)。
# ⛔ 低于下限 DRC 报「物理错误:线宽 x mil,应该在 y ~ z」—— 直连段按焊盘取宽
# (`U1` 的 SW 脚只有 0.279)和过孔合并的连接段(固定 0.2)都会踩到。
RULE_MIN = {'PWR12V': 0.25, 'SWNODE': 0.30, 'PWR4A': 0.30,
'PWR5V': 0.20, 'PWR1A': 0.15}
def rule_min(net, auto):
u"""这个网的线宽下限。查不到就按默认 0.15。"""
import pcb_rules as R
cls = R.NET_TRACK.get(net)
if cls is None:
for name, (c, probe, chk) in R.AUTO_TRACK.items():
if auto.get(name) == net:
cls = c
break
return RULE_MIN.get(cls, 0.15)
def width_of(net, auto, pads_of_net=()):
u"""线宽 = 规则宽度,但**被这个网最小的焊盘封顶**。
⛔ 不封顶的后果不是「线太粗」,是**根本出不来**:找出口那张占用图是按干线宽度
建的,1.5 mm 的网去接 `U2`(0.5 mm 间距 WQFN,焊盘 0.3×0.9)时,
隔壁焊盘按 0.93 mm 膨胀,把所有出口全堵死 —— 报的是「出不来」不是「没布通」,
一眼看上去像拥塞,其实是自己把自己关在焊盘里了。
这块板上这么封顶不损失载流:几个宽网要么本身就是**多脚并联的短连接**
(`$1N165` 是 eFuse 五个 OUT 脚 → `Rs1`,每脚 0.8 A,跨度 4 mm),
要么走的是 L3 的铜面(`VO_P` / `+12V`)。
"""
# ⚠ 2026-09-08:**撤掉了按焊盘封顶**。那是绕开「出不来」的权宜之计,
# 代价是把 4 A 的 `SWNODE`/`PWR4A` 干路压到 0.48 mm(外层 1 oz 只带 ~2.2 A),
# 拿载流换布通率,不能这么换。真正的根因在 `pad_nodes` 的 `others` 膨胀口径,
# 已经修好;干线保持规则宽度,只有**引出脚**按焊盘缩窄(`stub_w`)。
return rule_width(net, auto)
def route_groups(bypad, pads, segs, vias, board_vias, auto, priority=(), only=None):
u"""按线宽分档把 `bypad` 里的网布完;`only` 给拆线重布用(只重布这几个网)。
直接**原地**往 `segs` / `vias` 里追加,返回没搞定的日志。
"""
logs = []
# 其余按线宽分档,宽的先布
groups = {}
capped = []
for net, ps in bypad.items():
if only is not None and net not in only:
continue
w = width_of(net, auto, ps)
# ⚠ 直连之后**才**按焊盘封顶:4 A 的干线已经由直连段承担(宽度=焊盘本身,
# IC 的脚有多宽就有多宽,再宽也没意义),剩下要 A* 的都是小脚上的支路
# (BOOT 电容、FB 抽头)。不封顶的话,2 mm 的口径在 0.5 mm 间距的
# QFN 上一个出口都找不到 —— 报「出不来」,看着像拥塞。
small = w if net in NO_CAP else min([min(q[4], q[5]) for q in ps if q[0] != 'J1'] or [w])
# ⚠ 封顶不能低于**规则下限**,否则 DRC 报「物理错误:线宽不足」。
# `SWNODE` 下限 0.3,而 `U1` 的 SW 脚只有 0.279 —— 按脚封顶就踩线了。
w = max(W_DEFAULT, rule_min(net, auto), min(w, small))
if w < rule_width(net, auto) - 1e-9:
capped.append(u'%s %.2f→%.2f' % (net, rule_width(net, auto), w))
groups.setdefault(w, []).append(net)
if capped:
print(u'线宽被焊盘封顶: ' + ' '.join(capped))
for w in sorted(groups, reverse=True):
halfw = w / 2.0 + CLR
g = build_grid(pads, halfw, board_vias)
# ⚠ 已布的铜要**按它自己的网名**打进占用图,不能统一记成 '#done':
# 记成 '#done' 的话,一个网自己的金手指引出脚会把自己的落脚点堵死,
# 现象是「J1.launch 出不来」——,而且看上去像是拥塞,其实是自锁。
for (l, a, b, c, d, ww, nn) in segs: # 已布的铜
# ⛔ `extra` 不能用默认的 0:`block_both` 的第二张图(`ownv`)是**换层判据**,
# 不给额外膨胀的话,别人的过孔可以直接落在这根线上 ——
# DRC 报「导线到过孔 0 mil」,实测两处(`EFUSE_EN` 的孔压 `$1N147`、
# `$1N164` 的孔压 `EFUSE_PG`),而线与线那一支干干净净,很难看出来是谁的错。
# ⛔ 2026-09-11 再修:过孔图要让新孔中心离线心 ≥ VIA_D/2 + CLR + ww/2 ⇒ extra = VIA_D/2 + CLR − halfw。
# 原来减 (halfw + ww/2) 丢了这条线自己的半宽 —— 底板 0.4 的 +3V3 / +5V 线旁 0.625 处照样落孔(DRC 15 条 4.9 mil)。
block_track(g, l, a, b, c, d, halfw + ww / 2.0, nn,
extra=max(0.0, VIA_D / 2.0 + CLR - halfw), exact_v=True)
for (vx, vy, nn) in vias:
# ⛔ 同上:少了 `CLR` 的话,后布的线会贴着别人的过孔走。
# 过孔图也要打(原来只进走线图,换层判据看不见已放的孔):总口径 max(rv, VIA_D + CLR)。
rv = VIA_D / 2.0 + CLR + halfw
ev = max(0.0, VIA_D / 2.0 - halfw)
for l in LAY:
g.block_both(l, vx - rv, vy - rv, vx + rv, vy + rv, ev, net=nn, exact_v=True)
# 只装已布铜的占用图,供引出脚校验用(按引出脚口径 + 那根线自己的半宽膨胀)
gt = Grid()
stub_half = W_DEFAULT / 2.0
for (l, a, b, c, d, ww, nn) in segs:
block_track(gt, l, a, b, c, d, CLR + stub_half + ww / 2.0, nn)
for (vx, vy, nn) in vias:
r = VIA_D / 2.0 + CLR + stub_half
for l in LAY:
gt.block_rect(l, vx - r, vy - r, vx + r, vy + r, net=nn)
others = {1: [], 2: []}
for p in pads:
if p[6] in others:
others[p[6]].append(pad_rect(p))
# 跨度大的先布:短线随便绕,长线一旦被占住走廊就真的没路了
def span(n):
ps = bypad[n]
return -(max(q[2] for q in ps) - min(q[2] for q in ps)
+ max(q[3] for q in ps) - min(q[3] for q in ps))
# ⛔ 但**排在跨度前面的是「引出脚有多少条路」**。
# 只按跨度排,细间距 IC 的**内侧引脚**必然吃亏:邻脚是长线、先布,
# 两边一占,内侧那只脚就再也出不来了。实测现象是
# 「INA_ALERT: 一个焊盘都出不来」(U4 TSSOP-24 底排第 15 脚 + U3 MSOP-10 第 3 脚),
# 看着像拥塞,其实是**顺序**问题 —— 同样的摆位,先布它就通。
# 判据用「在这一档刚建好的占用图上,这个网最紧的那只脚有几个可起步格点」,
# 少的先布。空 = 0,直接排最前面。
def escapes(n):
m = 99
for q in bypad[n]:
if q[0] == 'J1':
continue
m = min(m, len(pad_nodes(g, q, halfw, others.get(q[6], []),
w / 2.0, gt)))
return m
seq = sorted(groups[w],
key=lambda n: (0 if n in priority else 1,
escapes(n), span(n), n))
# ⛔ **过路费只对「还没布的脚」收**。静态收费(所有焊盘一视同仁)已经把
# 28 条降到 16 条;但布完的网还继续收费,等于白白把已经用不着的出口
# 也围起来,后面的线被迫绕远、反而去挤别人的出口。
# 改成每布一个网之前重算一次:**只围还没轮到的那些脚**。
todo_nets = list(seq) + [n for ww2 in groups if ww2 != w for n in groups[ww2]]
for k, net in enumerate(seq):
left = set(todo_nets[k:])
pen_pads(g, [q for nn in left for q in bypad.get(nn, [])
if q[0] != 'J1'], halfw)
s, v, st = route_net(g, net, bypad[net], halfw, w, others, logs, gt)
segs += s
vias += v
for (q, node, ww) in st:
# ⚠ 这里发的才是**真正落到板上的**引出脚(上面那两处只是往占用图里占位)。
# `g` 一定要传:不传就退回「只避焊盘」的老判据,
# L 形拐点会贴到别人的过孔上(见 stub_path 的说明)。
# ⚠ 传的是 `gt`(引出脚口径的占用图)不是 `g`(干线口径):
# `pad_nodes` 挑落点时用的就是 `gt`,两边判据必须**同一张图** ——
# 用 `g` 更严,会出现「落点挑得出来、引出脚发不出来」,
# 实测多报 4 个脚「一根铜都没有」(U2.2 / U2.15 / U2.20 / C9.1)。
segs += stub_emit(q, wx(node[1]), wy(node[2]),
[] if _is_launch(q) else others.get(q[6], []),
ww / 2.0, ww, net, gt)
return logs
# 拆线半径(mm)。⚠ 2.5 太小:实测挡住失败焊盘的网**不都在它旁边** ——
# 空占用图上这些脚全都出得来,一旦别的网从三五毫米外压过来就出不来了。
RIP_R = 6.0
RIP_MAX = 15 # 一次最多拆几个网
RIP_ROUNDS = 4
def _seg_pt_dist(sg, x, y):
return _pt_seg(x, y, sg[1], sg[2], sg[3], sg[4])
def _pt_seg(px, py, ax, ay, bx, by):
dx, dy = bx - ax, by - ay
L = dx * dx + dy * dy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
def _msg_net(m):
u"""从日志里取网名。⚠ 连通性那几条带 `[连通性] ` 前缀,不剥掉的话
优先队列里塞的是「[连通性] +12V_F」这种鬼名字,等于**这些网从来没被重试过**。"""
if m.startswith(u'[连通性] '):
m = m[len(u'[连通性] '):]
return m.split(':', 1)[0].strip()
def repair(bypad, pads, segs, vias, logs, board_vias, auto, fixed_s=0, fixed_v=0):
u"""**拆线重布**:把挡在失败焊盘出口附近的几个网拆掉,先布失败的那个,再把拆掉的补回去。
⛔ 到这一步为止试过的所有招(顺序、扇出、过路费、L 形引出脚、正交方向)都
只能把失败数从 42 压到 13 —— 因为**剩下的全是「轮到它时出口已经被占了」**,
而这个布线器没有回退。把占位的那几根拆掉重来,是唯一还剩的办法。
判据是**总数变少才接受**:重布之后如果失败反而更多,整批回滚。
"""
padmap = {}
for q in pads:
padmap[(q[0], str(q[1]))] = q
for rnd in range(RIP_ROUNDS):
if not logs:
break
before = len(logs)
for msg in list(logs):
if msg not in logs:
continue
net = _msg_net(msg)
if net not in bypad:
continue
q = None
try:
ref_pin = msg.split(':', 1)[1].strip().split()[0]
q = padmap.get(tuple(ref_pin.split('.')))
except Exception:
q = None
if q is None:
# 「铜分成 N 块」这种没指名焊盘:拿这个网自己的焊盘当中心
cand2 = [x for x in bypad[net] if x[0] != 'J1']
if not cand2:
continue
q = cand2[0]
near = {}
for sg in segs:
if sg[6] == net or sg[6] in SKIP or sg[6] not in bypad:
continue
d = _seg_pt_dist(sg, q[2], q[3])
if d < RIP_R:
near[sg[6]] = min(near.get(sg[6], 99), d)
victims = [n for _d, n in sorted((d, n) for n, d in near.items())][:RIP_MAX]
if not victims:
continue
redo = set(victims) | {net}
# ⛔ **前 fixed_s / fixed_v 条是前置阶段(Kelvin / VO_P 过孔 / 直连)的铜,
# 一根都不许拆** —— `route_groups` 只跑 A*,拆了它**重建不回来**,
# 而且拆掉的那些焊盘早已从 `bypad` 里摘掉了,于是**失败计数看不出来**:
# 板子断了,脚本还报「好了」。`$1N138` 只剩一段 0.06 mm 的残桩就是这么来的。
keep_s = segs[:fixed_s] + [sg for sg in segs[fixed_s:] if sg[6] not in redo]
keep_v = vias[:fixed_v] + [v for v in vias[fixed_v:] if v[2] not in redo]
keep_logs = [m for m in logs if _msg_net(m) not in redo]
ns, nv = list(keep_s), list(keep_v)
new_logs = route_groups(bypad, pads, ns, nv, board_vias, auto,
priority=(net,), only=redo)
# ⚠ 重布完要**按几何**再查一遍:A* 说「布通了」不等于铜真的连上
# (见 route_net 里种子引出脚那条注释)。
for m2 in check_connected(pads, ns, nv):
if _msg_net(m2) in redo:
new_logs.append(u'[连通性] ' + m2)
# ⚠ **打平也接受**:不接受打平就会一直卡在同一个平台上
# (12~16 来回跳)。换一组同样多的失败出来,下一轮就可能解开。
if len(keep_logs) + len(new_logs) <= len(logs):
segs[:] = ns
vias[:] = nv
logs[:] = keep_logs + new_logs
print(u' 拆线重布第 %d 轮:%d -> %d' % (rnd + 1, before, len(logs)))
if len(logs) >= before:
break
return segs, vias, logs
def check_connected(pads, segs, vias):
u"""**真正的连通性判据**:按几何做并查集,看每个网的焊盘是不是真的连成一片。
⛔ 为什么非要有:布线器报「0 条没搞定」只说明**它自己**觉得布完了。
拆线重布一旦把前置阶段的铜(直连 / Kelvin / VO_P 过孔)删掉,
那些焊盘早就不在 `bypad` 里,于是**没人再检查它们** —— 板子断了、脚本报好。
这里不看布线器的账本,只看铜。
"""
par = {}
def find(a):
while par.get(a, a) != a:
par[a] = par.get(par[a], par[a])
a = par[a]
return a
def uni(a, b):
ra, rb = find(a), find(b)
if ra != rb:
par[ra] = rb
def key(l, x, y):
return (l, round(x, 2), round(y, 2))
for sg in segs:
l, x0, y0, x1, y1 = sg[0], sg[1], sg[2], sg[3], sg[4]
par.setdefault(key(l, x0, y0), key(l, x0, y0))
par.setdefault(key(l, x1, y1), key(l, x1, y1))
uni(key(l, x0, y0), key(l, x1, y1))
for (vx, vy, _n) in vias:
a1, b1 = key(LAY[0], vx, vy), key(LAY[1], vx, vy)
par.setdefault(a1, a1)
par.setdefault(b1, b1)
uni(a1, b1)
# 焊盘:盘框内的所有端点并到一起
bad = []
bynet = {}
for q in pads:
bynet.setdefault(q[7], []).append(q)
ends = {}
for k in list(par):
ends.setdefault(k[0], []).append(k)
for net, ps in bynet.items():
if not net or net in SKIP:
continue
roots, naked = [], []
for q in ps:
if q[0] == 'J1': # 金手指自己有扇出,另算
continue
r = pad_rect(q, 0.05)
hit = [k for k in ends.get(q[6], [])
if r[0] <= k[1] <= r[2] and r[1] <= k[2] <= r[3]]
if not hit:
naked.append('%s.%s' % (q[0], q[1]))
continue
for h2 in hit[1:]:
uni(hit[0], h2)
roots.append(find(hit[0]))
if naked:
bad.append(u'%s: %s 一根铜都没有' % (net, ' '.join(naked[:4])))
elif len(set(roots)) > 1 and net not in ISLAND_NETS:
# ⚠ `VO_P` / `+12V` 靠 L3 的整块铜岛成网(每个焊盘扎一个过孔进去),
# 铜在几何上**本来就是分开的几块**,不是断线。
bad.append(u'%s: 铜分成 %d 块' % (net, len(set(roots))))
return bad
# ⛔ 2026-09-11 晚(方案 §35):U2 角上四只信号脚**预先画好扇出**,A* 从扇出端点起步。
# p2/p3(北排西半)、p15/p14(南排西半)两两都往西北 / 西南去,0.5 节距的两只脚同时出去**是走得通的**
# —— 西边那只先拐、东边那只多直走半格再拐,两条 45° 平行线相距 0.707 —— 但 A* 一次只看一根:
# 服务器五轮里要么 $1N164 出脚后斜到 x 79.0 往北压住 U2.2 那一列,要么 EFUSE_PG 在 U2.2 正北落孔挡住 p3。
# {位号: {脚: (直走 d1, 斜走 d2)}},方向按脚在芯片的哪半边自动取(北排往北 / 南排往南,西半往西)。
# 端点全在 0.25 格点上、落在禁换层圈(VIA_KEEP_M)外面。
# 西列 p17~p20(横放的脚)同理(服务器第六轮:p18 出脚往下斜、在 p17 正西落孔,U2.17 出不来):
# p20 / p19 往西再斜西北、p18 直走、p17 往西再斜西南;平行段相距 0.5 / 0.707。
# 第 3 个字段 True = 端点**就地落孔换到另一层**:U4 北排 p7/p8(EDL_EN / VBUS_EN)要往西去左上角,
# 而它们西边的 p4~p6(KEY_*)要往东北去侧键 —— 拓扑上必然交叉,必须有一边换层。服务器第七轮 p8 的孔
# 落在 p7 正北、U4.7 出不来 ⇒ p7 / p8 各直走一段、孔错开一个对角(孔间 0.32),从 L1 往西走,KEY 留在 L4。
QFN_FAN = {'U2': {'2': (1.0, 0.75), '3': (1.5, 0.75), '15': (1.0, 0.75), '14': (1.5, 0.75),
'20': (0.75, 0.75), '19': (1.25, 0.75), '18': (1.75, 0.0), '17': (0.75, 0.75)},
'U4': {'7': (1.25, 0.0, True), '8': (1.9, 0.0, True)}}
def qfn_fan(pads):
u"""按 `QFN_FAN` 画扇出 → (segs, vias, [(焊盘, 端点, 端点层)])。
d1 / d2 一律从**焊盘中心**起算(U4 的脚长 1.65:d1 1.25 = 半长 0.825 + 伸出 0.425)。"""
segs, vias, virt = [], [], []
for ref, pins in QFN_FAN.items():
ps = [p for p in pads if p[0] == ref]
if not ps:
continue
cx = sum(p[2] for p in ps) / len(ps)
cy = sum(p[3] for p in ps) / len(ps)
for pin, cfg in pins.items():
d1, d2 = cfg[0], cfg[1]
to_via = len(cfg) > 2 and cfg[2]
q = [p for p in ps if p[1] == pin]
if not q or not q[0][7]:
continue
p = q[0]
sy = 1.0 if p[3] > cy else -1.0
sx = -1.0 if p[2] < cx else 1.0
if p[5] > p[4]: # 上下排(竖放的脚):先直走出排,再 45° 往左右拐
pts = [(p[2], p[3]), (p[2], p[3] + sy * d1), (p[2] + sx * d2, p[3] + sy * (d1 + d2))]
else: # 左右列(横放的脚):先横走出列,再 45° 往上下拐
pts = [(p[2], p[3]), (p[2] + sx * d1, p[3]), (p[2] + sx * (d1 + d2), p[3] + sy * d2)]
if d2 == 0:
pts = pts[:2]
for a, b in zip(pts, pts[1:]):
segs.append((p[6], round(a[0], 4), round(a[1], 4), round(b[0], 4), round(b[1], 4),
W_DEFAULT, p[7]))
end = (round(pts[-1][0], 4), round(pts[-1][1], 4))
lay = p[6]
if to_via:
vias.append((end[0], end[1], p[7]))
lay = LAY[1] if p[6] == LAY[0] else LAY[0]
virt.append((p, end, lay))
return segs, vias, virt
def _seg_d(a, b):
u"""两条线段(x0,y0,x1,y1)之间的最短距离(中心线)。"""
(x1, y1, x2, y2), (x3, y3, x4, y4) = a, b
d = (x2 - x1) * (y4 - y3) - (y2 - y1) * (x4 - x3)
if abs(d) > 1e-12:
t = ((x3 - x1) * (y4 - y3) - (y3 - y1) * (x4 - x3)) / d
u = ((x3 - x1) * (y2 - y1) - (y3 - y1) * (x2 - x1)) / d
if 0 <= t <= 1 and 0 <= u <= 1:
return 0.0
return min(_pt_seg(x1, y1, x3, y3, x4, y4), _pt_seg(x2, y2, x3, y3, x4, y4),
_pt_seg(x3, y3, x1, y1, x2, y2), _pt_seg(x4, y4, x1, y1, x2, y2))
def _gnd_clear(stub, via, segs, vias, clr=0.15):
u"""GND 孔 + 引出脚对**异网**已有铜(线 / 孔)的精确净距是否都 ≥ clr。"""
vx, vy = via
for s in segs:
if s[6] == 'GND':
continue
if _pt_seg(vx, vy, s[1], s[2], s[3], s[4]) - VIA_D / 2.0 - s[5] / 2.0 < clr:
return False
for t in stub:
if t[0] == s[0] and _seg_d(t[1:5], s[1:5]) - t[5] / 2.0 - s[5] / 2.0 < clr:
return False
for v in vias:
if v[2] == 'GND':
continue
if math.hypot(vx - v[0], vy - v[1]) - VIA_D < clr:
return False
for t in stub:
if _pt_seg(v[0], v[1], t[1], t[2], t[3], t[4]) - VIA_D / 2.0 - t[5] / 2.0 < clr:
return False
return True
def gnd_vias(pads, segs, vias, board_vias):
u"""`--gndvia`:**信号全布完之后**,细间距器件(`GND_VIA_REFS`)的 GND 脚各打一个孔下 L2 地平面。
⛔ 不能放在信号之前(2026-09-11 晚服务器第三轮):先打的 GND 孔把 U2 / U3 / J4 的出口占掉,
断网从 4 个涨到 9~10 个。放在最后 = 有空才打,打不下的脚留给铺铜或手补,不跟信号抢。
散热盘 / 分瓣散热盘(同一脚号多块盘)优先盘中孔。
"""
halfv = VIA_D / 2.0 + CLR
allv = [list(v) for v in board_vias] + [[x, y, n, VIA_D] for (x, y, n) in vias]
gv = build_grid(pads, halfv, allv, vkeep=False)
for (l, a, b, c, d, ww, nn) in segs:
block_track(gv, l, a, b, c, d, halfv + ww / 2.0, nn)
others = {1: [pad_rect(q) for q in pads if q[6] == 1],
2: [pad_rect(q) for q in pads if q[6] == 2]}
isl = PLANE_ISLANDS['GND'][0]
npin = {}
for q in pads:
npin[(q[0], q[1])] = npin.get((q[0], q[1]), 0) + 1
s_out, v_out, logs = [], [], []
for p in pads:
if p[7] != 'GND' or p[0] not in GND_VIA_REFS or p[6] not in LAY:
continue
w = max(0.15, min(0.3, min(p[4], p[5])))
cand = []
if min(p[4], p[5]) >= 1.0 or npin[(p[0], p[1])] > 1:
n0 = (p[6], gx(p[2]), gy(p[3]))
if gv.free_via(n0[1], n0[2], 'GND'):
cand.append(n0)
cand += [n for n in pad_nodes(gv, p, halfv, others.get(p[6], []), w / 2.0)
if isl[0] < wx(n[1]) < isl[2] and isl[1] < wy(n[2]) < isl[3]
and gv.free_via(n[1], n[2], 'GND')]
# ⛔ 落点格子空着 ≠ 引出脚空着:信号已经布完,引出脚可能横穿别人的线(服务器第九轮 J4 口
# GND 引出脚压 USB_THERM 线 / 孔 −0.06~−0.18)。每个候选先按精确几何核一遍,不够就换下一个。
pick = None
for n in cand:
vx, vy = wx(n[1]), wy(n[2])
st = stub_emit(p, vx, vy, others.get(p[6], []), w / 2.0, w, 'GND', _stub_grid(gv, None))
if _gnd_clear(st, (vx, vy), segs + s_out, vias + v_out):
pick = (vx, vy, st)
break
if pick is None:
logs.append(u'GND: %s.%s 没地方打孔(留给铺铜 / 手补)' % (p[0], p[1]))
continue
vx, vy, st = pick
s_out += st
v_out.append((vx, vy, 'GND'))
keep = halfv + w / 2.0
block_track(gv, p[6], p[2], p[3], vx, vy, keep, 'GND')
for l in LAY:
gv.block_rect(l, vx - keep, vy - keep, vx + keep, vy + keep, net='GND')
return s_out, v_out, logs
def plan_all(priority=()):
u"""`priority` 里的网络在同一档宽度里**排最前面** —— 给失败重试用。"""
import pcb_rules as R
auto = R.resolve_auto()
pads = load_pads()
board_vias = load_vias()
print(u'板上已有过孔 %d 个' % len(board_vias))
fsegs, fvias, launch = finger_escape()
segs = list(fsegs)
vias = list(fvias)
logs = []
# D± 构造最先落(L1 那时还是空的),孔同时记进「板上已有过孔」—— 后面每一张占用图都认它
dsegs, dvias = dp_blade(pads)
segs += dsegs
vias += dvias
board_vias += [[x, y, n, VIA_D] for (x, y, n) in dvias]
print(u'D± 构造 %d 段 / %d 孔' % (len(dsegs), len(dvias)))
# J1 的焊盘不再参与 A*(已经爬到 LAUNCH_Y),改成「虚拟焊盘」
bypad = {}
for p in pads:
if p[0] == 'J1':
continue
if not p[7] or p[7] in SKIP:
continue
bypad.setdefault(p[7], []).append(p)
for net, pts in launch.items():
for (lay, x, y) in pts:
bypad.setdefault(net, []).append(['J1', 'launch', x, y, 0.3, 0.3, lay, net, 'RECT'])
for net in DP_NETS: # 已由 dp_blade() 画完
bypad.pop(net, None)
# ── Kelvin 专线(必须在所有别的线之前)──
halfk = 0.3 / 2.0 + CLR
gk = build_grid(pads, halfk, board_vias)
for (l, a, b, c, d, ww, nn) in segs:
block_track(gk, l, a, b, c, d, halfk + ww / 2.0, nn)
padmap = {(q[0], q[1]): q for q in pads}
othersk = {1: [pad_rect(q) for q in pads if q[6] == 1],
2: [pad_rect(q) for q in pads if q[6] == 2]}
for (aa, bb, w) in KELVIN:
pa, pb = padmap.get(aa), padmap.get(bb)
if not pa or not pb:
logs.append(u'Kelvin: 找不到 %s / %s' % (aa, bb))
continue
na = pad_nodes(gk, pa, halfk, othersk.get(pa[6], []), w / 2.0)
nb = pad_nodes(gk, pb, halfk, othersk.get(pb[6], []), w / 2.0)
path = astar(gk, na[:12], nb[:12], pa[7], halfk)
if not path:
logs.append(u'Kelvin: %s.%s -> %s.%s 没布通' % (aa + bb))
continue
sg, vv = to_segments(path)
new = [(l, x0, y0, x1, y1, w, pa[7]) for (l, x0, y0, x1, y1) in sg]
new += stub_emit(pa, wx(path[0][1]), wy(path[0][2]),
othersk.get(pa[6], []), w / 2.0, w, pa[7])
new += stub_emit(pb, wx(path[-1][1]), wy(path[-1][2]),
othersk.get(pb[6], []), w / 2.0, w, pb[7])
segs += new
vias += [(x, y, pa[7]) for (x, y) in vv]
# ⛔ 2026-09-14:第二条 Kelvin 必须看得见第一条。原来 gk 只在循环前按旧 segs 建一次,
# BLADE-PCB_1 上两条取样线(VO_P / $1N165)整段重叠 −0.30 = 短路(旧摆位碰巧没撞上)。
for (l, a, b, c, d, ww, nn) in new:
block_track(gk, l, a, b, c, d, halfk + ww / 2.0, nn)
for (x, y) in vv:
for l in LAY:
gk.block_rect(l, x - VIA_D / 2 - CLR, y - VIA_D / 2 - CLR,
x + VIA_D / 2 + CLR, y + VIA_D / 2 + CLR, net=pa[7])
# ⚠ Kelvin 那两条已经把 `U3.9/10` 接到 `Rs1` 上了,**要把 U3 那两只脚从
# bypad 里摘掉** —— 不摘的话 A* 会再去接一遍(`$1N165: U3.10 出不来`),
# 而它本来就已经连上了。
for (aa, _bb, _w) in KELVIN:
for net in list(bypad):
bypad[net] = [q for q in bypad[net] if (q[0], q[1]) != aa]
# ── 同网邻脚短接(2026-09-14 BLADE-PCB_1):`U3.8`(VBUS 采样) 在两边都是细间距脚、落不下平面孔,
# 直接在焊盘**外端**竖着接到隔壁的 `U3.9`(VO_P,Kelvin 线已经把它接到 Rs1.1)。
# INA226 的 VBUS 本来就该取在负载侧的 VO_P 上,接在 Kelvin 取样点上电气等价、电流 µA 级。
for (aa, bb, w) in PIN_TIES:
pa, pb = padmap.get(aa), padmap.get(bb)
if not pa or not pb or pa[7] != pb[7] or pa[6] != pb[6]:
logs.append(u'短接: %s / %s 不在同层同网' % (aa, bb))
continue
cx = sum(q[2] for q in pads if q[0] == pa[0]) / float(len([q for q in pads if q[0] == pa[0]]))
sx = 1.0 if pa[2] > cx else -1.0
x = pa[2] + sx * (pa[4] / 2.0 - w / 2.0 - 0.05)
segs.append((pa[6], x, pa[3], x, pb[3], w, pa[7]))
for net in list(bypad):
bypad[net] = [q for q in bypad[net] if (q[0], q[1]) != aa]
# VO_P:靠 L3 的岛,每个焊盘扎一个过孔就够(4 A 走平面,比走线好)。
# ⚠ **过孔位置必须过占用图**:第一版是「往右 0.9 mm 找个点就打」,
# 于是 `U3.8`(0.5 mm 间距的 MSOP-10)的 1.5 mm 引出脚直接压住隔壁两个脚,
# DRC 一口气报 400 条「导线到导线 0 mil」。判据换成 pad_nodes + 岛的范围。
halfv = VIA_D / 2.0 + CLR
gv = build_grid(pads, halfv, board_vias, vkeep=False) # 平面孔要贴着自己的盘落,不吃禁孔圈
for (l, a, b, c, d, ww, nn) in segs:
block_track(gv, l, a, b, c, d, halfv + ww / 2.0, nn)
othersv = {1: [pad_rect(q) for q in pads if q[6] == 1],
2: [pad_rect(q) for q in pads if q[6] == 2]}
# 2026-09-11 晚:VO_P 和 +12V 两个平面网同一套(岛由 PLANE_ISLANDS 给,几块矩形任一块里都能落孔)
vo = []
for pnet in PLANE_VIA_NETS:
vo += [(pnet, q) for q in bypad.pop(pnet, []) if (q[0], q[1]) not in KELVIN_SENSE]
for pnet, p in vo:
isls = PLANE_ISLANDS[pnet]
if p[0] == 'J1': # 金手指那头由 finger_escape 的孔接进岛
continue
# ⚠ 下限是 **0.3 = PWR4A 规则的最小线宽**,不是 0.25。
# `U3` 的 8/9 脚(VBUS 采样 / IN+)只有 0.28 宽,按焊盘缩到 0.28 就
# 低于规则下限,DRC 报「物理错误:线宽 11mil,应该 >= 11.8mil」。
# 0.3 的线接 0.28 的盘只是稍微出头,间距还剩 0.36-0.30=0.06,是够的。
w = min(WIDTH.get(pnet, 0.3), max(0.15 if pnet == 'GND' else 0.30, min(p[4], p[5])))
# ⚠ 落的是**通孔**,两层都要让得开 —— 只查自己那一层的话会撞上另一面的焊盘
# (实测 VO_P 的孔压在顶层 `C60` 的 GND 盘上,净距 0.125,DRC 报「焊盘到过孔」)。
cand = [n for n in pad_nodes(gv, p, halfv, othersv.get(p[6], []), w / 2.0)
if any(isl[0] + 0.6 < wx(n[1]) < isl[2] - 0.6
and isl[1] + 0.6 < wy(n[2]) < isl[3] - 0.6 for isl in isls)
and gv.free_via(n[1], n[2], pnet)]
if pnet == 'GND' and min(p[4], p[5]) >= 1.0: # QFN 散热盘:盘中孔优先
n0 = (p[6], gx(p[2]), gy(p[3]))
if gv.free_via(n0[1], n0[2], 'GND'):
cand = [n0] + cand
if not cand:
logs.append(u'%s: %s.%s 找不到落过孔的地方' % (pnet, p[0], p[1]))
continue
want = VIAS_PER_PAD.get((pnet, p[0]), 1)
placed = []
for n in cand:
if len(placed) >= want:
break
vx, vy = wx(n[1]), wy(n[2])
# 同一个盘的第 2、3 个孔:离已放的孔 ≥ 孔径 + 0.2,且此刻仍然空着
if any(math.hypot(vx - a, vy - b) < VIA_D + 0.2 for a, b in placed):
continue
if placed and not gv.free_via(n[1], n[2], pnet):
continue
segs += stub_emit(p, vx, vy, othersv.get(p[6], []), w / 2.0, w, pnet)
vias.append((vx, vy, pnet))
keep = halfv + w / 2.0
block_track(gv, p[6], p[2], p[3], vx, vy, keep, pnet)
for l in LAY:
gv.block_rect(l, vx - keep, vy - keep, vx + keep, vy + keep, net=pnet)
board_vias.append([vx, vy, pnet, VIA_D])
placed.append((vx, vy))
if len(placed) < want:
print(u' [!] %s: %s.%s 只落了 %d / %d 个平面孔' % (pnet, p[0], p[1], len(placed), want))
# ── U2 角上的预扇出(见 QFN_FAN):画上铜,焊盘在 bypad 里换成扇出端点的虚拟焊盘 ──
if '--nofan' not in sys.argv:
qs, qv, virt = qfn_fan(pads)
segs += qs
vias += qv
board_vias += [[x, y, n, VIA_D] for (x, y, n) in qv] # 后面每一张占用图都认它
for p, (ex, ey), lay in virt:
bypad[p[7]] = [q for q in bypad.get(p[7], []) if not (q[0] == p[0] and q[1] == p[1])]
# ⚠ 名字用 `qfan:`,**不能用 `fan:`**:`_is_launch()` 把 `fan:` 当成金手指 / 过孔扇出那种
# 「坐在焊盘阵边上的落点」,从它出发的引出脚不查邻盘 —— 实测 $1N158 从扇出端点斜着出去压上
# C14.2(GND,−0.133)。这里的端点已经在焊盘阵外面,照普通焊盘查邻盘。
bypad[p[7]].append([p[0], 'qfan:%s' % p[1], ex, ey, 0.3, 0.3, lay, p[7], 'RECT'])
print(u'QFN 预扇出 %d 段 / %d 孔 / %d 只脚' % (len(qs), len(qv), len(virt)))
# ── 直连:**焊盘对焊盘、中间没别人**的短连接,不走 A* ────────────────
AUTO_CACHE.clear()
AUTO_CACHE.update(auto)
dsegs, merged = direct_links(pads, bypad, segs, vias)
segs += dsegs
if dsegs:
print(u'直连 %d 段(焊盘贴焊盘,A* 的干线口径进不去)' % len(dsegs))
for net, groups in merged.items():
drop = set()
for gset in groups:
drop |= set(list(gset)[1:]) # 每一簇只留一个代表
bypad[net] = [q for q in bypad[net] if (q[0], q[1]) not in drop]
# ── 过孔扇出(M6,`--viafan`):细间距 IC 每只信号脚先打一个过孔上顶层 ──
if '--viafan' in sys.argv:
fs, fv, fl = via_fanout(pads, bypad, segs, vias, auto)
segs += fs
vias += fv
n_fan = 0
for net, items in fl.items():
for (q, vx, vy) in items:
bypad[net] = [o for o in bypad[net] if o is not q]
bypad[net].append([q[0], 'fan:%s' % q[1], vx, vy, 0.3, 0.3, 1, net, 'RECT'])
n_fan += 1
print(u'过孔扇出 %d 只脚(%s),落点全在顶层' % (n_fan, ' '.join(FANOUT_REFS)))
# ── 关键网络优先:在**所有**别的网之前布 ────────────────────────────
# ⛔ `$1N126`(FB) 和 `$1N138`(BOOT) 是 Buck 的命门:少了 BOOT 高侧管不开,
# 少了 FB 环路不闭合。它们都在 `U1` 最挤的那一圈上,按线宽分档的话排在
# 最后一档、又被别的网占完出口 —— 所以给它们一条绝对优先的通道。
for net in FIRST_NETS:
if net not in bypad or not bypad[net]:
continue
ps = bypad[net]
w = max(W_DEFAULT, rule_min(net, auto),
min([min(q[4], q[5]) for q in ps if q[0] != 'J1'] or [W_DEFAULT]))
halfw = w / 2.0 + CLR
g = build_grid(pads, halfw, board_vias)
# ⛔ 2026-09-11(刀片 90×68):这里原来只按 `halfw + ww/2` 把已布的线打进占用图,**过孔图少了
# VIA_D/2 + CLR − halfw 那一截** —— 底板那边在 route_groups / grid_now 早修过(VIA_MAP_FIX),
# 刀片这条 FIRST_NETS 的路一直没跟上。后果:先布的网(这张表里的)的孔可以落在更早那根线旁
# 0.2 mm 以内。实测六组变体**全部**在 U2 角上有两处孔压线(EFUSE_EN / $1N164 的孔 vs
# $1N147 / EFUSE_PG 的线,净距 −0.095)+ 若干 0.125(< 规则 0.15)。
for (l, a1, b1, c1, d1, ww, nn) in segs:
if VIA_MAP_FIX:
block_track(g, l, a1, b1, c1, d1, halfw + ww / 2.0, nn,
extra=max(0.0, VIA_D / 2.0 + CLR - halfw), exact_v=True)
else:
block_track(g, l, a1, b1, c1, d1, halfw + ww / 2.0, nn)
# ⛔⛔ 2026-09-11 真正的根因:这条 FIRST_NETS 路**从来没把已放的过孔打进占用图** ——
# `build_grid(..., board_vias)` 只含板上旧孔 / D± / VO_P 孔,本轮 `plan_all` 里刚放的
# (金手指 launch 孔、表里先布的网的孔)只靠挂在孔上的那几段短线间接挡着:0.15 的线只护住孔心 ±0.31,
# 后布的线就能压进 Ø0.6 的孔 —— 实测 `$1N164` 的孔压 `EFUSE_PG`、`EFUSE_EN` 的孔压 `$1N147`(−0.095),
# 和 route_groups 注释里 M7 记过的是**同一对**,那边早修了,这边漏了。口径照抄 route_groups。
for (vx, vy, nn) in vias:
rv = VIA_D / 2.0 + CLR + halfw
ev = max(0.0, VIA_D / 2.0 - halfw)
for l in LAY:
g.block_both(l, vx - rv, vy - rv, vx + rv, vy + rv, ev, net=nn, exact_v=True)
gt = Grid()
for (l, a1, b1, c1, d1, ww, nn) in segs:
block_track(gt, l, a1, b1, c1, d1, CLR + W_DEFAULT / 2.0 + ww / 2.0, nn)
for (vx, vy, nn) in vias:
r = VIA_D / 2.0 + CLR + W_DEFAULT / 2.0
for l in LAY:
gt.block_rect(l, vx - r, vy - r, vx + r, vy + r, net=nn)
others = {1: [], 2: []}
for q in pads:
if q[6] in others:
others[q[6]].append(pad_rect(q))
s2, v2, st2 = route_net(g, net, ps, halfw, w, others, logs, gt)
segs += s2
vias += v2
for (q, node, ww) in st2:
segs += stub_emit(q, wx(node[1]), wy(node[2]),
[] if _is_launch(q) else others.get(q[6], []),
ww / 2.0, ww, net, gt)
bypad.pop(net, None)
# ⚠ **独立的扇出阶段试过,反而更差**(30 -> 40 条没接上),代码留在 `fanout()`
# 里备查,默认不跑。原因:提前把每只脚的引出脚铺成铜,等于在还不知道干线
# 往哪走的时候先占地盘;更要命的是干线从此**只能从那一个落点起步**,
# 而原来 `pad_nodes` 能给 A* 24 个候选起点。少了这份自由度,得不偿失。
fixed_s, fixed_v = len(segs), len(vias)
if '--pf' in sys.argv:
# 协商式拥塞布线:所有干线交给它,不再走「一次布死」那条路
logs += pf_solve(bypad, pads, segs, vias, board_vias, auto, fixed_s, fixed_v)
else:
logs += route_groups(bypad, pads, segs, vias, board_vias, auto, priority)
if logs:
segs, vias, logs = repair(bypad, pads, segs, vias, logs, board_vias, auto,
fixed_s, fixed_v)
if '--gndvia' in sys.argv:
s3, v3, l3 = gnd_vias(pads, segs, vias, board_vias)
segs += s3
vias += v3
logs += l3
print(u'GND 打孔 %d 个(%d 只脚没地方,留给铺铜 / 手补)' % (len(v3), len(l3)))
segs, vias = dedupe_vias(segs, vias)
for m in check_connected(pads, segs, vias):
if m not in logs:
logs.append(u'[连通性] ' + m)
return segs, vias, logs
PUSH_JS = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r=>setTimeout(r,2500));
const MIL = %s, S = %s, V = %s, H = %s, D = %s;
let ls = 0, vs = 0;
for (const s of S) {
try { await eda.pcb_PrimitiveLine.create(s[6], s[0], s[1]*MIL, s[2]*MIL,
s[3]*MIL, s[4]*MIL, s[5]*MIL); ls++; } catch (e) {}
}
for (const v of V) {
try { await eda.pcb_PrimitiveVia.create(v[2], v[0]*MIL, v[1]*MIL, H*MIL, D*MIL); vs++; } catch (e) {}
}
return {ls, vs};
"""
def push(segs, vias):
n = 0
for k in range(0, len(segs), 120):
r = call(PUSH_JS % (PCB, MIL, json.dumps(segs[k:k + 120]),
json.dumps(vias if k == 0 else []), VIA_HOLE, VIA_D),
timeout=600)
n += r['ls']
print(u' 下发 %d 段 / %d 过孔' % (r['ls'], r['vs']))
call("await eda.pcb_Document.save(); return 1;")
print(u' 共 %d 段' % n)
# ⛔⛔ 2026-09-12 深夜的教训:**`primitiveId` 必须整串传,客户端是 16 个十六进制字符**。
# `delete()` 收到一个不存在的 id **不报错、不返回失败计数、静默忽略** ——
# 那次是把诊断脚本里 `print('%-14s' % pid[:14])` **截断显示**的 id 复制进删除清单,
# 于是「删 6 线 / 2 孔」照常打印成功、`err` 数组为空,实际一个都没删;
# 新线又画上了,板上变成新旧并联(16 段 → 21 段),而且 EasyEDA 还把重叠处
# 打断重组成三段、原 id 当场失效。
# ⇒ 两条规矩:① 打印 id 一律不截断(要短就另起一列,别动 id 本身);
# ② 删除后**必须按条数或坐标读回核对**,不能信返回值 —— 这是本项目第 N 次
# 栽在「返回值看着正常 ≠ 它在工作」上(同 §13 的 ToolsCheckInterference2、
# §25 的 GetFaceCount、焊盘 getAll 的 width/height)。
def clear_mine():
print(call("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2500));"
"const ls = (await eda.pcb_PrimitiveLine.getAll()||[])"
" .filter(l=>l.layer===1||l.layer===2).map(l=>l.primitiveId);"
"if (ls.length) await eda.pcb_PrimitiveLine.delete(ls);"
"const vs = (await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>v.primitiveId);"
"if (vs.length) await eda.pcb_PrimitiveVia.delete(vs);"
"await eda.pcb_Document.save();"
"return {lines: ls.length, vias: vs.length};" % PCB, timeout=600))
FAN_RING = 10 # 扇出找落点最多往外扫几圈(× G = 2.5 mm)
FAN_PITCH = 1.0 # 只给「邻脚中心距 < 这个值」的密脚做扇出
def _pad_axis(pads_of_comp, q):
u"""焊盘的**出线方向**:沿长轴、背对本封装的重心。"""
cx = sum(p[2] for p in pads_of_comp) / float(len(pads_of_comp))
cy = sum(p[3] for p in pads_of_comp) / float(len(pads_of_comp))
if q[5] >= q[4]: # 竖着的盘 -> 上下出
return (0.0, 1.0 if q[3] >= cy else -1.0)
return (1.0 if q[2] >= cx else -1.0, 0.0)
FANOUT_REFS = ('U2', 'U4', 'U3') # WQFN-20 0.5 / TSSOP-24 0.65 / MSOP-10 0.5
FAN_NEAR, FAN_FAR = 0.55, 1.15 # 过孔中心离焊盘端的距离,相邻脚交替(两排)
FAN_MAX_W = 0.4 # 只扇信号(含 +3V3);VO/$1N165 那种粗线靠直连的铜条
def via_fanout(pads, bypad, segs, vias, auto):
u"""**交错两排的过孔扇出**(手工布细间距 IC 的标准做法)。
每只信号脚沿焊盘长轴直着伸出一段 0.15 的引出脚,末端打一个 Ø0.5 的过孔上顶层;
相邻脚一近一远交替(0.55 / 1.15),于是同一排过孔的节距是脚距的两倍、
远排的引出脚正好从近排两个过孔中间穿过(0.5 节距:0.5 − 0.25 − 0.075 = 0.175)。
干线从此全在**几乎是空的顶层**起步,细间距 IC 周围的底层不再是抢出口的战场。
⚠ 与 `fanout()`(同层落点、已废)的区别就在「换层」:同层落点只是把地盘
提前占了,干线还是要从那一堆脚中间挤出去。
落点冲突(别的网的焊盘 / 已有铜 / 已有过孔)就换另一排;两排都不行的脚原样交给 A*。
返回 (引出脚线段, 过孔, {网: [(原焊盘, vx, vy)]})。
"""
bycomp = {}
for q in pads:
bycomp.setdefault(q[0], []).append(q)
laid = list(segs)
fan_vias = list(vias) # (x, y, net)
out_s, out_v, out_l = [], [], {}
r_via = VIA_D / 2.0
want = {}
for net, ps in bypad.items():
if rule_width(net, auto) > FAN_MAX_W + 1e-9:
continue
for q in ps:
if q[0] in FANOUT_REFS and not _is_launch(q) and (q[0], q[1]) not in KELVIN_PADS:
want[(q[0], q[1])] = (q, net)
for ref in FANOUT_REFS:
comp = bycomp.get(ref, [])
sides = {}
for q in comp:
if (q[0], q[1]) not in want:
continue
ax, ay = _pad_axis(comp, q)
key = (int(round(ax)), int(round(ay)))
sides.setdefault(key, []).append((q, ax, ay))
for key, items in sides.items():
# 沿着这一边排序:竖排按 y,横排按 x
items.sort(key=lambda t: t[0][3] if key[0] else t[0][2])
for k, (q, ax, ay) in enumerate(items):
net = want[(q[0], q[1])][1]
half_len = (q[5] if abs(ay) > 0.5 else q[4]) / 2.0
sw = stub_w(q, W_DEFAULT)
order = (FAN_NEAR, FAN_FAR) if k % 2 == 0 else (FAN_FAR, FAN_NEAR)
placed = None
for dist in order:
vx = q[2] + ax * (half_len + dist)
vy = q[3] + ay * (half_len + dist)
ok = True
# 别的网的焊盘(两层都要让:通孔)
for o in pads:
if o[7] == net:
continue
r = pad_rect(o)
d = math.hypot(max(r[0] - vx, 0.0, vx - r[2]), max(r[1] - vy, 0.0, vy - r[3]))
if d < r_via + CLR:
ok = False
break
# 引出脚本身也不许蹭到同层别的盘
if o[6] == q[6] and seg_hits_rect(q[2], q[3], vx, vy,
(r[0] - CLR - sw / 2, r[1] - CLR - sw / 2,
r[2] + CLR + sw / 2, r[3] + CLR + sw / 2)):
ok = False
break
if not ok:
continue
for (l, a1, b1, c1, d1, ww, nn) in laid + out_s:
if nn == net:
continue
if _pt_seg_d(vx, vy, a1, b1, c1, d1) < r_via + ww / 2.0 + CLR:
ok = False
break
if l == q[6] and _seg_seg_d((q[2], q[3], vx, vy), (a1, b1, c1, d1)) < sw / 2.0 + ww / 2.0 + CLR:
ok = False
break
if not ok:
continue
for (ox, oy, on) in fan_vias:
if on == net:
continue
if math.hypot(ox - vx, oy - vy) < 2 * r_via + CLR + 0.05:
ok = False
break
if _pt_seg_d(ox, oy, q[2], q[3], vx, vy) < r_via + sw / 2.0 + CLR:
ok = False
break
if ok:
placed = (vx, vy)
break
if placed is None:
continue
vx, vy = placed
out_s.append((q[6], q[2], q[3], round(vx, 3), round(vy, 3), sw, net))
out_v.append((round(vx, 3), round(vy, 3), net))
fan_vias.append((vx, vy, net))
out_l.setdefault(net, []).append((q, round(vx, 3), round(vy, 3)))
return out_s, out_v, out_l
def _pt_seg_d(px, py, x0, y0, x1, y1):
dx, dy = x1 - x0, y1 - y0
L2 = dx * dx + dy * dy
t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / L2))
return math.hypot(px - (x0 + dx * t), py - (y0 + dy * t))
def _seg_seg_d(a, b):
(x0, y0, x1, y1), (u0, v0, u1, v1) = a, b
r = (x1 - x0, y1 - y0); s2 = (u1 - u0, v1 - v0)
den = r[0] * s2[1] - r[1] * s2[0]
if abs(den) > 1e-12:
t = ((u0 - x0) * s2[1] - (v0 - y0) * s2[0]) / den
u = ((u0 - x0) * r[1] - (v0 - y0) * r[0]) / den
if 0 <= t <= 1 and 0 <= u <= 1:
return 0.0
return min(_pt_seg_d(x0, y0, *b), _pt_seg_d(x1, y1, *b), _pt_seg_d(u0, v0, *a), _pt_seg_d(u1, v1, *a))
def fanout(pads, bypad, segs, vias, board_vias, auto):
u"""**扇出阶段**:布任何干线之前,先把每只焊盘的引出脚铺成真铜。
⛔ 为什么必须独立成一阶段:出口是**抢**的。谁先布谁把邻脚的格点占掉,
细间距 IC 的内侧脚必然最后一个布、也必然抢不到 —— 一轮下来 29~42 处
报「出不来」,而同样的摆位单独看每只脚**都是出得来的**(空占用图上
`U4.5` 有 7 个出口,`U2.14` 有 4 个)。这就是手工布板「先做扇出、再拉干线」
的顺序,不是优化,是**必须**。
两段式引出脚(细间距连接器靠它):**先沿焊盘长轴直着出去**到本盘自己的
「车道」尽头,再拐到格点。`J4`(0.35 mm 间距、盘 0.18 宽)只有这一条路 ——
盘心到邻盘边只有 0.26 mm,任何斜着走的引出脚都会蹭上邻盘(实测 0.047 mm)。
返回 (引出脚线段, {网: [落点虚拟焊盘]}, 没出来的日志)。
"""
bycomp = {}
for q in pads:
bycomp.setdefault(q[0], []).append(q)
laid = list(segs) # 已经有铜的(金手指扇出 / Kelvin / 直连)
launched, logs = {}, []
# 宽的先扇出:它的落点要求最苛刻
order = sorted(bypad, key=lambda n: -width_of(n, auto, bypad[n]))
for net in order:
ps = [q for q in bypad[net] if q[0] != 'J1']
if not ps:
continue
w = width_of(net, auto, ps)
w = max(W_DEFAULT, min(w, min(min(q[4], q[5]) for q in ps)))
halfw = w / 2.0 + CLR
g = build_grid(pads, halfw, board_vias)
for (l, a1, b1, c1, d1, ww, nn) in laid:
block_track(g, l, a1, b1, c1, d1, halfw + ww / 2.0, nn)
others = [pad_rect(q) for q in pads]
out = []
# ⚠ **只扇密脚**。把每只脚都提前铺一段引出脚,等于在还不知道干线往哪走的时候
# 就占地盘 —— 实测反而从 30 处涨到 40 处没接上。真正抢不到出口的只有
# 细间距 IC 的内侧脚(邻脚中心距 < 1 mm),其余交给 A* 自己找更好。
for q in ps:
near = min([math.hypot(q[2] - o[2], q[3] - o[3])
for o in bycomp[q[0]] if o is not q] or [99])
if near >= FAN_PITCH:
out.append(q) # 原样交给 A*
continue
sw = stub_w(q, w)
inf = STUB_CLR + sw / 2.0
own = pad_rect(q)
oth = [(r[0] - inf, r[1] - inf, r[2] + inf, r[3] + inf)
for r, p2 in zip(others, pads)
if p2[6] == q[6] and p2[7] != net
and max(abs(r[0] - own[0]), abs(r[1] - own[1]),
abs(r[2] - own[2]), abs(r[3] - own[3])) > 1e-9]
oth_pads = [o for o in ps if o is not q]
tx = (sum(o[2] for o in oth_pads) / len(oth_pads)) if oth_pads else q[2]
ty = (sum(o[3] for o in oth_pads) / len(oth_pads)) if oth_pads else q[3]
ax, ay = _pad_axis(bycomp[q[0]], q)
reach = (q[5] if ay else q[4]) / 2.0 + inf
ex, ey = q[2] + ax * reach, q[3] + ay * reach
best = None
for ring in range(1, FAN_RING + 1):
r = ring * G
cand = []
for j in range(gy(ey - r), gy(ey + r) + 1):
for i in range(gx(ex - r), gx(ex + r) + 1):
X, Y = wx(i), wy(j)
if not g.free(q[6], i, j, net):
continue
if any(seg_hits_rect(q[2], q[3], ex, ey, o) for o in oth):
continue
if any(seg_hits_rect(ex, ey, X, Y, o) for o in oth):
continue
if not _stub_clear(g, q[6], q[2], q[3], X, Y, net):
continue
# ⚠ 落点**朝目的地挑**,不是挑离焊盘最近的:
# 引出脚指错方向,后面干线要绕回来,还把路占了。
cand.append((math.hypot(X - tx, Y - ty),
math.hypot(X - ex, Y - ey), i, j))
if cand:
cand.sort()
best = cand[0]
break
if best is None:
logs.append(u'%s: %s.%s 扇不出来' % (net, q[0], q[1]))
continue
_d, _d2, i, j = best
X, Y = wx(i), wy(j)
if math.hypot(ex - q[2], ey - q[3]) > 1e-6:
laid.append((q[6], q[2], q[3], ex, ey, sw, net))
laid.append((q[6], ex, ey, X, Y, sw, net))
block_track(g, q[6], q[2], q[3], ex, ey, halfw + sw / 2.0, net)
block_track(g, q[6], ex, ey, X, Y, halfw + sw / 2.0, net)
out.append([q[0], 'fan:' + str(q[1]), X, Y, 0.3, 0.3, q[6], net, 'RECT'])
if out:
launched[net] = out
return laid[len(segs):], launched, logs
LINK_MAX = 2.2 # 直连允许的最大焊盘净距
def _pad_gap(a, b):
dx = max(abs(a[2] - b[2]) - (a[4] + b[4]) / 2.0, 0.0)
dy = max(abs(a[3] - b[3]) - (a[5] + b[5]) / 2.0, 0.0)
return math.hypot(dx, dy)
AUTO_CACHE = {}
def direct_links(pads, bypad, segs, vias, blocked_rects=()):
u"""**焊盘对焊盘**的短连接直接画一段铜,不交给 A*。
⛔ 为什么非要有这一步:A* 的占用图是按**干线宽度**建的。`SWNODE` 是 2.0 mm ⇒
半宽 + 净距 = 1.18 mm,于是 `U1` 的 SW 脚(0.5 mm 间距)周围**一个格点都不空**,
报「$1N137: U1.11 出不来」。可这条连接根本不需要 A* —— 摆位时就是
**焊盘正对焊盘、净距 0.65 mm**,人手画就是一段直铜。
同一类的还有 `VO`(L1→C6)、`$1N165`(U2→Rs1)、`+12V_F`(Cin→U1)。
宽度取**两个焊盘在垂直于连线方向上的较小者** —— 再宽也没用,
电流的瓶颈就是 IC 自己的脚。
判据:同层、同网、净距 ≤ `LINK_MAX`,且连线走廊里没有第三个焊盘、
没有已布的铜、没有过孔。
"""
out, merged = [], {}
others = {}
for q in pads:
others.setdefault(q[6], []).append(q)
for net, ps in sorted(bypad.items()):
cand = [q for q in ps if q[0] != 'J1']
groups = []
for i in range(len(cand)):
for j in range(i + 1, len(cand)):
a, b = cand[i], cand[j]
if a[6] != b[6]:
continue
if _pad_gap(a, b) > LINK_MAX:
continue
# 垂直于连线方向的可用宽度
dx, dy = abs(a[2] - b[2]), abs(a[3] - b[3])
wa = a[5] if dx >= dy else a[4]
wb = b[5] if dx >= dy else b[4]
w = round(max(rule_min(net, AUTO_CACHE), min(wa, wb)), 3)
if w < 0.15:
continue
inf = CLR + w / 2.0
bad = False
for owner, rr in blocked_rects:
if owner == net:
continue
r = (rr[0] - inf, rr[1] - inf, rr[2] + inf, rr[3] + inf)
if seg_hits_rect(a[2], a[3], b[2], b[3], r):
bad = True
break
for q in others.get(a[6], []):
# ⚠ **同网的焊盘不算障碍** —— 压上去也是同一个节点。
# 漏了这一条,`U1` 的两只 SW 脚会互相判死:11 脚要接 L1,
# 12 脚(同网、隔 0.5 mm)挡在旁边,于是「U1.11 出不来」。
if q[7] == net or (q[0], q[1]) in ((a[0], a[1]), (b[0], b[1])):
continue
r = pad_rect(q, inf)
if seg_hits_rect(a[2], a[3], b[2], b[3], r):
bad = True
break
if not bad:
for (l, x0, y0, x1, y1, ww, nn) in segs:
if l != a[6] or nn == net:
continue
if seg_seg_dist(a[2], a[3], b[2], b[3], x0, y0, x1, y1) < inf + ww / 2.0:
bad = True
break
if not bad:
for (vx, vy, nn) in vias:
if nn == net:
continue
if seg_hits_rect(a[2], a[3], b[2], b[3],
(vx - VIA_D / 2 - inf, vy - VIA_D / 2 - inf,
vx + VIA_D / 2 + inf, vy + VIA_D / 2 + inf)):
bad = True
break
if bad:
continue
out.append((a[6], a[2], a[3], b[2], b[3], w, net))
ka, kb = (a[0], a[1]), (b[0], b[1])
ga = next((g for g in groups if ka in g), None)
gb = next((g for g in groups if kb in g), None)
if ga is None and gb is None:
groups.append({ka, kb})
elif ga is None:
gb.add(ka)
elif gb is None:
ga.add(kb)
elif ga is not gb:
ga |= gb
groups.remove(gb)
if groups:
merged[net] = groups
return out, merged
def seg_seg_dist(x1, y1, x2, y2, x3, y3, x4, y4):
u"""两条线段的最小距离(够用的粗算:端点到线段 + 采样)。"""
def pt_seg(px, py, ax, ay, bx, by):
dx, dy = bx - ax, by - ay
L = dx * dx + dy * dy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
return min(pt_seg(x1, y1, x3, y3, x4, y4), pt_seg(x2, y2, x3, y3, x4, y4),
pt_seg(x3, y3, x1, y1, x2, y2), pt_seg(x4, y4, x1, y1, x2, y2))
VIA_MIN_D = VIA_D # 同网过孔的最小中心距 = 钻孔间距 0.3 + 钻孔径 0.3
def dedupe_vias(segs, vias):
u"""同一个网上**挨得太近的过孔**合成一个。
⛔ 占用图是「按网可进」的,所以同一个网的两条支路可以各自在几乎同一个点换层 ——
几何上不冲突,但**钻孔到钻孔**的规则不认网:DRC 报
「钻孔 (SCL) 到 钻孔 (SCL) 距离为 0 mil,应该 >= 11.8 mil」。
自算净距那一支也抓不到(`router_check` 只判铜与铜,钻孔那一支归 EasyEDA)。
合并判据:同网、中心距 < `VIA_MIN_D`(=0.6 mm)。这时两个过孔的**焊盘本来就重叠**,
所以补一小段两层导线把被删的那个点接回保留点,不会伸出原来的铜之外。
"""
keep, drop = [], {}
for (x, y, net) in vias:
hit = None
for (kx, ky, kn) in keep:
if kn == net and math.hypot(kx - x, ky - y) < VIA_MIN_D - 1e-9:
hit = (kx, ky)
break
if hit is None:
keep.append((x, y, net))
else:
drop[(round(x, 4), round(y, 4), net)] = hit
if drop:
print(u'同网过孔合并 %d 个(中心距 < %.2f mm)' % (len(drop), VIA_MIN_D))
for (x, y, net), (kx, ky) in drop.items():
w = max(0.2, rule_min(net, AUTO_CACHE))
for l in LAY:
segs.append((l, x, y, kx, ky, w, net))
return segs, keep
PASSES = 4 # 失败重试轮数(拆线重布接手之后不必太多)
CACHE = os.path.join(HERE, '_blade_routes.json')
# ══════════════════════════════════════════════════════════════════════
# 协商式拥塞布线(PathFinder)
# ══════════════════════════════════════════════════════════════════════
# ⛔ 为什么非要换算法:这个「一次布死、不回退」的 A* 到 23 条就不动了。
# 试过顺序、扇出、过路费、L 形引出脚、正交方向、拆线重布、难的先布 —— 全在
# 12~23 之间打转。根因是**它没有让路机制**:谁先占住走廊,后来的就只能报失败。
#
# PathFinder 的做法(Nair 1987 / McMurchie-Ebeling 1995):
# 1. 每一轮**所有网都重新布一遍**,允许暂时压在别人身上(不是硬障碍,是加钱);
# 2. 一轮结束后,把**被多个网压着的格点**的「历史代价」加一档;
# 3. 下一轮大家再抢,历史贵的地方自然有人让开 —— 谁绕路代价小谁让。
# 反复若干轮直到没有格点被两个网同时占。这就是「协商」。
# ⚠ 调度是 PathFinder 的命门。第一版「每轮翻倍、封顶 64」**不收敛** ——
# 冲突 1790→1062 之后在 1200 附近震荡:当前代价涨得太快,网被推得来回横跳,
# 历史代价还没来得及把「谁该让」这件事沉淀下来。
# 改成教科书的慢涨(×1.25),并让历史按**超用量**累加。
PF_PRESENT = [1.0] # 当前拥塞权重,每轮 ×PF_GROW
PF_GROW = 1.25
PF_HIST_W = 4 # 历史拥塞权重
PF_ITERS = 30
def _route_cells(segs_of_net, halfw):
u"""一条线占的格点。
⛔ 膨胀量是 **线半宽 + 净距/2**,不是 `halfw + w/2`(= 线宽 + 净距)。
两条线各膨胀「半宽 + 净距/2」之后**刚好在净距处相切** —— 这才是
「再近一点就违规」的判据。按 `halfw + w/2` 膨胀的话,两根**正好隔着
合法净距**的线也会共占格点,于是 PF 一直在追逐根本不存在的冲突,
冲突数卡在 800 上下永远收敛不了(实测 2678→823 就不动了)。
"""
out = set()
for (l, x0, y0, x1, y1, w, _n) in segs_of_net:
r = w / 2.0 + CLR / 2.0
steps = int(max(abs(x1 - x0), abs(y1 - y0)) / (G / 2)) + 1
for k in range(steps + 1):
t = k / float(steps)
x, y = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t
for j in range(gy(y - r), gy(y + r) + 1):
for i in range(gx(x - r), gx(x + r) + 1):
if 0 <= i < NX and 0 <= j < NY:
out.add((l, i, j))
return out
def _pf_mark(occ, cells, d):
for (l, i, j) in cells:
o = j * NX + i
v = occ[l][o] + d
occ[l][o] = max(0, min(250, v))
def _exact_conflicts(cur, pads):
u"""按**精确几何**找出真正压在一起的网(判据同 router_check:净距 < MIN_CLR)。
⚠ **过孔那一支不能漏**:只查线与线的话,会漏掉「过孔压在另一面的焊盘上」
和「两个不同网的过孔叠在一起」—— 实测漏掉了 `PRSNT#` 的孔压在 `J2.5`(GND)
盘上(净距 −0.300)和 `+12V_F` / `$1N131` 两个孔叠了 0.041。
通孔是穿两层的,所以焊盘那一支**两层都要查**。
"""
MIN = 0.15
items = []
allv = []
for net, (s2, v2, _c) in cur.items():
for t in s2:
items.append((t[0], t[1], t[2], t[3], t[4], t[5], net))
for (vx, vy, _q) in v2:
allv.append((vx, vy, net))
# 按 y 粗分桶,避免 O(n^2) 全比
bad = set()
n = len(items)
for i in range(n):
a = items[i]
ay0, ay1 = min(a[2], a[4]), max(a[2], a[4])
ax0, ax1 = min(a[1], a[3]), max(a[1], a[3])
for j in range(i + 1, n):
b = items[j]
if b[6] == a[6] or b[0] != a[0]:
continue
if min(b[2], b[4]) > ay1 + 1.0 or max(b[2], b[4]) < ay0 - 1.0:
continue
if min(b[1], b[3]) > ax1 + 1.0 or max(b[1], b[3]) < ax0 - 1.0:
continue
d = seg_seg_dist(a[1], a[2], a[3], a[4], b[1], b[2], b[3], b[4])
if d - a[5] / 2.0 - b[5] / 2.0 < MIN - 1e-9:
bad.add(a[6])
bad.add(b[6])
# 过孔 vs 焊盘(两层都查,通孔穿透)
for (vx, vy, net) in allv:
for q in pads:
if q[7] == net:
continue
dx = max(q[2] - q[4] / 2 - vx, 0.0, vx - (q[2] + q[4] / 2))
dy = max(q[3] - q[5] / 2 - vy, 0.0, vy - (q[3] + q[5] / 2))
if math.hypot(dx, dy) - VIA_D / 2.0 < MIN - 1e-9:
bad.add(net)
break
# 过孔 vs 过孔(异网)
for i in range(len(allv)):
for j in range(i + 1, len(allv)):
if allv[i][2] == allv[j][2]:
continue
if math.hypot(allv[i][0] - allv[j][0], allv[i][1] - allv[j][1]) - VIA_D < MIN - 1e-9:
bad.add(allv[i][2])
bad.add(allv[j][2])
return bad
def pf_solve(bypad, pads, segs, vias, board_vias, auto, fixed_s, fixed_v):
u"""对 `bypad` 里所有网做协商式拥塞布线。原地改 segs/vias,返回没搞定的日志。"""
occ = {l: bytearray(NX * NY) for l in LAY}
hist = {l: bytearray(NX * NY) for l in LAY}
base_s, base_v = segs[:fixed_s], vias[:fixed_v]
# 每个网一份线宽 / 半宽;硬障碍图按宽度分档建一次就够
wof, hard = {}, {}
for net, ps in bypad.items():
w = width_of(net, auto, ps)
small = w if net in NO_CAP else min([min(q[4], q[5]) for q in ps if q[0] != 'J1'] or [w])
wof[net] = max(W_DEFAULT, rule_min(net, auto), min(w, small))
for w in sorted(set(wof.values())):
halfw = w / 2.0 + CLR
g = build_grid(pads, halfw, board_vias)
for (l, a, b, c, d, ww, nn) in base_s: # 前置阶段的铜是硬障碍
# ⛔ `extra` 不能用默认的 0:`block_both` 的第二张图(`ownv`)是**换层判据**,
# 不给额外膨胀的话,别人的过孔可以直接落在这根线上 ——
# DRC 报「导线到过孔 0 mil」,实测两处(`EFUSE_EN` 的孔压 `$1N147`、
# `$1N164` 的孔压 `EFUSE_PG`),而线与线那一支干干净净,很难看出来是谁的错。
block_track(g, l, a, b, c, d, halfw + ww / 2.0, nn,
extra=max(0.0, VIA_D / 2.0 + CLR - halfw), exact_v=True) # 2026-09-11:同 route_groups
for (vx, vy, nn) in base_v:
ev = max(0.0, VIA_D / 2.0 + CLR - halfw) # 过孔图:新孔离旧孔 ≥ VIA_D + CLR
for l in LAY:
g.block_both(l, vx - VIA_D / 2 - halfw, vy - VIA_D / 2 - halfw,
vx + VIA_D / 2 + halfw, vy + VIA_D / 2 + halfw, ev, net=nn, exact_v=True)
g.occ, g.hist = occ, hist
hard[w] = g
others = {1: [], 2: []}
for p in pads:
if p[6] in others:
others[p[6]].append(pad_rect(p))
cur = {} # net -> (segs, vias, cells)
order = sorted(bypad, key=lambda n: -wof[n])
logs = []
best = None
for it in range(PF_ITERS):
logs = []
for net in order:
old = cur.pop(net, None)
if old:
_pf_mark(occ, old[2], -1)
w = wof[net]
g = hard[w]
halfw = w / 2.0 + CLR
s2, v2, st2 = route_net(g, net, bypad[net], halfw, w, others, logs,
None, mutate=False)
for (q, node, ww) in st2:
s2 = s2 + stub_emit(q, wx(node[1]), wy(node[2]),
[] if _is_launch(q) else others.get(q[6], []),
ww / 2.0, ww, net)
cells = _route_cells(s2, halfw)
_pf_mark(occ, cells, +1)
cur[net] = (s2, v2, cells)
# 一轮结束:给被多个网压着的格点按**超用量**加历史代价
bad = 0
for l in LAY:
oc, hi = occ[l], hist[l]
for o in range(NX * NY):
if oc[o] > 1:
bad += 1
hi[o] = min(250, hi[o] + oc[o] - 1)
print(u' PF 第 %d 轮:冲突格点 %d,没布通 %d(present=%.1f)'
% (it + 1, bad, len(logs), PF_PRESENT[0]))
if best is None or (bad, len(logs)) < best[0]:
best = ((bad, len(logs)), {n: (list(a2), list(b2), set(c2))
for n, (a2, b2, c2) in cur.items()}, list(logs))
if bad == 0 and not logs:
break
PF_PRESENT[0] *= PF_GROW
# 取**冲突最少的那一轮**,不是最后一轮
if best is not None:
cur, logs = best[1], list(best[2])
# ⛔ **格点冲突 ≠ 真违规**。0.25 mm 的格子表示不了 0.35 mm 间距的分离,
# 所以 PF 的冲突数里混着大量**量化假象**(卡在 ~895 下不去就是这个原因)。
# 真正的判据是**精确几何**(和 router_check 同一套)。这里按精确距离筛一遍,
# 只把**真的**压在一起的网拿去重布。
real = _exact_conflicts(cur, pads)
if real:
print(u' 精确几何:真违规涉及 %d 个网 —— %s'
% (len(real), ' '.join(sorted(real)[:10])))
base_cells = {}
for n2, (s2, v2, c2) in cur.items():
if n2 not in real:
base_cells[n2] = (s2, v2, c2)
ns = list(base_s) + [t for n2 in base_cells for t in base_cells[n2][0]]
nv = list(base_v) + [(x, y, n2) for n2 in base_cells
for (x, y, _q) in base_cells[n2][1]]
sub = route_groups(bypad, pads, ns, nv, board_vias, auto,
priority=tuple(real), only=set(real))
logs += sub
del segs[fixed_s:]
del vias[fixed_v:]
segs += ns[len(base_s):]
vias += nv[len(base_v):]
return logs
# ⛔ **绝不能把还有冲突的解下发**(那是真短路)。还剩冲突就逐个丢掉
# 「压着别人最多」的那个网,直到干净为止,丢掉的如实报成没布通。
while True:
occ2 = {l: bytearray(NX * NY) for l in LAY}
for _n, (_s, _v, c2) in cur.items():
_pf_mark(occ2, c2, +1)
blame = {}
for net, (_s, _v, c2) in cur.items():
k = sum(1 for (l, i, j) in c2 if occ2[l][j * NX + i] > 1)
if k:
blame[net] = k
if not blame:
break
worst = max(blame, key=lambda n: (blame[n], wof[n]))
del cur[worst]
logs.append(u'%s: 让路(协商后仍有冲突,整条丢弃)' % worst)
del segs[fixed_s:]
del vias[fixed_v:]
for net, (s2, v2, _c) in cur.items():
segs += s2
vias += [(x, y, net) for (x, y, _n) in v2]
return logs
if __name__ == '__main__':
if '--clear' in sys.argv:
clear_mine()
sys.exit(0)
if '--snapshot-vias' in sys.argv:
v = call("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2500));"
"return (await eda.pcb_PrimitiveVia.getAll()||[])"
" .map(v=>[v.x/%s, v.y/%s, String(v.net||''), (v.diameter||%s)/%s]);"
% (PCB, MIL, MIL, VIA_D * MIL, MIL), timeout=300)
io.open(VIAS_SNAP, 'w', encoding='utf-8').write(json.dumps(v))
print(u'板上过孔快照 %d 个 -> %s' % (len(v), os.path.basename(VIAS_SNAP)))
sys.exit(0)
if ('--replan' in sys.argv or '--pf' in sys.argv) and not os.environ.get('DCDC_ROUTE_SERVER'):
sys.exit(u'⛔ 布线运算只在 10.5.5.13 上跑(CLAUDE.md 硬规则):bash .claude/r2/remote_blade.sh')
# 算一次要好几分钟,结果落盘;--push 默认吃缓存,加 --replan 才重算。
if '--replan' not in sys.argv and os.path.exists(CACHE):
c = json.load(io.open(CACHE, encoding='utf-8'))
segs, vias, logs = c['segs'], c['vias'], c['logs']
print(u'(用缓存 _blade_routes.json)')
else:
# ⛔ **一轮布不完是正常的**:谁先布谁占走廊,后布的就没路。
# 没有 rip-up 的话,最有效的补救是「**这一轮没接上的,下一轮排最前面**」,
# 跑几轮取最好的一轮。实测 30 -> 个位数。
best, prio = None, set()
for it in range(1 if '--pf' in sys.argv else PASSES):
segs, vias, logs = plan_all(prio)
print(u' 第 %d 轮:%d 条没搞定' % (it + 1, len(logs)))
if best is None or len(logs) < len(best[2]):
best = (segs, vias, logs)
if not logs:
break
prio |= set(_msg_net(m) for m in logs)
segs, vias, logs = best
io.open(CACHE, 'w', encoding='utf-8').write(
json.dumps({'segs': segs, 'vias': vias, 'logs': logs}, ensure_ascii=False))
tot = sum(math.hypot(s[3] - s[1], s[4] - s[2]) for s in segs)
print(u'导线 %d 段 / 总长 %.1f mm / 过孔 %d' % (len(segs), tot, len(vias)))
nets = {}
for s in segs:
nets[s[6]] = nets.get(s[6], 0) + 1
print(u'涉及 %d 个网络' % len(nets))
if logs:
print(u'[!] %d 条没搞定:' % len(logs))
for m in logs[:40]:
print(u' ' + m)
if '--push' in sys.argv:
push(segs, vias)