# -*- coding: utf-8 -*- u"""载板电源 + 杂散信号布线:**栅格迷宫布线器**(`pcb2_route_dp.py` 的姊妹件)。 /c/Python314/python .claude/pcb2_route_sig.py # 全部算 + 本地校核,不下发 /c/Python314/python .claude/pcb2_route_sig.py --net +5V # 只算这几个网络(可多个) /c/Python314/python .claude/pcb2_route_sig.py --push # 下发(只删本脚本负责的网络) /c/Python314/python .claude/pcb2_route_sig.py --plot out.png # 画一张自己算的图 ## 为什么这里用布线器,差分对却是手写坐标 差分对只有 11 对,但每一对都要管耦合、参考面、偏斜、换层窗口 —— 是**几何设计**, 写死坐标才说得清理由。剩下这 60 来个网络是另一回事:三百多个端点、 大部分是「从 A 走到 B、别撞上东西」,逐条手写既慢又容易漏。 所以这里搭一个**能被同一套 `check()` 验的**布线器:路径怎么来的不重要, 下发前一律过 `pcb2_route_dp.check()`(和差分对同一份净距判据)。 ## 障碍模型(三条都实测过,别改成"看着差不多"的写法) 1. **栅格 0.1 mm,量化误差按最坏算**。障碍按"格心落在铜里"栅格化, 再做欧氏距离变换(`scipy.ndimage.distance_transform_edt`)。 真实铜边最多比"最近的被标记格心"再近 `grid·√2/2 = 0.0708 mm`, 所以判据一律是 `edt >= 净距 + 半线宽 + QERR`,**QERR 不许省**。 2. **本轮自己画的线立刻进障碍表**。网络是一个一个布的, 后布的必须看得见先布的 —— 否则本地校核会在最后一起爆。 3. **通孔焊盘(layer 12)两层都算障碍**,过孔也是。板框按 220×135 R5 内缩 1 mm。 ## BM24 的出线(`STUB`)不交给布线器 `J7n` 是 0.35 mm 间距,相邻焊盘净距只有 0.17 mm —— 0.1 mm 的栅格根本分辨不出来, 而且出线宽度要在 0.11 / 0.20 / 0.28 三档之间切换(算式见 `pcb2_route_dp.W_ARR`)。 所以这一小段**手写**:前排一律往南直出、后排一律往北直出(§25.8 的硬约束), 出了阵列再让布线器接手。GND 脚另外在阵列外并成一条梳子、共用过孔。 """ import io import json import math import os import sys import time import numpy as np from scipy import ndimage HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from pcb2_route_dp import (geom, check, expand, netof, push, MIL, PCB, L_TOP, L_BOT, CLR, VIA_D, VIA_H, seg_rect, seg_pt, seg_seg, chamfer_routes, sharp_corners) from pcb2_place import SITES, to_board, BW, BH, PW, PH GRID = 0.1 # 栅格步长 mm QERR = GRID * math.sqrt(2) / 2 # 栅格量化误差上界 0.0708 mm,判据里必须加上 EDGE = 1.0 # 铜离板边的最小距离 CORNER_R = 5.0 VIA_R = VIA_D / 2.0 HOLE_CLR = 0.30 # 钻孔到钻孔(规则 otherClearance.hole2Hole),**不分网络** VIA_COST = 2.5 # 换一次层折算成多少 mm 路程(压住"没事就打孔") TURN_COST = 0.30 # 拐一次弯折算成多少 mm(换来长直段,少几百条碎线) BBOX_PAD = 10.0 # 每次连线的搜索窗口比两端包围盒大多少 ASTAR_W = 1.3 # 加权 A*:h 乘这个数。放弃严格最优换速度 MAX_CELLS = 1200000 # 单层格数超过这个才把栅格放粗一档(只有跨半块板的网络会碰到) W_ARR = 0.11 # 还在 BM24 焊盘阵列里那一段 W_ESC = 0.20 # 刚出阵列 W_SIG = 0.20 # 一般信号。**不是 0.25** —— Hub 的卫星圈是 1.2 mm 网格上的 # 0402,缝只有 1.2 − 0.541 = 0.659 mm, # 0.25 的线要 2×(0.20+0.125) = 0.65,量化余量一进去就过不去; # 0.20 要 0.60,留 0.06 的余量,实测这一档才布得通。 # XTAL1/2 必须 0.20:Hub 南侧竖井只有 2.16 mm,要并排过 4 根 # (XTAL ×2 + UP_DP/DM ×2)。0.25 的话 0.2 净距下正好塞不进去 —— 实测布不通。 # `+5V` 0.60(不是 0.80):§25.10 重排逐针分配之后它是**10 针分散取电**, # 每个入口只喂本站的 U2n + C70n、段电流 <= 0.6 A,0.60 mm 外层 1 oz 可载 ~2 A。 # 0.80 在排母到服务带那条已经站满 EN/POGO 线的走廊里挤不过去(实测五处布不通)。 WIDTH = {'+5V': 0.60, '+3V3_HUB': 0.35, 'XTAL1': 0.20, 'XTAL2': 0.20} for _n in range(1, 6): WIDTH['VBUS%d' % _n] = 0.50 def width_of(net): return WIDTH.get(net, W_SIG) # ══════════════════ BM24 出线(手写,站内坐标 u/v)══════════════════ # # ⚠️ **2026-09-06(第十三轮)整段按 v5 重写。** 老的一版是 v3/v4 的坐标, # v5 把 J7n 从 rot 180 改成 rot 0 又挪了位置,站内坐标整体差一个仿射映射 # (`u' = u − 5.00`,`v' = 32.00 − v`,v 轴是**镜像**的)。 # 照抄映射结果也不行 —— v 一镜像,「前排南侧那条缝」跑到了北侧, # 而北侧现在站着 R76n/R77n;净距必须拿 v5 的实际障碍重算。下面全部是实测值。 # # 焊盘几何(`fp_bm24.py`,`--dump` 回读逐站核过,五个站完全同构): # 后排(奇数脚,**全是 GND**)v = 33.940,u 48.30~56.70 # 中间两个大脚 v = 35.000,pin51(VBUS) u 46.47 / pin52(GND) u 58.53 # 前排(偶数脚) v = 36.060,u 48.30~56.70 # 两排都是 hh = 0.24;信号脚 hw = 0.0902,脚距 0.35 ⇒ 相邻焊盘边到边 0.1696 J7_BACK_V, J7_MID_V, J7_FRONT_V = 33.940, 35.000, 36.060 J7_PAD_HH = 0.24 # 出线换挡的 v:离焊盘外沿 0.35,0.20 的线到相邻脚的焊盘角还有余量 J7_FRONT_ESC = J7_FRONT_V + J7_PAD_HH + 0.35 # 36.65,往**外**(+v) J7_BACK_ESC = J7_BACK_V - J7_PAD_HH - 0.35 # 33.35,往**外**(−v) J7_FRONT_IN = J7_FRONT_V - J7_PAD_HH - 0.35 # 35.47,前排往**内**(进中央通道) # GND 脚的 u(`--check` 会逐站断言) J7_GND_FRONT = [48.30, 49.35, 50.05, 50.75, 52.15] # pin 2/8/12/16/24 J7_GND_BACK = [48.30, 51.45, 52.15, 53.20, 54.60, 55.30, 56.35] # pin 1/19/23/29/37/41/47 J7_GND_BIG = 58.53 # pin 52,0.49×0.76 大焊盘 J7_THERM_U, J7_CC1_U = 50.40, 51.80 # pin 14 口温 / pin 22 CC1 # ══ v5 的车道分配(和 v4 换了个方向,理由在下面)══ # # ┌ 前排外侧 v > 36.30 ── **只给 THERM / CC1**(R76n/R77n 就在正北 v 38.0) # │ 中央通道 v ∈ [34.18, 35.82],净高 1.64 ── **前排 5 个 GND 的梳子** # └ 后排外侧 v < 33.70 ── **后排 7 个 GND 的梳子**,一路空到 v 26,最宽敞 # # 为什么和 v4 相反:v5 的**后排 25 个脚全是 GND**(没有信号脚要往外挤), # 而 THERM(pin14)/CC1(pin22) 都在前排、目标 R76n/R77n 也在前排外侧 —— # 让它们各走各的最短路,中央通道就整条空出来给前排 GND,不再有 # v4 那个「东行车道被竖着穿过通道的 GND 线切断」的死结。 # # 中央通道梳子 v = 35.00(正中):到两排焊盘内沿各 0.82 # 0.30 的线:0.82 − 0.15 = 0.67 ✔ 过孔:0.82 − 0.305 = 0.515 ✔(要 0.50) # ⚠️ 梳子只走到 u 52.15(最东那个前排 GND 脚),离 pin52 焊盘西沿 58.285 还有 6 mm, # 所以通道里这条梳子和 pin51/pin52 两个大脚互不相干。 J7_MID_COMB = J7_MID_V # 35.00 J7_MID_VIA_U = [48.30, 49.35, 50.75, 52.15] # 相邻间距 1.05/1.40/1.40 >= 0.81 ✔(跳过 50.05) # # 后排外侧梳子 v = 33.10:到后排焊盘外沿 33.70 有 0.60 # 0.30 的线余 0.45 ✔ 过孔余 0.295 ✔(要 0.20;离焊盘 0.60 − 0.305 = 0.295) # ⚠️ 这一带在**尾插排线车道 `TAIL_LANE` 里**(u 44.6~62.6 / v −1.2~36.3)。 # 车道的约束是「不放元件」,走线和过孔是允许的(排线平贴着盖过去)。 J7_BACK_COMB = 33.10 J7_BACK_VIA_U = [48.30, 52.15, 53.20, 54.60, 56.35] # 间距 3.85/1.05/1.40/1.75 >= 0.81 ✔ # # pin52(GND 大焊盘,u 58.53 / v 35.00):往**东**平着出去打孔, # 离 J7n 最东那列焊盘 (u 56.79) 2.8、离手机定位柱 PnH3 (u 61.90, v 33.70, Ø2.5) 1.05 ✔ J7_PIN52_VIA_U = 59.60 W_COMB = 0.30 W_LANE = 0.20 # ── THERM / CC1:都往前排外侧出,THERM 走顶层跳过 CC1 ── # # 两个脚一个要往东(THERM → R77n.1 u 54.367)、一个要往北(CC1 → R76n.1 u 51.800), # **在 u ≈ 51.8 必然交叉**,同层无解。CC1 是最短的那根,让它全程底层直上; # THERM 打两个孔从顶层跨过去。 # 西孔 (50.95, 37.20):离 `*_DPJ` 的换层孔 (50.00, 37.00) **0.97 ≥ 0.81**(孔到孔); # 离 J7n.16/18 焊盘 0.86;离 R76n.1 焊盘框 0.78 ✔ # 东孔 (54.37, 37.20):离 J7n.38 焊盘 0.90;R77n.1 是同网,不设限 ✔ # 顶层那一段 v = 37.20:顶层在 J7n 这一带**一根线都没有**(`*_DPJ` 的顶层小跳线在 u ≤ 50.0) # R76n 的中心特意取 52.233,好让 pin1 正落在 u 51.800 = J7n.22 正北 ⇒ CC1 是一根直线。 # ⚠️ THERM 出阵列后**第一次拐弯的 v 只有 0.053 mm 的窗口**,而且那一段必须一直保持 0.11: # 往下:离前排焊盘外沿 36.30 要 w/2 + 0.20 ⇒ v >= 36.555 # 往上:离 `*_DPJ` 的换层孔 (50.00, 37.00) 要 0.305 + w/2 + 0.20 = 0.560, # 而它在 u 上只差 0.40 ⇒ |v − 37.00| >= 0.392 ⇒ v <= 36.608 # 取窗口正中 36.58(上下各余 0.027)。**换成 0.15 的线窗口只剩 0.005,0.20 直接无解** —— # 2026-09-06 第一版写 0.20 / 拐点 36.65,五个站一起报「线-过孔 净距 0.127」。 J7_THERM_TURN_V = 36.58 J7_THERM_VIA = [(50.80, 37.30), (54.367, 37.30)] J7_THERM_KINK = (50.80, 36.98) # 45° 斜段的终点,之后竖着上到换层孔 J7_THERM_LANE_V = 37.30 J7_R76_PIN1_U, J7_R77_PIN1_U = 51.800, 54.367 J7_R_PAD_V = 38.000 # R76n/R77n 焊盘中心 v(`--check` 会核) J7_R_ENTER_V = 37.90 # 进焊盘的落点(焊盘 v 37.730~38.270) def _stub(u, v_pad, v_esc, w_out=W_ESC): u"""BM24 一个脚的直出段(站内坐标):焊盘 -> 阵列外。""" return [(u, v_pad, L_TOP, W_ARR), (u, v_esc, L_TOP, w_out)] def bm24_stub(n, u, v_pad, v_esc, w_out=W_ESC): u"""同上,映射到第 n 站的板坐标。""" out = [] for u_, v_, lay, ww in _stub(u, v_pad, v_esc, w_out): bx, by, _ = to_board(n, u_, v_, 0) out.append((round(bx, 3), round(by, 3), lay, ww)) return out # ══════════════════ 板框 ══════════════════ _CORNERS = ((CORNER_R, CORNER_R), (BW - CORNER_R, CORNER_R), (CORNER_R, BH - CORNER_R), (BW - CORNER_R, BH - CORNER_R)) def board_mask(XX, YY, m=EDGE): u"""格心在板内(含 R5 圆角、内缩 m)的布尔数组。""" ok = (XX >= m) & (XX <= BW - m) & (YY >= m) & (YY <= BH - m) for cx, cy in _CORNERS: sx = 1.0 if cx < BW / 2 else -1.0 sy = 1.0 if cy < BH / 2 else -1.0 inc = ((XX - cx) * sx < 0) & ((YY - cy) * sy < 0) ok &= ~(inc & (np.hypot(XX - cx, YY - cy) > CORNER_R - m)) return ok # ══════════════════ 形状(障碍 / 本网铜都用这一套)══════════════════ def sh_rect(x, y, hw, hh, layers): return ('rect', x, y, hw, hh, layers) def sh_via(x, y, r=VIA_R): return ('via', x, y, r, 0, (L_TOP, L_BOT)) def sh_seg(x0, y0, x1, y1, w, lay): return ('seg', x0, y0, x1, y1, w / 2.0, (lay,)) def shapes_of(pads, vias, lines): u"""把 geom() 的三张表变成障碍表:`[(形状, 网络), ...]`。 ⚠️ **网络必须跟着形状走**:布某个网络时它自己的铜不能算障碍, 否则连焊盘都出不来(第一版就是漏了这条,25 个种子格一个邻居都扩不出去)。 """ out = [] for p in pads: lay = (L_TOP, L_BOT) if p['lay'] == 12 else (p['lay'],) out.append((sh_rect(p['x'], p['y'], p['hw'], p['hh'], lay), p['net'])) for v in vias: out.append((sh_via(v['x'], v['y'], v['r']), v['net'])) for l in lines: out.append((sh_seg(l['x0'], l['y0'], l['x1'], l['y1'], l['w'], l['lay']), l['net'])) return out # ══════════════════ 栅格场 ══════════════════ class Field(object): u"""一个搜索窗口里的两层障碍距离场。 `edt[L][j, i]` = 该格心到**最近的异网铜**的距离(mm,含量化误差 QERR)。 判据统一是 `edt >= 净距 + 半线宽 + QERR`。 """ #: 搜索窗口外面还要多留一圈**只标障碍、不许走线**的边(mm)。见 __init__ 里的注释。 RIM = 1.2 def __init__(self, obstacles, bbox, skip_net=None, grid=GRID): x0, y0, x1, y1 = bbox self.g = grid self.qerr = grid * math.sqrt(2) / 2 # ⚠️ **窗口边上必须留一圈墙。** 障碍是裁到窗口里再栅格化的, # 完全落在窗口外的铜一格都不标 —— 于是贴着窗口边走的线看不见它。 # 2026-09-06 实测:`SCL` 的搜索窗口从 x=44.02 起,J31.6 的焊盘框 # (42.10~43.80) 整个在窗外,布线器就在 (44.10, 67.40) 打了个孔, # 离那个焊盘只有 0.134 —— 栅格判据全过,`check()` 才报出来。 # 做法:数组往外扩 RIM,障碍照标,**再把外面这一圈整体标成占用**。 # 可走的区域和原来一模一样,但现在看得见窗外 RIM 以内的铜。 rim = self.RIM x0 -= rim; y0 -= rim; x1 += rim; y1 += rim self.x0 = math.floor(x0 / grid) * grid self.y0 = math.floor(y0 / grid) * grid self.nx = int(round((x1 - self.x0) / grid)) + 2 self.ny = int(round((y1 - self.y0) / grid)) + 2 # ⚠️ **层集合不能写死 (L_TOP, L_BOT)**。下板从 2026-09-06 起在 **Inner2(16)** # 上有真走线了(`pcb1_i2c_l3.py` 的 I²C 干线),障碍表里就会出现 layer 16 —— # 写死两层的话这里直接 `KeyError: 16`;就算滤掉,**新过孔也不会对内层那些线 # 做净距判断**(`viafree` 是 min(edt[TOP], edt[BOT])),孔会直接压在内层干线上。 # 这里按障碍表里真正出现过的层建数组;只有 1/2 层时行为和以前一模一样。 self.lays = sorted(set([L_TOP, L_BOT]) | set(L for s, _n in obstacles for L in s[-1])) occ = {L: np.zeros((self.ny, self.nx), bool) for L in self.lays} # ⚠️ 障碍要**先胀 QERR 再栅格化**,见 `_mark` 的注释。 for s, net in obstacles: if net == skip_net: continue for L in s[-1]: self._mark(occ[L], s, grow=self.qerr) # 板框外一律不许有铜 xs = self.x0 + np.arange(self.nx) * grid ys = self.y0 + np.arange(self.ny) * grid XX, YY = np.meshgrid(xs, ys) out = ~board_mask(XX, YY) # 外圈那一圈墙(障碍已经标完了,这里只是禁止走线) nb = max(1, int(round(rim / grid))) wall = np.zeros((self.ny, self.nx), bool) wall[:nb, :] = True; wall[-nb:, :] = True wall[:, :nb] = True; wall[:, -nb:] = True out |= wall for L in self.lays: occ[L] |= out self.occ = occ self.edt = {L: ndimage.distance_transform_edt(~occ[L], sampling=grid).astype(np.float32) for L in self.lays} # 过孔是通孔 ⇒ **每一层**都要让开(含 Inner2 上的真走线) self.viafree = np.minimum.reduce([self.edt[L] for L in self.lays]) # ⚠️ **钻孔间距不分网络**。铜可以重叠,孔不行 —— 规则 otherClearance.hole2Hole = 0.30。 # `viafree` 是「到**异网**铜的距离」,同网的过孔在它眼里根本不存在, # 于是布线器会紧挨着 `pcb2_dp_long` 已经打好的孔再打一个: # 2026-09-06 实测 (92.500, 70.000) 与 (92.400, 70.500) 中心只隔 0.510, # DRC 报「钻孔 到 钻孔 8.1mil,应 >= 11.8mil」,而净距校核一声不吭。 occh = np.zeros((self.ny, self.nx), bool) for s, _net in obstacles: if s[0] == 'via': self._mark(occh, ('via', s[1], s[2], VIA_H / 2.0, 0, s[5]), grow=self.qerr) occh |= out self.holefree = ndimage.distance_transform_edt( ~occh, sampling=grid).astype(np.float32) # ── 栅格化 ── def _idx(self, x, y): return (int(round((x - self.x0) / self.g)), int(round((y - self.y0) / self.g))) def _win(self, xa, xb, ya, yb): i0 = max(0, int(math.ceil((xa - self.x0) / self.g))) i1 = min(self.nx - 1, int(math.floor((xb - self.x0) / self.g))) j0 = max(0, int(math.ceil((ya - self.y0) / self.g))) j1 = min(self.ny - 1, int(math.floor((yb - self.y0) / self.g))) return i0, i1, j0, j1 def _mark(self, mask, s, grow=0.0): u"""把一个形状打进布尔栅格。 ⚠️ **`grow` 不是保险丝,是判据的一部分。** 只标"格心落在铜里"的格时, 铜的边界到最近的被标记格心可以差到将近一整格(实测:一个 Ø0.61 的过孔 边缘在 x=52.781,最近的格心在 52.7,差 0.081 > √2/2·0.1), 于是 `edt` 会**高估** 0.08 mm,净距校核就在下发前一刻才炸。 把障碍先胀 `QERR = g·√2/2` 再栅格化,任何铜边界点周围 半径 QERR 内一定有被标记的格 ⇒ `真实净距 >= edt − QERR` 严格成立, 判据 `edt >= 净距 + 半线宽 + QERR` 才真的是充分条件。 """ k = s[0] if k == 'rect': _, x, y, hw, hh, _ = s hw, hh = hw + grow, hh + grow i0, i1, j0, j1 = self._win(x - hw, x + hw, y - hh, y + hh) if i0 <= i1 and j0 <= j1: mask[j0:j1 + 1, i0:i1 + 1] = True return if k == 'via': _, x, y, r, _, _ = s r = r + grow i0, i1, j0, j1 = self._win(x - r, x + r, y - r, y + r) if i0 > i1 or j0 > j1: return xs = self.x0 + np.arange(i0, i1 + 1) * self.g ys = self.y0 + np.arange(j0, j1 + 1) * self.g XX, YY = np.meshgrid(xs, ys) mask[j0:j1 + 1, i0:i1 + 1] |= ((XX - x) ** 2 + (YY - y) ** 2) <= r * r return _, xa, ya, xb, yb, h, _ = s h = h + grow i0, i1, j0, j1 = self._win(min(xa, xb) - h, max(xa, xb) + h, min(ya, yb) - h, max(ya, yb) + h) if i0 > i1 or j0 > j1: return xs = self.x0 + np.arange(i0, i1 + 1) * self.g ys = self.y0 + np.arange(j0, j1 + 1) * self.g XX, YY = np.meshgrid(xs, ys) dx, dy = xb - xa, yb - ya L2 = dx * dx + dy * dy if L2 < 1e-12: d2 = (XX - xa) ** 2 + (YY - ya) ** 2 else: t = np.clip(((XX - xa) * dx + (YY - ya) * dy) / L2, 0.0, 1.0) d2 = (XX - (xa + t * dx)) ** 2 + (YY - (ya + t * dy)) ** 2 mask[j0:j1 + 1, i0:i1 + 1] |= d2 <= h * h def mask_of(self, shapes): m = {L: np.zeros((self.ny, self.nx), bool) for L in self.lays} for s in shapes: for L in s[-1]: self._mark(m[L], s) return m # ── 判据 ── # `relax` = **本网自己的焊盘**所占的格。焊盘里本来就有铜, # 线从焊盘中心出来的那一小段落在焊盘内部,不该拿净距去卡它 # (0402 的两个焊盘只隔 0.5 mm,卡下去等于每个器件都出不了线)。 # 只放宽焊盘,不放宽本网的**导线** —— 导线本身是合法的,它所在的格 edt 也够。 def set_relax(self, shapes): self.relax = self.mask_of(shapes) def free(self, lay, w): f = self.edt[lay] >= CLR + w / 2.0 + self.qerr if getattr(self, 'relax', None) is not None: f = f | self.relax[lay] return f def via_ok(self): return ((self.viafree >= CLR + VIA_R + self.qerr) & (self.holefree >= VIA_H / 2.0 + HOLE_CLR + self.qerr)) def clear_seg(self, x0, y0, x1, y1, lay, w): u"""一段任意角度的线是否安全(沿线采样,EDT 是 1-Lipschitz 所以可加采样余量)。""" need = CLR + w / 2.0 + self.qerr L = math.hypot(x1 - x0, y1 - y0) n = max(2, int(L / (self.g / 2.0)) + 1) step = L / (n - 1) if n > 1 else 0.0 need += step / 2.0 rl = getattr(self, 'relax', None) for k in range(n): t = k / float(n - 1) if n > 1 else 0.0 i, j = self._idx(x0 + t * (x1 - x0), y0 + t * (y1 - y0)) if not (0 <= i < self.nx and 0 <= j < self.ny): return False if self.edt[lay][j, i] < need and not (rl is not None and rl[lay][j, i]): return False return True # ══════════════════ A* ══════════════════ DIRS = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)] def _edges(ok, dx, dy, nx, ny, base): u"""某一层上、沿 (dx, dy) 这个方向的所有合法走步 -> (起点索引, 终点索引)。 斜着走要**额外**要求两个正交邻格也是通的 —— 否则线会从两个障碍的夹角里"钻"过去。 """ xs0, xs1 = max(0, -dx), nx - max(0, dx) ys0, ys1 = max(0, -dy), ny - max(0, dy) if xs1 <= xs0 or ys1 <= ys0: e = np.empty(0, np.int64) return e, e m = ok[ys0:ys1, xs0:xs1] & ok[ys0 + dy:ys1 + dy, xs0 + dx:xs1 + dx] if dx and dy: m = m & ok[ys0:ys1, xs0 + dx:xs1 + dx] & ok[ys0 + dy:ys1 + dy, xs0:xs1] jj, ii = np.nonzero(m) jj = jj.astype(np.int64) + ys0 ii = ii.astype(np.int64) + xs0 return base + jj * nx + ii, base + (jj + dy) * nx + (ii + dx) def route_grid(f, src, tgt, w): u"""多源多汇最短路。`src`/`tgt` 是 {层: 布尔数组}。返回 [(i, j, 层), ...] 或 None。 代价 = 路程(mm) + 每次换层 `VIA_COST`。 ## 为什么是 scipy 的 Dijkstra,不是自己写的 A* 第一版是纯 Python 的加权 A*(还带拐弯惩罚)。**跨半块板的网络跑不动** —— `VBUS_UP`(J23 → Hub,95 mm)在 0.1 mm 栅格上是 140 万个节点, 两分钟没跑完,放粗到 0.2 mm 又因为净距判据跟着变严而布不通。 改成把整个栅格建成稀疏图交给 `scipy.sparse.csgraph.dijkstra`(C 实现、多源、 带前驱),同一个网络降到秒级。 代价是**丢了拐弯惩罚**(Dijkstra 表达不了"到达方向"), 最短路会退化成楼梯。所以 `polish()` 必须做「最远可视 L 形合并」把楼梯收回来 —— 这两件事是配套的,只改一半会得到几百条碎线。 """ from scipy.sparse import coo_matrix from scipy.sparse.csgraph import dijkstra nx, ny, g = f.nx, f.ny, f.g LAY = [L_TOP, L_BOT] ok = [f.free(L, w) for L in LAY] N = nx * ny rows, cols, vals = [], [], [] for li in (0, 1): for dx, dy in ((1, 0), (0, 1), (1, 1), (1, -1)): fr, to = _edges(ok[li], dx, dy, nx, ny, li * N) if len(fr): rows.append(fr) cols.append(to) vals.append(np.full(len(fr), g * (math.sqrt(2) if dx and dy else 1.0), np.float32)) vm = f.via_ok() & ok[0] & ok[1] jj, ii = np.nonzero(vm) if len(jj): idx = jj.astype(np.int64) * nx + ii.astype(np.int64) rows.append(idx) cols.append(idx + N) vals.append(np.full(len(idx), VIA_COST, np.float32)) if not rows: return None G = coo_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(2 * N, 2 * N)).tocsr() def flat(mask): out = [] for li in (0, 1): jj, ii = np.nonzero(mask[LAY[li]] & ok[li]) out.append(jj.astype(np.int64) * nx + ii.astype(np.int64) + li * N) return np.concatenate(out) si, ti = flat(src), flat(tgt) if not len(si) or not len(ti): return None D, pred, _srcs = dijkstra(G, directed=False, indices=si, min_only=True, return_predecessors=True) d = D[ti] k = int(np.argmin(d)) if not np.isfinite(d[k]): return None out = [] idx = int(ti[k]) seen = set() while idx >= 0 and idx not in seen: seen.add(idx) li, r = divmod(idx, N) j, i = divmod(r, nx) out.append((i, j, LAY[li])) idx = int(pred[idx]) out.reverse() return out # ══════════════════ 路径整形 ══════════════════ def _runs(cells): u"""8 邻域格路 -> 顶点表(同层的共线段合并;换层处断开)。""" pts = [cells[0]] for k in range(1, len(cells) - 1): i0, j0, l0 = cells[k - 1] i1, j1, l1 = cells[k] i2, j2, l2 = cells[k + 1] if l0 != l1 or l1 != l2: pts.append(cells[k]) continue if (i1 - i0, j1 - j0) != (i2 - i1, j2 - j1): pts.append(cells[k]) pts.append(cells[-1]) return pts def _l_shapes(a, b): u"""A->B 的 45°/90° 两段折线(两种拐法);本来就是 8 方向的直接返回一段。""" (x0, y0), (x1, y1) = a, b dx, dy = x1 - x0, y1 - y0 if abs(dx) < 1e-9 or abs(dy) < 1e-9 or abs(abs(dx) - abs(dy)) < 1e-9: return [[a, b]] m = min(abs(dx), abs(dy)) sx = math.copysign(1.0, dx) sy = math.copysign(1.0, dy) return [[a, (x0 + sx * m, y0 + sy * m), b], # 先斜后正 [a, (x1 - sx * m, y1 - sy * m), b]] # 先正后斜 def polish(f, pts, w): u"""顶点表 -> 少而长的 45°/90° 段。逐层做,换层点原样保留。""" out = [] seg_start = 0 for k in range(len(pts)): last = (k == len(pts) - 1) if not last and pts[k + 1][2] == pts[k][2]: continue chunk = pts[seg_start:k + 1] lay = chunk[0][2] xy = [(f.x0 + i * f.g, f.y0 + j * f.g) for i, j, _ in chunk] out.append((lay, _polish_one(f, xy, lay, w))) seg_start = k + 1 return out def _polish_one(f, xy, lay, w): if len(xy) <= 2: return xy def try_j(i, j): for cand in _l_shapes(xy[i], xy[j]): if all(f.clear_seg(a[0], a[1], b[0], b[1], lay, w) for a, b in zip(cand, cand[1:])): return cand return None res = [xy[0]] i = 0 n = len(xy) while i < n - 1: # 先指数外推找到"最后一个还通的 j"的量级,再二分收敛。 # 逐个从远往近试是 O(n²) 次几何校核,长线(上千个格)会卡住。 lo, best = i + 1, try_j(i, i + 1) step = 1 while lo + step <= n - 1: c = try_j(i, lo + step) if c is None: break lo, best = lo + step, c step *= 2 hi = min(n - 1, lo + step) while lo + 1 < hi: mid = (lo + hi) // 2 c = try_j(i, mid) if c is None: hi = mid else: lo, best = mid, c if best is None: res.append(xy[i + 1]) i += 1 continue res.extend(best[1:]) i = lo # 去掉共线冗余点 out = [res[0]] for k in range(1, len(res) - 1): ax, ay = out[-1] bx, by = res[k] cx, cy = res[k + 1] if abs((bx - ax) * (cy - by) - (by - ay) * (cx - bx)) > 1e-9: out.append(res[k]) out.append(res[-1]) return out # ══════════════════ 网络布线 ══════════════════ class Comp(object): u"""一个网络里已经连通的一坨铜。 `center` 只在"这一坨就是一个焊盘"时非空,值是**焊盘中心**。 ⚠️ 这一条不是锦上添花:`half()` 给圆焊盘返回的是**外接矩形**, 路径若停在矩形角上(Ø1.7 的排母脚差 0.32 mm)实际上根本没搭到铜。 所以单焊盘的起终点一律钉在圆心,A* 的种子/目标也只取圆心那一格。 """ def __init__(self, shapes, anchor, center=None, layers=None): self.shapes = list(shapes) self.anchor = anchor # (x, y) 只用来算"离谁最近" self.center = center self.layers = layers def merge(self, other, extra): self.shapes += other.shapes + extra self.center = None self.layers = None FINE_PAD = 0.31 # 焊盘短边半长 < 这个数就算"细间距",出线要先直出 STUB_EXT = (0.30, 0.20, 0.12) # 直出到焊盘外多少(依次退让) _CENTROID = {} def _centroid(ref, pads_all): if ref not in _CENTROID: ps = [p for p in pads_all if p['ref'] == ref] _CENTROID[ref] = (sum(p['x'] for p in ps) / len(ps), sum(p['y'] for p in ps) / len(ps)) if ps else None return _CENTROID[ref] def clear_exact(x0, y0, x1, y1, lay, w, net, obstacles): u"""一段线到**异网铜**的真实最小净距(不走栅格,逐个形状精确算)。""" h = w / 2.0 best = 1e9 a, b = (x0, y0), (x1, y1) for s, n in obstacles: if n == net or lay not in s[-1]: continue if s[0] == 'rect': d = seg_rect(a, b, s[1], s[2], s[3], s[4]) - h elif s[0] == 'via': d = seg_pt(x0, y0, x1, y1, s[1], s[2]) - s[3] - h else: d = seg_seg(a, b, (s[1], s[2]), (s[3], s[4])) - s[5] - h if d < best: best = d return best ELONGATED = 1.3 # 长短边之比超过这个,就认为焊盘"有方向" def entry_stub(q, pads_all, obstacles, net, w, all_cands=False): u"""细间距焊盘的**直出段**:从焊盘中心直着走出焊盘外一小截,路由器从这一截接手。 不这么做的话最短路会从焊盘的**侧面**或**角上**离开: QFN(0.5 脚距) 和 0402 的邻脚就在 0.2~0.4 mm 外,一出来就违规。 上一版 `XTAL1` 进 U32.61 是这么卡死的(布不通), `+3V3_HUB` 进 U32.64 更糟 —— 它从**西侧**横着挤进去,离 U32.63 只剩 0.10 mm。 ## 方向怎么定(两条规则,缺一不可) 1. **焊盘明显是长条的(长短边 ≥ 1.3)⇒ 沿长轴出**。这是封装设计者的意思: 长轴指向元件外面。QFN 南边一排是 0.30×0.851,J23 的信号脚是 1.30×0.30。 2. **接近方的(0402 焊盘 0.50×0.541、晶振 1.40×1.20)⇒ 沿"背离元件形心"那根轴出**。 只按第 2 条会错得很难看:`J23.A5` 上下各有一个焊盘、左右空着, 而形心被四个屏蔽脚拉到东边 ⇒ 判成"往南出",**正好撞在 B8 焊盘上**, 于是没有出线段,两个 2 mm 远的焊盘反而布不通。 """ if q['lay'] == 12 or min(q['hw'], q['hh']) >= FINE_PAD: return [] if all_cands else None cen = _centroid(q['ref'], pads_all) if cen is None: return [] if all_cands else None dx, dy = q['x'] - cen[0], q['y'] - cen[1] if q['hw'] >= q['hh'] * ELONGATED: axes = ('x', 'y') elif q['hh'] >= q['hw'] * ELONGATED: axes = ('y', 'x') else: axes = ('x', 'y') if abs(dx) >= abs(dy) else ('y', 'x') # 候选:主轴背离形心 -> 次轴背离形心 -> 主轴朝向形心(万不得已) cands = [] for ax in axes: d = dx if ax == 'x' else dy cands.append((ax, 1.0 if d >= 0 else -1.0)) cands.append((axes[0], -cands[0][1])) ws = min(w, 2 * min(q['hw'], q['hh'])) out = [] for ax, sgn in cands: for ext in STUB_EXT: if ax == 'x': end = (q['x'] + sgn * (q['hw'] + ext), q['y']) else: end = (q['x'], q['y'] + sgn * (q['hh'] + ext)) if clear_exact(q['x'], q['y'], end[0], end[1], q['lay'], ws, net, obstacles) >= CLR: out.append((end, ws)) break # 同一个方向只留最长的那一档 if not out: return [] if all_cands else None return out if all_cands else out[0] def plan_stubs(order, pads_all, obstacles, pre_pads): u"""**先给所有网络的所有焊盘留好出线段,再开始布线。** 这一步不是优化,是必需的。第一版是"轮到哪个网络才现画它的出线段", 于是先布的网络会把后布的网络**出焊盘的那 0.6 mm 占掉**: · `VBUS2` 抄近路斜穿 U25 东侧 ⇒ `+5V` 出 U25.1 只剩 −0.02 mm, 出线段只好掉头往**西**(钻进器件内部),整条 `+5V` 就此布不通; · `+3V3_HUB` 从西边竖着切进 U42.5 ⇒ 隔壁 U42.6 的 `EDL_EN2` 出不来, 而这两个焊盘只隔 0.95 mm。 先把出线段全部落地当障碍,等于给每个焊盘**预留了逃逸通道**, 这是布线器的常规做法(escape routing 先行)。 """ stubs, alts = {}, {} for net in order: w = width_of(net) for q in pads_all: key = (q['ref'], q['num']) if q['net'] != net or key in pre_pads: continue cand = entry_stub(q, pads_all, obstacles, net, w, all_cands=True) if not cand: continue alts[key] = cand end, ws = cand[0] stubs[key] = (end, ws) # 只**预留**首选方向。备选方向不占位 —— 它们只在首选走不通时才拿出来试, # 那时候本来就是"能捡到什么算什么"。 obstacles.append((sh_seg(q['x'], q['y'], end[0], end[1], ws, q['lay']), net)) return stubs, alts def route_net(net, pads_all, obstacles, w=None, pre=None, verbose=True, stubs=None, alts=None): u"""把一个网络布通。返回 [路径, ...](每条是 `expand()` 认的点表)。 `pre` 是手写前置段(BM24 出线 / GND 梳子),它们**先算进本网铜**, 然后端点参与后面的最小生成树。 `stubs` 是 `plan_stubs()` 预留好的出线段(已经在障碍表里了,这里只取用)。 `alts` 是同一个焊盘的**备选出线方向**:首选方向布不通时换一个再试。 ⚠️ 为什么需要备选:出线方向是按「背离元件形心」定的,那只是个启发式。 `R709.1`(VBUS_UP)在 Hub 卫星圈 A/B 两圈之间,背离形心 = **朝东**, 正好扎进 A/B 圈之间那条 0.268 mm 的死胡同 —— 出线段本身是合法的(只有 0.59 mm 长), 所以 `plan_stubs` 挑中了它,然后整个网络就再也接不上这个焊盘。 实测:只布这一个网络、整块板别的线都不存在,照样布不通 ⇒ 不是拥挤,是方向选错了。 """ w = w or width_of(net) paths = list(pre or []) comps = [] used = set() for p in pre or []: shp = [] for x0, y0, x1, y1, lay, ww in expand(p)[0]: shp.append(sh_seg(x0, y0, x1, y1, ww, lay)) for x, y in expand(p)[1]: shp.append(sh_via(x, y)) # ⚠️ 手写前置段也必须进障碍表。漏了这一条的后果是**后面的网络看不见它们** —— # BM24 那五组车道是最先落的铜,`POGO2_PWR` 却从中间穿了过去, # 而且只有最后一步 `check()` 才报出来(−0.200,实打实压在一起)。 obstacles.extend((sp, net) for sp in shp) for q in pads_all: if q['net'] != net: continue if seg_rect((p[0][0], p[0][1]), (p[0][0], p[0][1]), q['x'], q['y'], q['hw'], q['hh']) < 1e-6: used.add((q['ref'], q['num'])) comps.append(Comp(shp, (p[-1][0], p[-1][1]))) for q in pads_all: if q['net'] != net or (q['ref'], q['num']) in used: continue lay = (L_TOP, L_BOT) if q['lay'] == 12 else (q['lay'],) rect = sh_rect(q['x'], q['y'], q['hw'], q['hh'], lay) st = (stubs or {}).get((q['ref'], q['num'])) if st is None: comps.append(Comp([rect], (q['x'], q['y']), center=(q['x'], q['y']), layers=lay)) continue end, ws = st paths.append([(round(q['x'], 4), round(q['y'], 4), q['lay'], ws), (round(end[0], 4), round(end[1], 4), q['lay'], ws)]) seg = sh_seg(q['x'], q['y'], end[0], end[1], ws, q['lay']) c = Comp([rect, seg], end, center=end, layers=lay) c.pad, c.rect, c.stub_i = q, rect, len(paths) - 1 # 换出线方向时要用 c.stub_alts = [a for a in (alts or {}).get((q['ref'], q['num']), [])[1:]] comps.append(c) if len(comps) < 2: return paths # `relax`(允许贴着走的自网焊盘)**只放没有出线段的那些**。 # 有出线段的焊盘已经有了合法的进出口,再把整个焊盘放开的话, # 最短路会**从焊盘的另一头钻进来**:实测 `+3V3_HUB` 就这么顺着 R715.2 的 # 焊盘北沿爬进去,那一小段铜离 R715.1 只有 0.175 mm(0402 自己两个焊盘才隔 0.30)。 relax = [sh_rect(q['x'], q['y'], q['hw'], q['hh'], (L_TOP, L_BOT) if q['lay'] == 12 else (q['lay'],)) for q in pads_all if q['net'] == net and (q['ref'], q['num']) not in (stubs or {})] # 生成树用 **Prim**:每一步挑「离**已连上的整坨**最近的那个」。 # 第一版是"离上一个接上的点最近"(最近邻链),在 33 个焊盘的 `+3V3_HUB` 上 # 会串成一条长蛇,好几段被逼着绕过整个 Hub 卫星圈。 cur = comps[0] rest = comps[1:] anchors = [cur.anchor] while rest: rest.sort(key=lambda c: min(math.hypot(c.anchor[0] - a[0], c.anchor[1] - a[1]) for a in anchors)) tgt = rest.pop(0) # 宽度阶梯:正常宽度过不去就收窄再试。Hub 卫星圈 / 排母缝这些地方 # 差的往往就是几十微米,收到 0.15 就通了,而载流上这些都是信号线。 seg = None for wtry in _widths(w, net): seg = _connect(net, cur, tgt, obstacles, wtry, relax=relax) if seg is not None: break # 首选出线方向走不通 -> 换个方向再试(见文件头那段说明) if seg is None and getattr(tgt, 'stub_alts', None): q = tgt.pad for end2, ws2 in tgt.stub_alts: t2 = Comp([tgt.rect, sh_seg(q['x'], q['y'], end2[0], end2[1], ws2, q['lay'])], end2, center=end2, layers=tgt.layers) for wtry in _widths(w, net): seg = _connect(net, cur, t2, obstacles, wtry, relax=relax) if seg is not None: break if seg is not None: paths[tgt.stub_i] = [ (round(q['x'], 4), round(q['y'], 4), q['lay'], ws2), (round(end2[0], 4), round(end2[1], 4), q['lay'], ws2)] tgt = t2 if verbose: print(u' %s.%s 改从 %s 出线' % (q['ref'], q['num'], _fmt(end2))) break if seg is None: if verbose: print(u' [!!] %s 布不通:%s -> %s' % (net, _fmt(cur.anchor), _fmt(tgt.anchor))) cur.merge(tgt, []) anchors.append(tgt.anchor) continue path, shp = seg paths.append(path) obstacles.extend((sp, net) for sp in shp) cur.merge(tgt, shp) anchors.append(tgt.anchor) return paths W_MIN = 0.15 # 一般信号收窄的下限(嘉立创四层工艺下限 0.102,留足余量) # ⚠️ **功率网络的下限是设计规则给的,不是工艺给的。** # `pcb2_rules.TRACK_RULES` 里 PWR1A 的 `strokeWidthMin` 是 0.25、PWR5V 是 0.30、 # PWR4A 是 0.30;收窄到 0.15 会被 DRC 记成「物理错误·导线」—— # 实测第一版就这么刷出 **40 条**,而净距校核一条都不报(线宽不是净距问题)。 W_MIN_NET = {'+5V': 0.30, '+3V3_HUB': 0.15} # PWR1A 的 min 已降到 0.15,见 pcb2_rules for _n in range(1, 6): W_MIN_NET['VBUS%d' % _n] = 0.25 W_MIN_NET['VO%d_P' % _n] = 0.30 def _widths(w, net=None): lo = W_MIN_NET.get(net, W_MIN) out = [w] for v in (round(w * 0.7, 3), lo): if v < out[-1] - 1e-6 and v >= lo - 1e-6: out.append(v) return out def _fmt(p): return u'(%.2f,%.2f)' % (p[0], p[1]) def _bbox(shapes, pad): xs, ys = [], [] for s in shapes: if s[0] == 'rect': xs += [s[1] - s[3], s[1] + s[3]] ys += [s[2] - s[4], s[2] + s[4]] elif s[0] == 'via': xs += [s[1] - s[3], s[1] + s[3]] ys += [s[2] - s[3], s[2] + s[3]] else: xs += [s[1] - s[5], s[3] + s[5]] ys += [s[2] - s[5], s[4] + s[5]] return (min(xs) - pad, min(ys) - pad, max(xs) + pad, max(ys) + pad) def _connect(net, a, b, obstacles, w, pad=BBOX_PAD, relax=None): u"""把两坨铜连起来。窗口先小后大、栅格先粗后细,找到就返回。 ⚠️ **细栅格那一档不是可有可无的。** 排母 2.54 间距 / Ø1.699 焊盘 ⇒ 缝宽 0.841 mm, 0.25 的线要 2×(0.20+0.125) = 0.65 —— 明明过得去,但 0.1 mm 栅格上 离缝心最近的格心可能偏 0.04,再加 QERR 0.071 就差那么一点点, 于是"从排母针缝里穿过去"这件事在粗栅格上**整片不可达**。 0.05 mm 栅格把这两项都减半,缝就通了。 """ # ⚠️ 窗口先**以目标为中心**开小的,再退到「整坨 + 目标」的大窗口(2026-09-06)。 # 原来一上来就取 a.shapes + b.shapes 的包围盒:`+3V3_HUB` 已连上的那坨横跨全板, # 窗口一大栅格就被放粗到 0.2 以上,QFN 东侧 0.71 mm 的走廊(U32.46 的出线端) # 在粗栅格上整片不可达 —— 单独布它能通、全量布就不通,根子在这里不在拥挤。 # 小窗口里只有离目标近的那部分 `a` 当种子,别的部分对这一段本来也没用。 tries = [('tgt', 0, 1), ('tgt', 0, 2), ('tgt', 0, 4), ('tgt', 1, 2), ('tgt', 1, 4), ('all', 0, 1), ('all', 0, 2), ('all', 0, 4), ('all', 1, 1), ('all', 1, 2), ('all', 1, 4), ('all', 2, 1), ('all', 2, 4)] for mode, grow, fine in tries: base = b.shapes if mode == 'tgt' else a.shapes + b.shapes x0, y0, x1, y1 = _bbox(base, pad + grow * 20.0) x0, y0 = max(0.0, x0), max(0.0, y0) x1, y1 = min(BW, x1), min(BH, y1) if mode == 'tgt': a_in = [s_ for s_ in a.shapes if _bbox([s_], 0.0)[2] > x0 and _bbox([s_], 0.0)[0] < x1 and _bbox([s_], 0.0)[3] > y0 and _bbox([s_], 0.0)[1] < y1] if not a_in: continue # 这一窗里没有种子,直接开大窗 a_use = Comp(a_in, a.anchor, center=a.center, layers=a.layers) else: a_use = a # 窗口太大就把栅格放粗一档:判据里的 QERR 跟着变,仍然是**充分条件**, # 只是长距离那几根线会保守一点(走廊本来就宽,损失不了什么)。 g = GRID / float(fine) while ((x1 - x0) / g + 2) * ((y1 - y0) / g + 2) > MAX_CELLS: g *= 2 f = Field(obstacles, (x0, y0, x1, y1), skip_net=net, grid=g) if relax: f.set_relax(relax) src = _mask(f, a_use) tgt = _mask(f, b) cells = route_grid(f, src, tgt, w) if cells is None: continue pts = _runs(cells) chunks = polish(f, pts, w) path = [] for lay, xy in chunks: for x, y in xy: path.append((round(float(x), 3), round(float(y), 3), lay, w)) path = _dedup(path) # 端点钉回焊盘圆心(见 Comp 的注释)。 # ⚠️ 端点处可能有**一串重合点**(换层),要整串一起挪,不能只挪最后一个。 if a.center: _snap_end(path, a.center, False) if b.center: _snap_end(path, b.center, True) path = _end_via(path) segs, vs = expand(path) shp = [sh_seg(sx0, sy0, sx1, sy1, sw, sl) for sx0, sy0, sx1, sy1, sl, sw in segs] shp += [sh_via(vx, vy) for vx, vy in vs] return path, shp return None def _mask(f, c): u"""A* 的种子 / 目标格。单焊盘只取圆心那一格,其余按整坨铜。""" if c.center: m = {L: np.zeros((f.ny, f.nx), bool) for L in f.lays} i, j = f._idx(c.center[0], c.center[1]) if 0 <= i < f.nx and 0 <= j < f.ny: for L in c.layers: m[L][j, i] = True return m return f.mask_of(c.shapes) def _dedup(path): out = [] for p in path: if out and abs(out[-1][0] - p[0]) < 1e-9 and abs(out[-1][1] - p[1]) < 1e-9 \ and out[-1][2] == p[2]: continue out.append(p) return out def _same(a, b): return abs(a[0] - b[0]) < 1e-9 and abs(a[1] - b[1]) < 1e-9 def _snap_end(path, xy, at_end): u"""把路径某一端**所有重合的点**一起钉到 `xy`(焊盘圆心)。 换层处路径里是两个同坐标不同层的点;只挪其中一个的话, 过孔就跟导线错开 0.05 mm,`expand()` 认不出换层,孔直接消失。 """ if at_end: k = len(path) - 1 while k > 0 and _same(path[k - 1], path[k]): k -= 1 rng = range(k, len(path)) else: k = 0 while k + 1 < len(path) and _same(path[k], path[k + 1]): k += 1 rng = range(0, k + 1) for i in rng: path[i] = (round(xy[0], 4), round(xy[1], 4), path[i][2], path[i][3]) def _end_via(path): u"""路径**最后一个点上的过孔**要补一个重复点,`expand()` 才认得出来。 ⚠️ 这是本脚本抓到的最阴的一个 bug。`expand()` 的语义是 「第 i 点的层 = 从这一点走到下一点那一段的层」,换层的过孔打在第 i+1 点上, **但它显式跳过最后一个点**(`i + 1 < n - 1`)——最后一点的层被当成占位符。 于是"走到目标焊盘正上方再换层落下去"这种路径,**过孔被静默丢掉**: 两层的铜各画各的,中间断开。 更糟的是三道校核全都抓不到:净距校核只看有没有撞上(断开当然不撞), DRC 的连接性又要 `location.reload()` 才刷新。 实测 `$25N40` / `SDA` / `VBUS_DET` / `XTAL2` / `VBUS_EN3` 五个网络就是这么断的。 起点侧不用补 —— 起点的重合点对 `expand()` 是"第 0 段长度为 0 + 第 1 点换层",本来就认得出。 """ if len(path) >= 2 and _same(path[-1], path[-2]) and path[-1][2] != path[-2][2]: return path + [path[-1]] return path # ══════════════════ 本轮要布的网络 ══════════════════ def _to_site(n, seq): return [(round(to_board(n, u_, v_, 0)[0], 3), round(to_board(n, u_, v_, 0)[1], 3), lay, ww) for u_, v_, lay, ww in seq] def bm24_pre(n): u"""第 n 站 BM24 的 13 个 GND 脚怎么接到地平面(手写,站内坐标,**全部底层**)。 §25.8 判定「51 个脚旁边打不下 Ø0.61 的孔、只能逃逸出来再并」是对的。 v5 的具体走法(和 v4 反过来,理由见上面那段车道分配): · **前排** pin 2/8/12/16/24 → 往**内**(进中央通道),在 v=35.00 并成一条 0.30 梳子, 梳子上打 4 个孔(跳过 u 50.05,它和 50.75 只隔 0.70 < 0.81); · **后排** pin 1/19/23/29/37/41/47 → 往**外**(v−),在 v=33.10 并成第二条梳子, 打 5 个孔(那一带一路空到 v 26,是全站最宽敞的地方); · **pin 52** 单独往东平出到 u=59.60 打一个孔。 ⚠️ 这 65 个脚**已经从 `pcb2_fanout.py` 里整批剔除**(`SKIP_PAD`), 全靠这里画出来 —— 漏一个就是一条连接性 DRC。 """ B = lambda seq: _to_site(n, seq) out = [] # ── 前排:往内进中央通道 ── # 最西那根出线段和梳子写成**同一条路径**,西端那个 90° 才切得成 45°; # 其余几根是 T 形接头(线本身不拐弯),保持原样。 out.append(B([(J7_GND_FRONT[0], J7_FRONT_V, L_BOT, W_ARR), (J7_GND_FRONT[0], J7_FRONT_IN, L_BOT, W_COMB), (J7_GND_FRONT[0], J7_MID_COMB, L_BOT, W_COMB), (J7_GND_FRONT[-1], J7_MID_COMB, L_BOT, W_COMB)])) for u_ in J7_GND_FRONT[1:]: out.append(B([(u_, J7_FRONT_V, L_BOT, W_ARR), (u_, J7_FRONT_IN, L_BOT, W_COMB), (u_, J7_MID_COMB, L_BOT, W_COMB)])) for u_ in J7_MID_VIA_U: out.append(B([(u_, J7_MID_COMB, L_BOT, W_COMB), (u_, J7_MID_COMB, L_TOP, W_COMB)])) # ── 后排:往外并成第二条梳子 ── out.append(B([(J7_GND_BACK[0], J7_BACK_V, L_BOT, W_ARR), (J7_GND_BACK[0], J7_BACK_ESC, L_BOT, W_COMB), (J7_GND_BACK[0], J7_BACK_COMB, L_BOT, W_COMB), (J7_GND_BACK[-1], J7_BACK_COMB, L_BOT, W_COMB)])) for u_ in J7_GND_BACK[1:]: out.append(B([(u_, J7_BACK_V, L_BOT, W_ARR), (u_, J7_BACK_ESC, L_BOT, W_COMB), (u_, J7_BACK_COMB, L_BOT, W_COMB)])) for u_ in J7_BACK_VIA_U: out.append(B([(u_, J7_BACK_COMB, L_BOT, W_COMB), (u_, J7_BACK_COMB, L_TOP, W_COMB)])) # ── pin 52:往东平出打孔 ── out.append(B([(J7_GND_BIG, J7_MID_V, L_BOT, W_COMB), (J7_PIN52_VIA_U, J7_MID_V, L_BOT, W_COMB), (J7_PIN52_VIA_U, J7_MID_V, L_TOP, W_COMB)])) return out def bm24_lane(n, kind): u"""THERM / CC1 从前排往**外**出,直接进正北的 R77n.1 / R76n.1(v5)。 v4 那条「走中央通道往东绕出来」的路线在 v5 已经没有必要 —— 后排整排都是 GND,通道判给了前排 GND 的梳子,而 R76n/R77n 就在前排外侧。 · `CC1_n`:R76n 的中心特意取 52.233,pin1 正落在 u 51.800 = J7n.22 正北 ⇒ **一根直线上去**,全程底层。 · `THERM_n`:目标在 u 54.367,要往东跨过 CC1 那根竖线 —— **打两个孔走顶层过去**(顶层在 J7n 这一带一根线都没有)。 西孔离 `*_DPJ` 的换层孔 0.97 ≥ 0.81,是这一段最紧的一处。 """ if kind == 'CC1': return _to_site(n, [(J7_CC1_U, J7_FRONT_V, L_BOT, W_ARR), (J7_CC1_U, J7_FRONT_ESC, L_BOT, W_LANE), (J7_R76_PIN1_U, J7_R_ENTER_V, L_BOT, W_LANE)]) (wu, wv), (eu, ev) = J7_THERM_VIA ku, kv = J7_THERM_KINK # ⚠️ 第 i 点的 width 管的是「这一点到下一点」那一段。 # 前两段(直出 + 45° 斜出)**必须都是 0.11** —— 理由见 J7_THERM_TURN_V 那一段注释。 return _to_site(n, [(J7_THERM_U, J7_FRONT_V, L_BOT, W_ARR), (J7_THERM_U, J7_THERM_TURN_V, L_BOT, W_ARR), # 直出到 36.58,仍 0.11 (ku, kv, L_BOT, W_LANE), # 45° 斜出,仍 0.11 -> 之后放宽 (wu, wv, L_BOT, W_LANE), # 竖上到西孔(离 DPJ 孔 0.80) (wu, wv, L_TOP, W_LANE), # ↑ 换层 (eu, ev, L_TOP, W_LANE), # 顶层东行(顶层这一带是空的) (eu, ev, L_BOT, W_LANE), # ↓ 换层 (J7_R77_PIN1_U, J7_R_ENTER_V, L_BOT, W_LANE)]) # ── 布线顺序(挤的先布、宽的先布)───────────────────────────────────── # Hub 卫星圈是 1.2 mm 网格上的 0402,缝只有 0.66 mm;服务带里 0402 也是密排。 # 所以次序是:**Hub 局部 -> 上游 -> 站内局部 -> 电源 -> 长距离控制线**。 ALL_NETS = set() PADS_ALL = [] # main() 装进来,`_pogo_by_length()` 要用 lanes0 = (['THERM_%d' % n for n in range(1, 6)] + ['CC1_%d' % n for n in range(1, 6)]) def _pogo_by_length(): u"""十五根 POGO,按站分组、组内按两端直线距离从短到长。""" out = [] for n in range(1, 6): row = [] for k in ('PWR', 'VUP', 'VDN'): net = 'POGO%d_%s' % (n, k) ps = [p for p in PADS_ALL if p['net'] == net] d = (math.hypot(ps[0]['x'] - ps[1]['x'], ps[0]['y'] - ps[1]['y']) if len(ps) == 2 else 0.0) row.append((d, net)) out += [net for _d, net in sorted(row)] return out def net_order(): # GND 与 THERM/CC1 的车道排最前:BM24 周围那三条缝是**手写死的几何**, # 后面所有网络都必须看得见它们、绕开走。 # # ⚠️ 2026-09-06 试过把 `+3V3_HUB` 提到这一组最前(想让它先抢卫星圈的缝), # **结果更糟**:它有 33 个焊盘,先布就把 Hub 周围铺满, # `XTAL1`/`CFGSEL0`/`PRTDIS6`/`PRTDIS7`/`HUB_RST` 五根配置线全布不通 # (没布通的从 4 个涨到 7 个)。⇒ 大网络必须排在点对点的小网络之后。 hub = ['GND'] + lanes0 + ['VBUS_UP'] + [ 'RBIAS', 'VDD18', 'VDD18PLL', 'XTAL1', 'XTAL2', 'CFGSEL0', 'CFGSEL1', 'NONREM0', 'NONREM1', 'PRTDIS6', 'PRTDIS7', 'HUB_RST', '+3V3_HUB', 'VBUS_DET'] # `EDL_ENn` / `VBUS_ENn` 在 U4n / U2n 上是**紧挨着 +3V3 脚的那一针**, # 排在 `+3V3_HUB` 之前,否则 3V3 的长支干会从这 0.95 mm 的缝里横穿过去。 en = ['EDL_EN%d' % n for n in range(1, 6)] + ['VBUS_EN%d' % n for n in range(1, 6)] # `+3V3_HUB` 排在 Hub 局部之后、其它一切之前:它有 33 个焊盘、 # 一半挤在 Hub 的卫星圈里,晚一步就没缝了。 # `+5V` 紧跟 `+3V3_HUB`:它要从排母穿过已经站满 EN/POGO 线的走廊过来, # 排在后面的话 site2/site5 那两段就没缝了。 # 2026-09-06(v5)实测调过一次次序:`+3V3_HUB` 和 `VBUS_UP` 单独跑都能通 # (45 s / 2 s),一起跑就都断 —— 说明是抢地盘,不是没有路。 # · `+3V3_HUB` 提到 EN 组**之前**(但仍在 Hub 局部之后,那五根配置线的教训还在); # · `VBUS_UP` 提到 `+5V` **之前** —— 它要横穿大半块板去 J23, # 而 `+5V` 是 0.60 的宽线,先布就把那条走廊占死了。 up = en + ['VBUS_UP', 'UP_VBUS_DET', '+5V', '$16N222', '$16N224'] # `$` 开头的是原理图自动命名的局部网(J6n<->U4n、C72n<->U2n), # 编号不成规律(site1 是 $21N57/58,其余是 $2xN39/40),所以从网表里捞。 site = sorted(n for n in ALL_NETS if n.startswith('$')) power = ['VBUS%d' % n for n in range(1, 6)] # 每个站的三根 POGO 按**两端直线距离从短到长**排:短的那根余地最小, # 让它先挑路;长的绕得起。site4/site5 上这条规则把 `VDN` 提到最前 —— # 它的源在最东、靶点却在最西(J59 比 J57/J58 靠里),后布就被另外两根夹死 # (`POGO5_VDN` 原来就是这么布不通的)。 ctrl = [n for n in _pogo_by_length()] + ['SDA', 'SCL'] out, seen = [], set() for n in hub + up + site + power + ctrl: if n not in seen: seen.add(n) out.append(n) return out # ⚠️ 手写的前置段也**必须**过一遍 `_end_via()`。 # `bm24_pre()` 里那些「走到位置 -> 换层」的路径最后两个点是同坐标不同层, # 而 `expand()` 显式跳过最后一个点上的过孔 —— 五个站 **50 个 GND 过孔全部丢掉**, # 两条梳子和 pin52 的连线整个悬空,DRC 报出 J7n 的 pin 1/2/8/12/47/52 断开。 # 净距校核和 `inner1_verify` 都不会响:悬空的铜不违反任何净距。 PRE = {} for _n in range(1, 6): PRE.setdefault('GND', []).extend(_end_via(p) for p in bm24_pre(_n)) PRE['THERM_%d' % _n] = [_end_via(bm24_lane(_n, 'THERM'))] PRE['CC1_%d' % _n] = [_end_via(bm24_lane(_n, 'CC1'))] PRE_ONLY = {'GND'} # 只落前置段、不做最小生成树的网络 OUT = os.path.join(HERE, '_pcb2_sig_routes.json') # ══════════════════ 下发 ══════════════════ # ⚠️ `pcb2_route_dp.push()` 的做法是「把这些网络上现有的线和过孔全删掉再重画」。 # 对差分对没问题,对 **GND 是灾难** —— 一按就把 §25.8 那 210 个扇出过孔和 # 126 条出线全扫掉,而几何校核和 DRC 都只会说"连接性错误变多了"。 # 所以 GND 走**外科式删除**:只删落在五个 BM24 逃逸框里的 GND 铜。 # v5 的框取站内 u ∈ [47.5, 60.3]、v ∈ [32.5, 36.5]: # · 装得下两条梳子(v 35.00 / 33.10)、13 根出线和 pin52 往东那一截; # · **不碰**框外那些 `pcb2_fanout.py` 画的孔 —— R77n.2 在 v 38.0、U5n.2 的孔在 (48.80, 39.45), # 两者都在 v > 36.5;手机定位柱 PnH3 在 u 61.90 > 60.3。 # · J7n 那 65 个 GND 脚本来就被 `pcb2_fanout.SKIP_PAD` 剔除了,框里除了本脚本自己画的没有别人的铜。 GND_BOX_UV = (47.5, 32.5, 60.3, 36.5) def gnd_boxes(): out = [] for n in SITES: xs, ys = [], [] for u_, v_ in ((GND_BOX_UV[0], GND_BOX_UV[1]), (GND_BOX_UV[2], GND_BOX_UV[3])): bx, by, _ = to_board(n, u_, v_, 0) xs.append(bx) ys.append(by) out.append([round(min(xs), 3), round(min(ys), 3), round(max(xs), 3), round(max(ys), 3)]) return out JS_PUSH_SIG = u""" const NETS = %s, BOXES = %s, SEGS = %s, VIAS = %s, MIL = %f; const S = new Set(NETS); const inBox = (x, y) => BOXES.some(b => x >= b[0] && x <= b[2] && y >= b[1] && y <= b[3]); let dl = 0, dv = 0; for (const o of await eda.pcb_PrimitiveLine.getAll()) { const L = o.getState_Layer(); if (L !== 1 && L !== 2) continue; const n = o.getState_Net(); const keep = n === 'GND' ? !(inBox(o.getState_StartX()/MIL, o.getState_StartY()/MIL) && inBox(o.getState_EndX()/MIL, o.getState_EndY()/MIL)) : !S.has(n); if (!keep) { await eda.pcb_PrimitiveLine.delete(o.getState_PrimitiveId()); dl++; } } for (const o of await eda.pcb_PrimitiveVia.getAll()) { const n = o.getState_Net(); const keep = n === 'GND' ? !inBox(o.getState_X()/MIL, o.getState_Y()/MIL) : !S.has(n); if (!keep) { await eda.pcb_PrimitiveVia.delete(o.getState_PrimitiveId()); dv++; } } let nl = 0, nv = 0; for (const [net, lay, x0, y0, x1, y1, w] of SEGS) { await eda.pcb_PrimitiveLine.create(net, lay, x0*MIL, y0*MIL, x1*MIL, y1*MIL, w*MIL); nl++; } for (const [net, x, y] of VIAS) { await eda.pcb_PrimitiveVia.create(net, x*MIL, y*MIL, %f*MIL, %f*MIL); nv++; } await eda.pcb_Document.save(); await new Promise(r => setTimeout(r, 2500)); return {del_lines: dl, del_vias: dv, lines: nl, vias: nv}; """ def push_sig(routes): segs, vs = [], [] for key, path in routes.items(): net = netof(key) s_, v_ = expand(path) for x0, y0, x1, y1, lay, w in s_: segs.append([net, lay, round(x0, 4), round(y0, 4), round(x1, 4), round(y1, 4), w]) for x, y in v_: vs.append([net, round(x, 4), round(y, 4)]) nets = sorted(set(netof(k) for k in routes) - {'GND'}) js = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,1800));" % PCB) + ( JS_PUSH_SIG % (json.dumps(nets), json.dumps(gnd_boxes()), json.dumps(segs), json.dumps(vs), MIL, VIA_H, VIA_D)) from eda import run r = run(js, timeout=1800) if not r.get('success'): sys.exit(u'下发失败: ' + json.dumps(r, ensure_ascii=False)[:600]) d = r['result'] print(u'下发完成:先删 %d 线 / %d 孔,再画 %d 线 / %d 孔' % (d['del_lines'], d['del_vias'], d['lines'], d['vias'])) def _to_site_uv(n, x, y): u"""板坐标 -> 站内 (u, v)。`to_board` 的逆。""" x0, y0, rho = SITES[n] if rho == 0: return (x - x0, y - y0) return (PW - (x - x0), PH - (y - y0)) def j7_assert(pads): u"""**把上面那一堆 J7_* 常量和板上的实际焊盘对一遍**(五个站逐一)。 ⚠️ 这个断言是 2026-09-06 补的,起因是它不在的时候踩的坑: v5 把 J7n 从 rot 180 改成 rot 0 又挪了位置,站内坐标整体差 `u' = u − 5.00 / v' = 32.00 − v`,而脚本照旧用 v4 的常量 —— `bm24_pre()` 于是在**空地上**画了五套梳子,净距校核全过(空地当然不撞), 只有 DRC 的连接性会含糊地多出几十条。⇒ 手写坐标必须有断言兜底。 """ bad = [] for n in SITES: by = {} for p in pads: if p['ref'] != 'J7%d' % n: continue u_, v_ = _to_site_uv(n, p['x'], p['y']) by[p['num']] = (round(u_, 3), round(v_, 3), p['net'], p['lay']) if not by: bad.append(u'J7%d 一个焊盘都没找到' % n) continue want = ([(str(k), J7_GND_FRONT[i], J7_FRONT_V) for i, k in enumerate((2, 8, 12, 16, 24))] + [(str(k), J7_GND_BACK[i], J7_BACK_V) for i, k in enumerate((1, 19, 23, 29, 37, 41, 47))] + [('52', J7_GND_BIG, J7_MID_V)]) for num, uu, vv in want: got = by.get(num) if not got: bad.append(u'J7%d 没有 pin%s' % (n, num)); continue if got[2] != 'GND': bad.append(u'J7%d.pin%s 的网络是 %s,不是 GND' % (n, num, got[2])) if abs(got[0] - uu) > 0.02 or abs(got[1] - vv) > 0.02: bad.append(u'J7%d.pin%s 实测 (%.3f, %.3f),常量写的是 (%.2f, %.2f)' % (n, num, got[0], got[1], uu, vv)) for num, uu, net in (('14', J7_THERM_U, 'THERM_%d' % n), ('22', J7_CC1_U, 'CC1_%d' % n)): got = by.get(num) if not got: bad.append(u'J7%d 没有 pin%s' % (n, num)); continue if got[2] != net: bad.append(u'J7%d.pin%s 的网络是 %s,应为 %s' % (n, num, got[2], net)) if abs(got[0] - uu) > 0.02 or abs(got[1] - J7_FRONT_V) > 0.02: bad.append(u'J7%d.pin%s 实测 u=%.3f v=%.3f,常量 u=%.2f v=%.2f' % (n, num, got[0], got[1], uu, J7_FRONT_V)) if by.get('2') and by['2'][3] != L_BOT: bad.append(u'J7%d 不在底层(lay=%s)—— bm24_pre 的路径层写死了 L_BOT' % (n, by['2'][3])) # THERM / CC1 的靶子:R76n.1 / R77n.1 for ref, uu, tag in (('R76%d' % n, J7_R76_PIN1_U, 'CC1'), ('R77%d' % n, J7_R77_PIN1_U, 'THERM')): hit = [p for p in pads if p['ref'] == ref and p['num'] == '1'] if not hit: bad.append(u'找不到 %s.1' % ref); continue u_, v_ = _to_site_uv(n, hit[0]['x'], hit[0]['y']) if abs(u_ - uu) > 0.02 or abs(v_ - J7_R_PAD_V) > 0.02: bad.append(u'%s.1(%s)实测 (%.3f, %.3f),常量 (%.3f, %.3f)' % (ref, tag, u_, v_, uu, J7_R_PAD_V)) if bad: for t in bad[:20]: print(u' !! ' + t) sys.exit(u'\nBM24 的手写常量和板上对不上(%d 条)—— 先改常量再布线。' % len(bad)) print(u'BM24 手写常量与板上实测一致(五个站 × 13 个 GND 脚 + THERM/CC1 + R76n/R77n)✔') def main(): only = [a for a in sys.argv[1:] if not a.startswith('--')] pads, vias, lines = geom() ALL_NETS.update(p['net'] for p in pads if p['net']) PADS_ALL[:] = pads j7_assert(pads) # ⚠️ 下发时要删掉五个 BM24 框里的旧 GND 铜(§25.8 自动扇出的产物), # 所以**算的时候就得当它们已经没了** —— 否则手写的车道会跟一堆 # 马上就要消失的过孔"撞车",而真正的冲突反而被淹没。 boxes = gnd_boxes() inbox = lambda x, y: any(b[0] <= x <= b[2] and b[1] <= y <= b[3] for b in boxes) mine = set(net_order()) - {'GND'} n0 = len(vias) + len(lines) # ① 本脚本负责的网络:下发时整网删掉重画,所以**算的时候就当它们不存在**。 # 不摘的话,第二次跑会把自己上一轮的线当成障碍 —— 结果既不幂等, # 还会在校核里刷出一堆「和马上要删掉的那条线撞了」的假冲突(实测 3 条)。 # ② GND 只摘五个 BM24 框里的(§25.8 自动扇出的产物,下发时按框删)。 vias = [v for v in vias if v['net'] not in mine and not (v['net'] == 'GND' and inbox(v['x'], v['y']))] lines = [l for l in lines if l['net'] not in mine and not (l['net'] == 'GND' and inbox(l['x0'], l['y0']) and inbox(l['x1'], l['y1']))] print(u'障碍表:焊盘 %d / 过孔 %d / 铜层导线 %d(摘掉本脚本自己负责的旧铜 %d 件)' % (len(pads), len(vias), len(lines), n0 - len(vias) - len(lines))) order = [n for n in net_order() if not only or n in only] obs = shapes_of(pads, vias, lines) have = set(p['net'] for p in pads) missing = [n for n in order if n not in have] if missing: sys.exit(u'网络表里没有这些网络:%s' % missing) # ⚠️ `+3V3_HUB` 上**已经有铜**:`pcb2_dp_long.py` 在 Hub 西缘画了三条 # (pin5 / pin10 / 顶层干线,15 段 + 4 个孔)。它们被 D± 夹在中间, # 是差分那一轮算出来的、后面的布线器再也挤不进去的位置。 # 而 `push_sig()` 的做法是「把本脚本负责的网络整网删掉重画」—— # 不管的话这三条会被静默抹掉,只在 DRC 里冒出几条连接性错误。 # ⇒ 从**同一个事实来源**(`pcb2_dp_long.build`)取回来当本网的前置段: # 既让最小生成树知道这些焊盘已经连上了,下发时也原样重画。 from pcb2_dp_long import build as _dp_long_build _kept = [q for k, q in _dp_long_build(pads).items() if netof(k) == '+3V3_HUB'] if len(_kept) != 3: sys.exit(u'pcb2_dp_long 里的 +3V3_HUB 前置段应有 3 条,实际 %d 条' % len(_kept)) # U32 的 7 个 +3V3 脚里,**pin46 在东排、四周被 VBUS_DET / NONREM0 围死**, # 它唯一的出路是往 QFN **肚子里**走:南排焊盘上沿 63.975 与散热盘南沿 65.60 # 之间有一条 1.6 mm 的死带(芯片底下,没有别人要用)。 # 单独跑 `--net +3V3_HUB` 时布线器自己就是这么走的;但排在 Hub 那几根配置线之后跑, # 那个内角已经被占掉 ⇒ 干脆手写成前置段,和 BM24 的梳子一样**先占住**。 # (102.25, 63.975) 是 pin52 焊盘的北沿;45° 斜段离 pin51 焊盘框 0.50、 # 东行段离 pin47/pin45 焊盘框各 0.35(0.24 的线要 0.32)✔ PRE['+3V3_HUB'] = _kept + [[(102.250, 63.550, L_BOT, 0.24), (102.250, 64.125, L_BOT, 0.24), (103.375, 65.250, L_BOT, 0.24), (104.450, 65.250, L_BOT, 0.24)], # R716.2(HUB_RST 的上拉)是最东边那颗 +3V3 焊盘, # 夹在 R711.2 / R720.2 两个 GND 脚之间(南北各 1.2 mm), # 只能**翻顶层**从 C750.1 斜拉过来。同样先占住。 # 两个孔位都是卡着算的: # 西孔 (105.95, 63.825):离 R710.2 那条 0.15 扇出线 0.624(要 0.58) # —— 原来放在 105.88 只有 0.566,差 14 µm; # 东孔 (109.60, 66.45):离 `USB5_DP` 竖段 (x 110.30) 0.70(要 0.645) # —— 原来放在 109.72 只有 0.58,差 65 µm。 [(105.950, 63.450, L_BOT, 0.35), (105.950, 63.825, L_BOT, 0.35), (105.950, 63.825, L_TOP, 0.35), (106.275, 64.150, L_TOP, 0.35), (107.300, 64.150, L_TOP, 0.35), (109.600, 66.450, L_TOP, 0.35), (109.600, 66.450, L_BOT, 0.35), (109.333, 66.450, L_BOT, 0.35)]] + PRE.get('+3V3_HUB', []) print(u'+3V3_HUB 保留 pcb2_dp_long 画的 3 条前置段(%d 段导线 / %d 个孔)' % (sum(len(expand(q)[0]) for q in _kept), sum(len(expand(q)[1]) for q in _kept))) # `HUB_RST`:U32.43 (104.45, 66.75) 到 R716.1 (108.467, 66.45) 之间只有 # R709(y 65.85) 与 R712(y 67.05) 之间那条 **0.66 mm** 的缝。 # 0.20 的线走它是够的(上下各 0.23 ≥ 0.20),但**栅格布线器进不去**: # 它的判据是 `edt >= 0.20 + 0.10 + QERR(0.0708) = 0.371`,而半缝只有 0.33。 # ⇒ 这一段也手写。(不是布线器的 bug —— 0.1 mm 栅格就是分辨不出 0.03 的余量。) # ⚠️ 这一段**必须 0.15 不能 0.20**,两处都是算死的: # 转折点 a 的窗口:往西受 U32.44 焊盘框(东沿 104.875)挡,a >= 104.875 + (w/2+0.20); # 往东受 R712.1 焊盘框西南角 (105.584, 66.780) 挡(45° 斜段的垂距)。 # w=0.20 时窗口只有 15 µm(105.175~105.190),w=0.15 时有 75 µm ⇒ 取 105.19。 # 东行段 y=66.45 离 (107.624, 67.050) 那个 GND 扇出孔 0.600: # w=0.20 要 0.605(差 5 µm 不过),w=0.15 要 0.58 ✔ PRE['HUB_RST'] = [[(104.450, 66.750, L_BOT, 0.15), (105.190, 66.750, L_BOT, 0.15), (105.490, 66.450, L_BOT, 0.15), # 45° (108.467, 66.450, L_BOT, 0.15)]] # `VBUS_DET`:U32.44 (104.45, 66.25) 到 R709.2 (105.867, 65.85) —— 和 HUB_RST 同一个口袋。 # HUB_RST 的手写段占了 y 66.45 那条线之后,这一小段的余量掉到栅格分辨不出来的量级, # 所以一起手写。45° 斜段离 HUB_RST 斜段 0.4525(要 0.375)、离 U32.45 焊盘框 0.371(要 0.30)。 PRE['VBUS_DET'] = [[(104.450, 66.250, L_BOT, W_SIG), (105.050, 66.250, L_BOT, W_SIG), (105.450, 65.850, L_BOT, W_SIG), # 45° (105.867, 65.850, L_BOT, W_SIG)]] # `+5V`:J32.39 与 J32.40 是排母上下相邻的两针,中间 2.54 mm。 # 上一轮布线器给 J32.39 留的出线段末端**正好落在 J32.40 的焊盘圈里**(差 0.077), # 本脚本的连通域判定认了,EasyEDA 的连接性检查不认 —— DRC 报 # 「导线 (+5V) 与其他相同网络的对象断开了连接」+「插件焊盘 J32_39 断开」。 # ⇒ 两针之间直接连一根顶层短线,端点落在焊盘中心,谁都不会误判。 PRE['+5V'] = [[(182.1298, 68.770, L_TOP, 0.60), (182.1298, 66.230, L_TOP, 0.60)]] pre_pads = set() for net, ps in PRE.items(): for p in ps: for q in pads: if q['net'] == net and seg_rect((p[0][0], p[0][1]), (p[0][0], p[0][1]), q['x'], q['y'], q['hw'], q['hh']) < 1e-6: pre_pads.add((q['ref'], q['num'])) # ⚠️ **所有前置段一开始就进障碍表**(带各自的网络名)。 # 原来只有 GND 的在循环里补进去,THERM/CC1/+3V3 的要等轮到自己才进 —— # 于是排在它们前面的网络看不见这些手写坐标,照样从上面碾过去。 for _net, _ps in PRE.items(): for _p in _ps: _sg, _vv = expand(_p) for _x0, _y0, _x1, _y1, _lay, _w in _sg: obs.append((sh_seg(_x0, _y0, _x1, _y1, _w, _lay), _net)) for _x, _y in _vv: obs.append((sh_via(_x, _y), _net)) stubs, stub_alts = plan_stubs(order, pads, obs, pre_pads) print(u'预留出线段 %d 条' % len(stubs)) routes, failed = {}, [] t0 = time.time() for net in order: t = time.time() if net in PRE_ONLY: # GND 不做最小生成树 —— 它靠 Inner2 整板地平面 + §25.8 的扇出连通, # 这里只补 BM24 那 65 个脚的逃逸段(`bm24_pre`),照原样落盘。 ps = list(PRE[net]) for p in ps: for x0, y0, x1, y1, lay, w in expand(p)[0]: obs.append((sh_seg(x0, y0, x1, y1, w, lay), net)) for x, y in expand(p)[1]: obs.append((sh_via(x, y), net)) ok = True else: ps = route_net(net, pads, obs, pre=PRE.get(net), verbose=True, stubs=stubs, alts=stub_alts) ok = _wire_ok(net, ps, pads) if not ok: failed.append(net) for k, p in enumerate(ps): routes[net if k == 0 else '%s#%d' % (net, k + 1)] = p print(u' %-12s %2d 段路径 / %3d 导线 / %2d 过孔 %5.1fs %s' % (net, len(ps), sum(len(expand(q)[0]) for q in ps), sum(len(expand(q)[1]) for q in ps), time.time() - t, u'' if ok else u' ← 有端点没连上')) print(u'\n%d 个网络 / %d 条路径 / %d 段导线 / %d 个过孔 (%.0f 秒)' % (len(order), len(routes), sum(len(expand(p)[0]) for p in routes.values()), sum(len(expand(p)[1]) for p in routes.values()), time.time() - t0)) if failed: print(u'⚠️ %d 个网络没布通:%s' % (len(failed), u' '.join(failed))) # ── 45° 化(2026-09-06 用户拍板:全板不许有直角走线)── # 放在**布完之后统一做**,而不是每条路径各切各的: # 斜切只会拿掉铜,所以拿"未切之前"的其它段做校核是保守且正确的。 n0 = len(sharp_corners(routes)) t = time.time() routes = chamfer_routes(routes, pads, vias, lines, smooth=True) left = sharp_corners(routes) print(u'\n45° 化:直角拐点 %d -> %d(%.0f 秒)' % (n0, len(left), time.time() - t)) for key, i, xy, ang in left[:20]: print(u' [!!] 切不动: %-12s (%.2f, %.2f) 转向 %.0f°' % (key, xy[0], xy[1], ang)) if len(left) > 20: print(u' (还有 %d 处)' % (len(left) - 20)) print(u'\n净距校核(%.2f mm):' % CLR) bad = check(routes, pads, vias, lines) if not bad: print(u' 全部通过 ✔') json.dump({k: [list(map(float, q[:2])) + [int(q[2]), float(q[3])] for q in v] for k, v in routes.items()}, io.open(OUT, 'w', encoding='utf-8')) print(u'路径已存 %s' % os.path.basename(OUT)) if '--push' in sys.argv: if bad: sys.exit(u'\n!! 净距还有问题,不下发。') if failed and '--force' not in sys.argv: sys.exit(u'\n!! 还有 %d 个网络没布通,要下发请加 --force。' u'(没布通的网络只会落下几截悬空的出线段,' u'下一轮 --push 会把它们连同别的一起删掉重画。)' % len(failed)) push_sig(routes) def _wire_ok(net, paths, pads_all): u"""这个网络是不是**真的连成一坨**(按实际铜算连通域,不看 DRC)。 ⚠️ 判据必须是"连通域 == 1",**不能是"每个焊盘都被某条线碰到"**。 每个焊盘出门时都先画了一小截 `entry_stub`,所以"被碰到"永远成立 —— 第一版就是这么写的,于是 20 个断开的网络里只报出 5 个, 另外 15 个各自剩着一截悬空的出线段,几何校核还全绿。 """ items = [] # (层集合, 种类, 数据) for q in pads_all: if q['net'] == net: lay = {L_TOP, L_BOT} if q['lay'] == 12 else {q['lay']} items.append((lay, 'pad', (q['x'], q['y'], q['hw'], q['hh']))) for p in paths: segs, vs = expand(p) for x0, y0, x1, y1, lay, w in segs: items.append(({lay}, 'seg', (x0, y0, x1, y1, w / 2.0))) for x, y in vs: items.append(({L_TOP, L_BOT}, 'via', (x, y, VIA_R))) n = len(items) if n < 2: return True par = list(range(n)) def find(i): while par[i] != i: par[i] = par[par[i]] i = par[i] return i def touch(a, b): la, ka, da = a lb, kb, db = b if not (la & lb): return False if ka > kb: return touch(b, a) if ka == 'pad' and kb == 'pad': return (abs(da[0] - db[0]) <= da[2] + db[2] + 1e-6 and abs(da[1] - db[1]) <= da[3] + db[3] + 1e-6) if ka == 'pad' and kb == 'seg': return seg_rect((db[0], db[1]), (db[2], db[3]), da[0], da[1], da[2], da[3]) <= db[4] + 1e-6 if ka == 'pad' and kb == 'via': return seg_rect((db[0], db[1]), (db[0], db[1]), da[0], da[1], da[2], da[3]) <= db[2] + 1e-6 if ka == 'seg' and kb == 'seg': return seg_seg((da[0], da[1]), (da[2], da[3]), (db[0], db[1]), (db[2], db[3])) <= da[4] + db[4] + 1e-6 if ka == 'seg' and kb == 'via': return seg_pt(da[0], da[1], da[2], da[3], db[0], db[1]) <= da[4] + db[2] + 1e-6 return math.hypot(da[0] - db[0], da[1] - db[1]) <= da[2] + db[2] + 1e-6 for i in range(n): for j in range(i + 1, n): if find(i) != find(j) and touch(items[i], items[j]): par[find(i)] = find(j) return len(set(find(i) for i in range(n))) == 1 if __name__ == '__main__': main()