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