# -*- coding: utf-8 -*- u"""下板 PCB1 的 **I²C 干线改走 Inner2(L3)** —— 三层(Top / Bottom / Inner2)迷宫布线。 /c/Python314/python .claude/pcb1_i2c_l3.py # 算 + 校核,不动板(默认) /c/Python314/python .claude/pcb1_i2c_l3.py --map # 只打 L3 的可走区域图 /c/Python314/python .claude/pcb1_i2c_l3.py --push # 拆旧长干线 -> 重算 -> 校核 -> 下发 /c/Python314/python .claude/pcb1_i2c_l3.py --net SDA # 只做一个网络 ## 为什么单写一个文件,而不是给 `pcb1_route_sig.py` 加一层 引擎(`pcb2_route_sig.py`)的层数是写死的 2:`route_grid` 里 `LAY = [L_TOP, L_BOT]`、 图是 `2*N` 个节点、`Field.viafree = min(edt[TOP], edt[BOT])`。加第三层要动的不止层数 —— **`viafree` 那一行会连带把内层平面算成"过孔不许打"**:贴片焊盘对内层透明、 过孔穿平面本来就有反焊盘,可 `viafree` 是「所有层 EDT 的最小值」, 把 `+12V` 平面塞进 `edt[16]` 之后,整块 +12V 底下一个孔都打不了。 ⇒ 这里的场把 **L3 分成两张 EDT**:`edt[16]`(含平面,给走线用)与 `cu16`(只含真铜:通孔、过孔、别的网的 L3 走线,给**打孔**用)。 引擎那一版没有这个区分,硬加会踩这一条。 ## 拓扑:为什么只有中桥那 13 mm 还在外层(§10.1 的 Inner2 分块图) Inner2 上东西两半之间**只有一条隔断是不能割的**:`12V-SW+BR` 的中桥 (x 61.5~72.6,11.1 mm,喂南西块 3.6 A,`pcb1_pour.CUTS` 里三条断面都盯着它)。 横着割一刀 = 南西块断电,而 I²C 要过去就必须横着割 ⇒ **中桥只能外层跳过去**。 (上一轮 `pcb1_route_sig` 自己也撞出了同一个结论:板上现成的 `SDA` 就是 过孔 (61.00,49.30) ↔ 底层 ↔ 过孔 (73.90,51.20) 这么跳的。) 除此之外内层随便走: - `+3V3` 两大块(`3V3-SW` 57.7×17.5、`3V3-SE` 更大)**只有 0.5 A**, IPC-2221 内层 0.5 oz 下 0.40 mm 就够 —— 开一条 1 mm 的槽是「切掉 6%」而不是「切断」。 这里给它 `PEN_3V3` 的代价(不是障碍),布线器能绕就绕,绕不开就穿。 - `GND-FILL-L3` 是底填,L2 才是「不许割断」的那张完整地平面(§10.1)。 - `+12V` 三块一律**硬障碍**,一格都不进。 ⚠️ 所以下发之后**必须重铺铜**:新的 L3 走线会让 `+3V3` / GND 底填按净距退让, 而 `pcb1_pour.py --verify` 是**回读实测铜**量断面的,能量出真的被啃窄了多少。 ## 器件簇:哪些铜留着不动 "只挪跨板长干线"(用户 2026-09-06 拍板)。做法不是按长度删线段 —— 那样会把 簇内那些短接线也切碎 —— 而是划 `CLUSTERS` 里那几个框:**框内的 SDA/SCL 铜原样保留, 框外的一律拆掉**,然后按剩下的碎片重连。控制块内部(U20 ↔ U6 ↔ U7 ↔ R604/605 ↔ U23) 那一坨短线因此一根都不用重布。 """ import io import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import numpy as np # noqa: E402 from scipy import ndimage # noqa: E402 from scipy.sparse import coo_matrix # noqa: E402 from scipy.sparse.csgraph import dijkstra # noqa: E402 import pcb1_netlist as NL # noqa: E402 import pcb1_pour as PO # noqa: E402 import pcb1_route_sig as RS # noqa: E402 E = RS.E # 引擎(polish / _l_shapes / board_mask 从这里借) #: 要挪进内层的网络。判据是「**跨板长跑**」:焊盘散在整块板上、在外层要横穿好几条通道。 #: `INA_ALERT` 的脚就挨着 I²C(六个 INA226 的 ALERT),形状一模一样。 #: ⚠️ `USB_DP`/`USB_DM` **不许进这个表** —— 90 Ω 差分以 L2 完整地平面为参考(§10.1), #: 换到 Inner2 参考面就没了,阻抗模型整个作废。 #: ⚠️ `INA_ALERT` 形状和 I²C 一模一样(8 个焊盘跨 135 mm,脚就挨着 INA226 的 SDA/SCL), #: **但 2026-09-06 试着加进来没跑通**:它板上的铜整片都在顶层、且集中在控制块那一带, #: 六条连线里有四条报「L2 源0 / L16 源0」—— 主碎片周围一个合法孔位都没有,扎不下去。 #: 要挪它得先把控制块那一带的顶层拆开,不是改这一行的事。 NETS = ('SCL', 'SDA', 'BUCK_PG') # ── 侧键模拟线 POGO3_PWR / POGO3_VDN / POGO4_VDN / POGO5_PWR / POGO5_VUP(第二十六轮加) # 2026-09-06 随 74HC595 + 按键 MOS 整窝挪到载板(§26.23)之后,下板上不再有 POGO* 网络, # 已从候选表拿掉;它们在 Inner2 上的铜由 pcb_prune_nets.py 一并清掉了。 L_TOP, L_BOT, L3 = 1, 2, 16 LAYS = (L_TOP, L_BOT, L3) GRID = 0.1 CLR = 0.20 WIRE = 0.20 # I²C 线宽(`pcb1_rules` 的 SIG 类默认) VIA_D, VIA_H = 0.61, 0.305 HOLE_CLR = 0.30 VIA_COST = 2.5 # 换一次层折算成多少 mm 路程 BW, BH = 160.0, 125.0 BBOX_PAD = 14.0 VERBOSE = ('--v' in sys.argv) QERR = GRID * math.sqrt(2) / 2 #: 层代价。外层贵 3 倍 —— 短跳线(中桥那 13 mm,代价 39)仍然比绕远划算, #: 而几十毫米的长跑一定落到 L3 上。改这个数就是改「多长才值得挪进内层」。 LAY_COST = {L_TOP: 3.0, L_BOT: 3.0, L3: 1.0} #: L3 上穿 `+3V3` 平面的代价倍数。**这个数决定的不是长度,是平面会不会被切出孤岛**: #: 1.8 的时候 SCL 沿 y=50 在 `3V3-SW` 里横跑了 12.7 mm、SDA 又从东边界斜插进来, #: 两根线和平面东边界围出一块孤岛(里面有 R415.2 的 +3V3 焊盘,见 `plane_report`)。 #: 4.0 之后干线改走北边的 GND 底填,只在器件附近**扎一个盲端**进平面 —— #: 盲端不切平面,连通域数不变。堵孤岛重绕是不收敛的(越堵越大,实测四轮)。 PEN_3V3 = 4.0 #: 上一轮把电源面切出孤岛的那些格(L3 上当硬障碍)。`main()` 每轮往里加,见 `plane_report`。 BLOCK = [] #: 器件簇:框内的旧 I²C 铜保留,框外拆掉重来。 CLUSTERS = { 'CTRL': (76.0, 14.0, 116.0, 60.0), # U20 / U6 / U7 / R604,R605 / U23 'U8': (11.5, 45.5, 18.5, 52.5), # CH1 INA226 'U11': (43.5, 45.5, 50.5, 53.5), # CH4 INA226 'U9': (49.5, 73.5, 56.5, 80.0), # CH2 INA226 'U10': (137.5, 71.5, 145.0, 80.0), # CH3 INA226 'U12': (121.5, 45.5, 129.5, 52.5), # CH5 INA226 'U13': (3.0, 90.5, 11.0, 100.0), # 输入侧 INA226 'J24': (30.0, 60.5, 38.5, 67.0), # 排针(通孔针,L3 上直接落脚) # 五路 Buck 控制器:`BUCK_PG` 在 U*.14 上。**不划框的话整条出线短线也被拆掉**, # 而那几个脚在 0.5 mm 脚距的 QFN 上,`entry_stub` 重建不回来 ⇒ 整个网络断成 6 片 # (2026-09-06 实测,连通性保险当场拦下)。框只罩住芯片本体 + 0.6 mm。 'U1': (14.4, 25.1, 19.2, 29.9), 'U2': (48.7, 95.6, 53.5, 100.4), 'U3': (137.1, 95.6, 141.9, 100.4), 'U4': (46.1, 25.1, 50.9, 29.9), 'U5': (125.6, 25.1, 130.4, 29.9), } # ══════════════════ 几何小工具 ══════════════════ def poly_mask(poly, XX, YY): u"""格心在多边形内(偶奇判据,矢量化)。`PO.inside` 的 numpy 版 —— +12V 三块加起来 200 万个格心,逐点调 `PO.inside` 要几十秒。""" inside = np.zeros(XX.shape, bool) n = len(poly) for i in range(n): x1, y1 = poly[i] x2, y2 = poly[(i + 1) % n] if y1 == y2: continue cond = (y1 > YY) != (y2 > YY) with np.errstate(invalid='ignore', divide='ignore'): xint = (x2 - x1) * (YY - y1) / (y2 - y1) + x1 inside ^= cond & (XX < xint) return inside def seg_dist2(XX, YY, x0, y0, x1, y1): u"""格心到线段的距离平方。""" dx, dy = x1 - x0, y1 - y0 L2 = dx * dx + dy * dy if L2 < 1e-12: return (XX - x0) ** 2 + (YY - y0) ** 2 t = np.clip(((XX - x0) * dx + (YY - y0) * dy) / L2, 0.0, 1.0) return (XX - x0 - t * dx) ** 2 + (YY - y0 - t * dy) ** 2 def pad_layers(p): return LAYS if p['th'] else (p['plane'],) def in_box(box, x, y): return box[0] <= x <= box[2] and box[1] <= y <= box[3] # ══════════════════ 板上的铜:哪些留、哪些拆 ══════════════════ def split_old(pads, vias, lines): u"""把 I²C 的旧铜分成「簇内保留」与「簇外拆掉」两堆。 判据是**整条线段/整个过孔都落在同一个簇框里**才留 —— 一端在框内一端在框外的 就是要拆的干线。 """ keep_l, drop_l, keep_v, drop_v = [], [], [], [] for i, l in enumerate(lines): if l['net'] not in NETS: continue # L3 上的 I²C 铜一律拆:板上原来一根都没有,有的全是本脚本上一轮画的, # 不拆的话重跑不幂等(簇框里会一轮一轮攒下旧干线的尾巴)。 hit = (l['lay'] != L3) and [k for k, b in CLUSTERS.items() if in_box(b, l['x0'], l['y0']) and in_box(b, l['x1'], l['y1'])] (keep_l if hit else drop_l).append(i) for i, v in enumerate(vias): if v['net'] not in NETS: continue hit = [k for k, b in CLUSTERS.items() if in_box(b, v['x'], v['y'])] (keep_v if hit else drop_v).append(i) return keep_l, drop_l, keep_v, drop_v # ══════════════════ 碎片(同一个网络里已经连在一起的铜) ══════════════════ class DSU(object): def __init__(self, n): self.p = list(range(n)) def find(self, a): while self.p[a] != a: self.p[a] = self.p[self.p[a]] a = self.p[a] return a def union(self, a, b): ra, rb = self.find(a), self.find(b) if ra != rb: self.p[ra] = rb def _shape(kind, obj): u"""统一成 ('rect'|'seg'|'via', ..., 半径, 层元组),方便算距离。""" if kind == 'pad': return ('rect', obj['x'], obj['y'], obj['hw'], obj['hh'], 0.0, pad_layers(obj)) if kind == 'via': return ('via', obj['x'], obj['y'], 0.0, 0.0, obj['r'], LAYS) return ('seg', obj['x0'], obj['y0'], obj['x1'], obj['y1'], obj['w'] / 2.0, (obj['lay'],)) def _near(a, b, tol=1e-6): u"""两个形状在**共同的层**上是否相碰。""" if not (set(a[6]) & set(b[6])): return False def as_seg(s): if s[0] == 'seg': return (s[1], s[2]), (s[3], s[4]), s[5] return (s[1], s[2]), (s[1], s[2]), s[5] if a[0] == 'rect' or b[0] == 'rect': r, o = (a, b) if a[0] == 'rect' else (b, a) p, q, rad = as_seg(o) return RS.D.seg_rect(p, q, r[1], r[2], r[3], r[4]) <= rad + tol (p0, p1, ra), (q0, q1, rb) = as_seg(a), as_seg(b) return RS.D.seg_seg(p0, p1, q0, q1) <= ra + rb + tol def frags(net, pads, vias, lines): u"""该网络现有的铜 -> 若干连通碎片。返回 [[形状, ...], ...]。""" items = [_shape('pad', p) for p in pads if p['net'] == net] items += [_shape('via', v) for v in vias if v['net'] == net] items += [_shape('seg', l) for l in lines if l['net'] == net] d = DSU(len(items)) for i in range(len(items)): for j in range(i + 1, len(items)): if _near(items[i], items[j]): d.union(i, j) out = {} for i, s in enumerate(items): out.setdefault(d.find(i), []).append(s) return list(out.values()) # ══════════════════ 三层障碍场 ══════════════════ class Field(object): u"""一个搜索窗口里的三层障碍距离场。 `edt[L]` = 到最近**异网铜**的距离。L3 那一层额外带一张 `cu16`: 只含真铜、不含铺铜平面,**给打孔判据用**(见文件头)。 """ RIM = 1.4 def __init__(self, bbox, net, pads, vias, lines, extra=()): x0, y0, x1, y1 = bbox r = self.RIM self.g = GRID self.qerr = QERR self.x0 = math.floor((x0 - r) / GRID) * GRID self.y0 = math.floor((y0 - r) / GRID) * GRID self.nx = int(round((x1 + r - self.x0) / GRID)) + 2 self.ny = int(round((y1 + r - self.y0) / GRID)) + 2 xs = self.x0 + np.arange(self.nx) * GRID ys = self.y0 + np.arange(self.ny) * GRID XX, YY = np.meshgrid(xs, ys) self.XX, self.YY = XX, YY occ = {L: np.zeros(XX.shape, bool) for L in LAYS} cu16 = np.zeros(XX.shape, bool) # L3 上的真铜(不含平面) hole = np.zeros(XX.shape, bool) own = {L: np.zeros(XX.shape, bool) for L in LAYS} # 本网铜:放行 # ⚠️ **每个图元只在自己的包围盒里算**。第一版对着整个窗口做 numpy 运算, # 5000 个图元 × 30 万格 = 十几亿次浮点,一条连线要几分钟; # 切窗口之后同样的场是几十毫秒。 def _win(xa, ya, xb, yb): i0 = int(math.floor((xa - self.x0) / GRID)) i1 = int(math.ceil((xb - self.x0) / GRID)) j0 = int(math.floor((ya - self.y0) / GRID)) j1 = int(math.ceil((yb - self.y0) / GRID)) i0 = max(0, i0); j0 = max(0, j0) i1 = min(self.nx - 1, i1); j1 = min(self.ny - 1, j1) if i1 < i0 or j1 < j0: return None return i0, i1, j0, j1 def mark(arr, kind, a, b, c, d_, rad): if kind == 'rect': w = _win(a - c - rad, b - d_ - rad, a + c + rad, b + d_ + rad) if w is None: return i0, i1, j0, j1 = w sx, sy = XX[j0:j1 + 1, i0:i1 + 1], YY[j0:j1 + 1, i0:i1 + 1] m = (np.abs(sx - a) <= c + rad) & (np.abs(sy - b) <= d_ + rad) elif kind == 'disc': w = _win(a - rad, b - rad, a + rad, b + rad) if w is None: return i0, i1, j0, j1 = w sx, sy = XX[j0:j1 + 1, i0:i1 + 1], YY[j0:j1 + 1, i0:i1 + 1] m = (sx - a) ** 2 + (sy - b) ** 2 <= rad * rad else: w = _win(min(a, c) - rad, min(b, d_) - rad, max(a, c) + rad, max(b, d_) + rad) if w is None: return i0, i1, j0, j1 = w sx, sy = XX[j0:j1 + 1, i0:i1 + 1], YY[j0:j1 + 1, i0:i1 + 1] m = seg_dist2(sx, sy, a, b, c, d_) <= rad * rad arr[j0:j1 + 1, i0:i1 + 1] |= m for p in pads: tgt = own if p['net'] == net else occ for L in pad_layers(p): if p['net'] == net: mark(own[L], 'rect', p['x'], p['y'], max(0.0, p['hw'] - WIRE / 2.0), max(0.0, p['hh'] - WIRE / 2.0), GRID / 2.0) else: mark(occ[L], 'rect', p['x'], p['y'], p['hw'], p['hh'], QERR) if p['th'] and p['net'] != net: mark(cu16, 'rect', p['x'], p['y'], p['hw'], p['hh'], QERR) mark(hole, 'disc', p['x'], p['y'], 0, 0, max(p['hw'], p['hh']) + QERR) for v in vias: tgt = own if v['net'] == net else occ for L in LAYS: mark(tgt[L], 'disc', v['x'], v['y'], 0, 0, (v['r'] - WIRE / 2.0 + GRID / 2.0) if v['net'] == net else (v['r'] + QERR)) if v['net'] != net: mark(cu16, 'disc', v['x'], v['y'], 0, 0, v['r'] + QERR) mark(hole, 'disc', v['x'], v['y'], 0, 0, VIA_H / 2.0 + QERR) for l in lines: tgt = own if l['net'] == net else occ mark(tgt[l['lay']], 'seg', l['x0'], l['y0'], l['x1'], l['y1'], (max(0.0, l['w'] / 2.0 - WIRE / 2.0) + GRID / 2.0) if l['net'] == net else (l['w'] / 2.0 + QERR)) if l['lay'] == L3 and l['net'] != net: mark(cu16, 'seg', l['x0'], l['y0'], l['x1'], l['y1'], l['w'] / 2.0 + QERR) # ⚠️ `extra` 里**必须带网络**。第一版不带,于是同一个网络自己刚布好的 # 前几条路径被当成障碍标进 `occ`,下一条连线的**源掩膜整个变成空的** # (实测「L1 源0/汇36 L16 源0/汇0」,看上去像"布不通",其实是把自己挡死了)。 for kind, args, enet in extra: same = (enet == net) if kind == 'seg': x0_, y0_, x1_, y1_, w_, lay_ = args if same: mark(own[lay_], 'seg', x0_, y0_, x1_, y1_, max(0.0, w_ / 2.0 - WIRE / 2.0) + GRID / 2.0) else: mark(occ[lay_], 'seg', x0_, y0_, x1_, y1_, w_ / 2.0 + QERR) if lay_ == L3: mark(cu16, 'seg', x0_, y0_, x1_, y1_, w_ / 2.0 + QERR) else: vx, vy = args for L in LAYS: mark(own[L] if same else occ[L], 'disc', vx, vy, 0, 0, (VIA_D / 2.0 - WIRE / 2.0 + GRID / 2.0) if same else (VIA_D / 2.0 + QERR)) if not same: mark(cu16, 'disc', vx, vy, 0, 0, VIA_D / 2.0 + QERR) mark(hole, 'disc', vx, vy, 0, 0, VIA_H / 2.0 + QERR) def _hits(poly): u"""多边形包围盒和本窗口有没有交集(不交就整块跳过)。""" px = [q[0] for q in poly]; py = [q[1] for q in poly] return not (max(px) < xs[0] or min(px) > xs[-1] or max(py) < ys[0] or min(py) > ys[-1]) # ── 外层铺铜(顶层 8 A 宽铜 / 底层 VOn_P 小岛)── for rr in PO.REGIONS: if rr['layer'] in (L_TOP, L_BOT) and rr['net'] != net and _hits(rr['poly']): occ[rr['layer']] |= poly_mask(rr['poly'], XX, YY) # ── Inner2:+12V 三块是硬障碍;+3V3 只加代价 ── pen = np.ones(XX.shape, np.float32) for rr in PO.REGIONS: if rr['layer'] != PO.L_PWR or not _hits(rr['poly']): continue if rr['net'] == '+12V': occ[L3] |= poly_mask(rr['poly'], XX, YY) elif rr['net'] == '+3V3': pen[poly_mask(rr['poly'], XX, YY)] = PEN_3V3 self.pen = pen # ── 上一轮把电源面切出孤岛的那些格:这一轮 L3 上当硬障碍(`repair()`)── for bx0, by0, bx1, by1 in BLOCK: mark(occ[L3], 'rect', (bx0 + bx1) / 2.0, (by0 + by1) / 2.0, (bx1 - bx0) / 2.0, (by1 - by0) / 2.0, QERR) # ── 板外 + 天线区 + 窗口外圈墙 ── out = ~E.board_mask(XX, YY) ax0, ay0, ax1, ay1 = RS.ANT out |= (XX >= ax0 - CLR) & (XX <= ax1 + CLR) & \ (YY >= ay0 - CLR) & (YY <= ay1 + CLR) nb = max(1, int(round(r / GRID))) wall = np.zeros(XX.shape, bool) wall[:nb, :] = True; wall[-nb:, :] = True wall[:, :nb] = True; wall[:, -nb:] = True for L in LAYS: occ[L] |= out | wall own[L] &= ~(out | wall) cu16 |= out | wall hole |= out | wall # ⚠️ **本网铜的放行不能按「格心落在本网铜里」判**。第一版是 # `free = edt >= need | own`,于是 0.2 mm 的线可以贴着自家焊盘的**边**走: # U8.5 的焊盘只有 0.28 宽,线心压在它西边缘 ⇒ 线的西侧探出去 0.1, # 离邻脚 U8.4 只剩 0.06。真正的判据是「本网铜**完全盖住**这根线」, # 所以 `own` 是**按半线宽内缩之后**标的(上面每一处 `- WIRE/2`), # 留 `GRID/2` 是格心对不上中心线的容差。 # 过孔不吃这个放行 —— `edt` 本来就不含本网铜,`via_ok` 直接按真净距判。 self.own = own self.edt = {L: ndimage.distance_transform_edt( ~occ[L], sampling=GRID).astype(np.float32) for L in LAYS} self.cu16 = ndimage.distance_transform_edt( ~cu16, sampling=GRID).astype(np.float32) self.holefree = ndimage.distance_transform_edt( ~hole, sampling=GRID).astype(np.float32) # ── 判据 ── def free(self, lay, w=WIRE): return (self.edt[lay] >= CLR + w / 2.0 + self.qerr) | self.own[lay] def via_ok(self): u"""过孔位:外层两层要让开异网铜,L3 只看**真铜**(平面自己会挖反焊盘), 再加不分网络的钻孔间距。""" # ⚠️ **过孔不吃 `own` 那条放行**。`edt` 里本来就不含本网铜,所以这就是真净距。 # 第一版写成 `(edt >= need) | own`,于是 Φ0.61 的孔被按在 0.28 宽的 # INA226 焊盘上 —— "在 L3 上走到焊盘正上方再落下去"正是最短路, # 实测「孔-焊盘 SDA(53.00,75.10) vs U9.5 净距 −0.033」,一口气 30 条。 need = CLR + VIA_D / 2.0 + self.qerr m = (self.edt[L_TOP] >= need) & (self.edt[L_BOT] >= need) \ & (self.cu16 >= need) return m & (self.holefree >= VIA_H / 2.0 + HOLE_CLR + self.qerr) def _idx(self, x, y): return (int(round((x - self.x0) / self.g)), int(round((y - self.y0) / self.g))) def clear_seg(self, x0, y0, x1, y1, lay, w): need = CLR + w / 2.0 + self.qerr L = math.hypot(x1 - x0, y1 - y0) n = max(2, int(L / (self.g / 2.0)) + 1) need += (L / (n - 1)) / 2.0 if n > 1 else 0.0 for k in range(n): t = k / float(n - 1) 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 self.own[lay][j, i]: return False return True def mask_of(self, shapes, w=WIRE): u"""一组形状 -> {层: 布尔数组}(该形状盖住的、且可走的格)。""" m = {L: np.zeros((self.ny, self.nx), bool) for L in LAYS} for s in shapes: if s[0] == 'rect': hit = (np.abs(self.XX - s[1]) <= s[3]) & \ (np.abs(self.YY - s[2]) <= s[4]) elif s[0] == 'via': hit = (self.XX - s[1]) ** 2 + (self.YY - s[2]) ** 2 <= s[5] ** 2 else: hit = seg_dist2(self.XX, self.YY, s[1], s[2], s[3], s[4]) \ <= s[5] ** 2 for L in s[6]: m[L] |= hit for L in LAYS: m[L] &= self.free(L, w) return m # ══════════════════ 三层 Dijkstra ══════════════════ def route(f, src, tgt, w=WIRE): u"""多源多汇最短路。返回 [(i, j, 层), ...] 或 None。 代价 = 路程 × 层代价 × L3 上的 `+3V3` 惩罚 + 每次换层 `VIA_COST`。 """ nx, ny, g = f.nx, f.ny, f.g N = nx * ny ok = [f.free(L, w) for L in LAYS] rows, cols, vals = [], [], [] for li, L in enumerate(LAYS): base = (f.pen if L == L3 else np.ones_like(f.pen)) * LAY_COST[L] flat_ok = ok[li] for dx, dy in ((1, 0), (0, 1), (1, 1), (1, -1)): a = flat_ok[max(0, -dy):ny - max(0, dy), max(0, -dx):nx - max(0, dx)] b = flat_ok[max(0, dy):ny - max(0, -dy), max(0, dx):nx - max(0, -dx)] good = a & b if not good.any(): continue jj, ii = np.nonzero(good) j0 = jj + max(0, -dy); i0 = ii + max(0, -dx) j1 = jj + max(0, dy); i1 = ii + max(0, dx) ca = base[j0, i0]; cb = base[j1, i1] d = g * (math.sqrt(2) if dx and dy else 1.0) rows.append(j0.astype(np.int64) * nx + i0 + li * N) cols.append(j1.astype(np.int64) * nx + i1 + li * N) vals.append((d * (ca + cb) / 2.0).astype(np.float32)) # ⚠️ **不能写成 `via_ok() & ok[0] & ok[1] & ok[2]`**。`ok[2]` 是「L3 上能不能走线」, # 里面含 +12V 平面 —— 那样整块 +12V 底下一个孔都打不了,而穿平面的孔本来就有反焊盘。 # 落脚点合法与否由 `via_ok()`(外层铜 + L3 真铜 + 钻孔间距)单独判; # 孔落在不能走线的格上也没关系,那个格没有任何移动边,路径在那儿待不住。 vm = f.via_ok() jj, ii = np.nonzero(vm) if len(jj): idx = jj.astype(np.int64) * nx + ii for a in range(len(LAYS)): for b in range(a + 1, len(LAYS)): rows.append(idx + a * N) cols.append(idx + b * 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=(len(LAYS) * N, len(LAYS) * N)).tocsr() def flat(mask): out = [] for li, L in enumerate(LAYS): jj_, ii_ = np.nonzero(mask[L] & ok[li]) out.append(jj_.astype(np.int64) * nx + ii_ + li * N) return np.concatenate(out) if out else np.array([], np.int64) si, ti = flat(src), flat(tgt) if not len(si) or not len(ti): return None D, pred, _s = 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 path, cur = [], int(ti[k]) while cur >= 0: li, rem = divmod(cur, N) j, i = divmod(rem, nx) path.append((i, j, LAYS[li])) cur = int(pred[cur]) path.reverse() return path # ══════════════════ 细间距焊盘的直出段 ══════════════════ def stub_lines(net, pads, vias, lines): u"""给**还没有铜接着**的 I²C 焊盘补一段「直出焊盘外」的短线,路由器从这一截接手。 INA226 是 0.5 mm 脚距:U8.4/U8.5 中心只隔 0.5、焊盘边缘之间约 0.22 mm。 直接把 0.2 mm 的线或 Φ0.61 的过孔按到焊盘上,**对邻脚必然违规** (实测「孔-焊盘 SCL(15.10,49.90) vs U8.4 净距 −0.145」)。 直出段由引擎的 `entry_stub` 定方向(长轴优先、否则背离元件形心)。 """ epads = [dict(p, lay=(12 if p['th'] else p['plane'])) for p in pads] obst = E.shapes_of(epads, vias, lines) + RS.pour_rects() mine_seg = [_shape('seg', l) for l in lines if l['net'] == net] mine_via = [_shape('via', v) for v in vias if v['net'] == net] out = [] for p, q in zip(pads, epads): if p['net'] != net or p['th']: continue sp = _shape('pad', p) if any(_near(sp, o) for o in mine_seg + mine_via): continue # 簇内那些还留着的铜已经接上了 r = E.entry_stub(q, epads, obst, net, WIRE) if not r: continue (ex, ey), ws = r out.append(dict(net=net, lay=p['plane'], x0=p['x'], y0=p['y'], x1=ex, y1=ey, w=ws)) return out # ══════════════════ 规划:碎片 -> Prim -> 路径 ══════════════════ def _bbox(shapes): 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[5], s[1] + s[5]]; ys += [s[2] - s[5], s[2] + s[5]] else: xs += [s[1] - s[5], s[3] + s[5]]; ys += [s[2] - s[5], s[4] + s[5]] return min(xs), min(ys), max(xs), max(ys) def _cen(s): return ((s[1] + s[3]) / 2.0, (s[2] + s[4]) / 2.0) if s[0] == 'seg' \ else (s[1], s[2]) def _pair_dist(fa, fb): u"""两个碎片之间最近的一对形状(按形状中心的直线距离,够用)。""" best = (1e9, None, None) for a in fa: ax, ay = _cen(a) for b in fb: bx, by = _cen(b) d = math.hypot(ax - bx, ay - by) if d < best[0]: best = (d, a, b) return best def plan_net(net, pads, vias, lines, extra): u"""把一个网络的碎片连成一片。返回 ([路径, ...], 剩余碎片数)。""" fr = frags(net, pads, vias, lines) print(u' %s:%d 个碎片,要补 %d 条' % (net, len(fr), len(fr) - 1)) if len(fr) <= 1: return [], 0 fr.sort(key=len, reverse=True) tree, rest = list(fr[0]), fr[1:] paths, extra = [], list(extra) while rest: best = (1e9, None, None, None) for k, f_ in enumerate(rest): d, sa, sb = _pair_dist(tree, f_) if d < best[0]: best = (d, k, sa, sb) _d, k, sa, sb = best tgt_frag = rest[k] x0, y0, x1, y1 = _bbox([sa, sb]) for s in tgt_frag: bx0, by0, bx1, by1 = _bbox([s]) x0 = min(x0, bx0); y0 = min(y0, by0) x1 = max(x1, bx1); y1 = max(y1, by1) bbox = (max(0.0, x0 - BBOX_PAD), max(0.0, y0 - BBOX_PAD), min(BW, x1 + BBOX_PAD), min(BH, y1 + BBOX_PAD)) f = Field(bbox, net, pads, vias, lines, extra) near_tree = [s for s in tree if not (_bbox([s])[2] < bbox[0] or _bbox([s])[0] > bbox[2] or _bbox([s])[3] < bbox[1] or _bbox([s])[1] > bbox[3])] cells = route(f, f.mask_of(near_tree), f.mask_of(tgt_frag)) if cells is None: sm, tm = f.mask_of(near_tree), f.mask_of(tgt_frag) det = u' '.join(u'L%d 源%d/汇%d' % (L, int(sm[L].sum()), int(tm[L].sum())) for L in LAYS) print(u' ✗ %s: (%.2f,%.2f) -> (%.2f,%.2f) 布不通' u'(窗口 %.1f,%.1f - %.1f,%.1f;%s)' % ((net,) + _cen(sa) + _cen(sb) + (bbox[0], bbox[1], bbox[2], bbox[3], det))) rest.pop(k) continue chunks = E.polish(f, cells, WIRE) path = [] for lay, xy in chunks: for x, y in xy: path.append((round(x, 4), round(y, 4), lay, WIRE)) # ⚠️ **最后一点上的过孔必须补一个重复点**,否则 `expand()` 会静默丢掉它 # (引擎 `_end_via` 的注释:净距校核和 DRC 都抓不到)。这里的路径几乎都是 # 「L3 走到焊盘正上方再落下去」,最后一段是单点 chunk,全都踩这一条: # 实测末段被画在 L3 上、过孔没有,`frags` 报「U8.4 还是单独一片」。 path = E._end_via(path) paths.append(path) if VERBOSE: L = sum(math.hypot(path[i + 1][0] - path[i][0], path[i + 1][1] - path[i][1]) for i in range(len(path) - 1)) lays = sorted(set(q[2] for q in path)) print(u' ✓ (%.2f,%.2f) -> (%.2f,%.2f) %.1f mm 层%s' % (path[0][0], path[0][1], path[-1][0], path[-1][1], L, lays)) segs, vs = E.expand(path) for x0_, y0_, x1_, y1_, lay, w in segs: extra.append(('seg', (x0_, y0_, x1_, y1_, w, lay), net)) for vx, vy in vs: extra.append(('via', (vx, vy), net)) tree += tgt_frag + [('seg', s[0], s[1], s[2], s[3], s[5] / 2.0, (s[4],)) for s in segs] tree += [('via', vx, vy, 0.0, 0.0, VIA_D / 2.0, LAYS) for vx, vy in vs] rest.pop(k) return paths, 0 # ══════════════════ 45° 化(全板不许直角走线) ══════════════════ def l3_pwr_pads(): u"""Inner2 上的 `+12V` 三块 -> 一串"假焊盘",好让引擎的 `chamfer_routes` 也知道那里不能切进去(`seg_slack` 只认焊盘 / 过孔 / 走线三样,不认铺铜)。 三块都是**直角多边形**,所以按 y 扫描线切成横条是精确的,不是栅格近似。 """ out = [] for rr in PO.REGIONS: if rr['layer'] != PO.L_PWR or rr['net'] != '+12V': continue poly = rr['poly'] ys = sorted(set(q[1] for q in poly)) for ya, yb in zip(ys, ys[1:]): ymid = (ya + yb) / 2.0 xs = [] for i in range(len(poly)): x1, y1 = poly[i] x2, y2 = poly[(i + 1) % len(poly)] if (y1 > ymid) != (y2 > ymid): xs.append((x2 - x1) * (ymid - y1) / (y2 - y1) + x1) xs.sort() for xa, xb in zip(xs[0::2], xs[1::2]): out.append(dict(net=rr['net'], x=(xa + xb) / 2.0, y=(ya + yb) / 2.0, hw=(xb - xa) / 2.0, hh=(yb - ya) / 2.0, lay=PO.L_PWR, ref='POUR', num=rr['name'], tag='POUR.' + rr['name'])) return out def chamfer(routes, pads, vias, lines): u"""切 45° 拐角:全板不许直角走线(`pcb1_route_sig` §③b,用户 2026-09-06 拍板)。 ⚠️ **这里用 `smooth=False`**,和 `pcb1_route_sig` 不一样。`smooth=True` 会 **挪动**路径(抹微台阶,最多 0.15 mm)而不只是拿掉铜,而 `seg_slack` 拿的是切之前的 邻线 ⇒ 两条本轮新画的线会被各自抹到对方身上(那边实测压到 −0.199, 要靠"整条退回未切版本"再核四轮才收得住)。只切拐角的话切掉的铜是原来两段的**子集**, 不可能切出新的违规,`chamfer45` 自己的 `ok()` 又逐段核过 —— 一轮就够。 直线段本来就少(`polish` 已经并过),抹台阶带来的收益不值这个风险。 """ chk = [dict(p, lay=(12 if p['th'] else p['plane'])) for p in pads] + RS.pour_pads(RS.pour_rects()) + l3_pwr_pads() rd = {u'%s%s%03d' % (net, chr(0), i): path for i, (net, path) in enumerate(routes)} cut = RS.D.chamfer_routes(rd, chk, vias, lines, smooth=False) return [(k.split(chr(0))[0], cut[k]) for k in sorted(cut)] # ══════════════════ 校核(下发前后同一段代码) ══════════════════ def _poly_gap(poly, a, b): u"""线段到多边形的距离;线段任一端在多边形里返回 -1。""" if PO.inside(poly, a[0], a[1]) or PO.inside(poly, b[0], b[1]): return -1.0 m = 1e9 for i in range(len(poly)): p, q = poly[i], poly[(i + 1) % len(poly)] m = min(m, RS.D.seg_seg(a, b, p, q)) return m def verify(routes, pads, vias, lines): u"""返回 [问题, ...]。空表 = 全过。""" bad = [] new_segs, new_vias = [], [] for net, path in routes: s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: if math.hypot(x1 - x0, y1 - y0) <= 5e-4: continue new_segs.append((net, lay, (x0, y0), (x1, y1), w)) for x, y in v_: new_vias.append((net, x, y)) # ① 新线 vs 板上旧铜 for net, lay, a, b, w in new_segs: need = CLR + w / 2.0 for p in pads: if p['net'] == net or lay not in pad_layers(p): continue g = RS.D.seg_rect(a, b, p['x'], p['y'], p['hw'], p['hh']) - w / 2.0 if g < CLR - 1e-6: bad.append(u'线-焊盘 %s vs %s 净距 %.3f' % (net, p['tag'], g)) for v in vias: if v['net'] == net: continue g = RS.D.seg_seg(a, b, (v['x'], v['y']), (v['x'], v['y'])) \ - w / 2.0 - v['r'] if g < CLR - 1e-6: bad.append(u'线-过孔 %s vs %s(%.2f,%.2f) 净距 %.3f' % (net, v['net'], v['x'], v['y'], g)) for l in lines: if l['net'] == net or l['lay'] != lay: continue g = RS.D.seg_seg(a, b, (l['x0'], l['y0']), (l['x1'], l['y1'])) \ - w / 2.0 - l['w'] / 2.0 if g < CLR - 1e-6: bad.append(u'线-线 %s vs %s @(%.2f,%.2f) 净距 %.3f' % (net, l['net'], l['x0'], l['y0'], g)) for rr in PO.REGIONS: if rr['net'] == net: continue if rr['layer'] != lay and not (lay == L3 and rr['layer'] == PO.L_PWR and rr['net'] == '+12V'): continue if rr['layer'] == PO.L_PWR and rr['net'] != '+12V': continue # +3V3 / GND 底填:铺铜自己退让,不算侵入 g = _poly_gap(rr['poly'], a, b) - w / 2.0 if g < CLR - 1e-6: bad.append(u'线-铺铜 %s vs %s 净距 %.3f' % (net, rr['name'], g)) # ② 新线之间(两个网络互相) for i in range(len(new_segs)): ni, li_, ai, bi, wi = new_segs[i] for j in range(i + 1, len(new_segs)): nj, lj, aj, bj, wj = new_segs[j] if ni == nj or li_ != lj: continue g = RS.D.seg_seg(ai, bi, aj, bj) - wi / 2.0 - wj / 2.0 if g < CLR - 1e-6: bad.append(u'新线-新线 %s vs %s @(%.2f,%.2f) 净距 %.3f' % (ni, nj, ai[0], ai[1], g)) # ③ 新孔:铜净距(外层 + L3 真铜)与钻孔间距(不分网络) for net, x, y in new_vias: pt = (x, y) for p in pads: if p['net'] == net: continue lays = pad_layers(p) if not (set(lays) & set(LAYS)): continue if not p['th'] and p['plane'] == L3: continue g = RS.D.seg_rect(pt, pt, p['x'], p['y'], p['hw'], p['hh']) \ - VIA_D / 2.0 if g < CLR - 1e-6: bad.append(u'孔-焊盘 %s(%.2f,%.2f) vs %s 净距 %.3f' % (net, x, y, p['tag'], g)) if p['th']: d = math.hypot(x - p['x'], y - p['y']) \ - VIA_H / 2.0 - max(p['hw'], p['hh']) if d < HOLE_CLR - 1e-6: bad.append(u'钻孔-通孔 %s(%.2f,%.2f) vs %s 间距 %.3f' % (net, x, y, p['tag'], d)) for v in vias: d = math.hypot(x - v['x'], y - v['y']) if v['net'] != net and d - VIA_D / 2.0 - v['r'] < CLR - 1e-6: bad.append(u'孔-孔铜 %s(%.2f,%.2f) vs %s 净距 %.3f' % (net, x, y, v['net'], d - VIA_D / 2.0 - v['r'])) if d - VIA_H < HOLE_CLR - 1e-6: bad.append(u'钻孔-钻孔 %s(%.2f,%.2f) vs %s(%.2f,%.2f) 间距 %.3f' % (net, x, y, v['net'], v['x'], v['y'], d - VIA_H)) for l in lines: if l['net'] == net or l['lay'] not in LAYS: continue g = RS.D.seg_seg(pt, pt, (l['x0'], l['y0']), (l['x1'], l['y1'])) \ - VIA_D / 2.0 - l['w'] / 2.0 if g < CLR - 1e-6: bad.append(u'孔-线 %s(%.2f,%.2f) vs %s 净距 %.3f' % (net, x, y, l['net'], g)) for i in range(len(new_vias)): for j in range(i + 1, len(new_vias)): ni, xi, yi = new_vias[i] nj, xj, yj = new_vias[j] d = math.hypot(xi - xj, yi - yj) if d < 1e-6: continue if ni != nj and d - VIA_D < CLR - 1e-6: bad.append(u'新孔-新孔铜 %s vs %s @(%.2f,%.2f) 净距 %.3f' % (ni, nj, xi, yi, d - VIA_D)) if d - VIA_H < HOLE_CLR - 1e-6: bad.append(u'新孔-新孔钻 %s vs %s @(%.2f,%.2f) 间距 %.3f' % (ni, nj, xi, yi, d - VIA_H)) # ④ 拐角 > 46°(全板不许直角走线) for net, path in routes: pts = [(p[0], p[1], p[2]) for p in path] for k in range(1, len(pts) - 1): a, b, c = pts[k - 1], pts[k], pts[k + 1] if a[2] != b[2] or b[2] != c[2]: continue v1 = (b[0] - a[0], b[1] - a[1]) v2 = (c[0] - b[0], c[1] - b[1]) n1 = math.hypot(*v1); n2 = math.hypot(*v2) if n1 < 1e-9 or n2 < 1e-9: continue cs = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2))) ang = math.degrees(math.acos(cs)) if ang > 46.0: bad.append(u'拐角 %s @(%.2f,%.2f) %.1f°' % (net, b[0], b[1], ang)) return bad def _dropped(o, lines, drop_l, vias, drop_v): u"""这一条板上铜是不是本轮要拆掉的 I²C 干线(按包围盒对齐,`obstacles()` 也是包围盒)。""" for i in drop_l: l = lines[i] h = l['w'] / 2.0 if (o[1] == l['lay'] and abs(o[2] - (min(l['x0'], l['x1']) - h)) < 1e-6 and abs(o[3] - (min(l['y0'], l['y1']) - h)) < 1e-6): return True for i in drop_v: v = vias[i] if o[1] is None and abs(o[2] - (v['x'] - v['r'])) < 1e-6 and abs(o[3] - (v['y'] - v['r'])) < 1e-6: return True return False # ══════════════════ 报告 / 主流程 ══════════════════ def summarize(routes): by = {L: 0.0 for L in LAYS} nv = 0 for net, path in routes: s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: by[lay] += math.hypot(x1 - x0, y1 - y0) nv += len(v_) tot = sum(by.values()) print(u' 新铜:%.1f mm —— Inner2 %.1f (%.0f%%) / 顶层 %.1f / 底层 %.1f,' u'新过孔 %d' % (tot, by[L3], 100.0 * by[L3] / tot if tot else 0, by[L_TOP], by[L_BOT], nv)) return by def virtual(pads, vias, lines, drop_l, drop_v): dl, dv = set(drop_l), set(drop_v) return ([l for i, l in enumerate(lines) if i not in dl], [v for i, v in enumerate(vias) if i not in dv]) def run_plan(pads, vias, lines, nets): u"""⚠️ **两个网络的直出段要先全部留好,再开始布线**(引擎 `plan_stubs` 那一条): 先布的网络会把后布的那个「出焊盘的 0.6 mm」占掉 —— U8/U9/U11/U12 的 SDA/SCL 就是紧挨着的两个脚。""" lines = list(lines) routes, extra = [], [] for net in nets: for l in stub_lines(net, pads, vias, lines): lines.append(l) routes.append((net, [(l['x0'], l['y0'], l['lay'], l['w']), (l['x1'], l['y1'], l['lay'], l['w'])])) if routes: print(u' 直出段:%d 条' % len(routes)) for net in nets: ps, _n = plan_net(net, pads, vias, lines, extra) for pth in ps: routes.append((net, pth)) s_, v_ = E.expand(pth) for x0, y0, x1, y1, lay, w in s_: extra.append(('seg', (x0, y0, x1, y1, w, lay), net)) lines.append(dict(net=net, lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w)) for vx, vy in v_: extra.append(('via', (vx, vy), net)) vias = list(vias) + [dict(net=net, x=vx, y=vy, r=VIA_D / 2.0)] return routes def connectivity(routes, pads, vias, lines): ln = list(lines) vi = list(vias) for net, path in routes: s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: if math.hypot(x1 - x0, y1 - y0) <= 5e-4: continue ln.append(dict(net=net, lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w)) for x, y in v_: vi.append(dict(net=net, x=x, y=y, r=VIA_D / 2.0)) out = [] for net in NETS: fr = frags(net, pads, vi, ln) groups = [] for f in sorted(fr, key=len, reverse=True): names = [] for p in pads: if p['net'] != net: continue if any(s[0] == 'rect' and abs(s[1] - p['x']) < 1e-6 and abs(s[2] - p['y']) < 1e-6 for s in f): names.append(p['tag']) groups.append(u'{%s}' % (u' '.join(names) or u'裸铜×%d' % len(f))) out.append((net, len(fr), u' '.join(groups))) return out def main(): args = sys.argv[1:] push = '--push' in args refresh = '--refresh' in args or push nets = list(NETS) if '--net' in args: nets = [a for a in args[args.index('--net') + 1:] if not a.startswith('-')] pads, vias, lines = NL.geom(refresh) print(u'板上:焊盘 %d / 过孔 %d / 走线 %d' % (len(pads), len(vias), len(lines))) # ⚠️ 只拆**这一轮要动的**网络(`--net` 限定时尤其重要) globals()['NETS'] = tuple(nets) keep_l, drop_l, keep_v, drop_v = split_old(pads, vias, lines) dlen = sum(math.hypot(lines[i]['x1'] - lines[i]['x0'], lines[i]['y1'] - lines[i]['y0']) for i in drop_l) klen = sum(math.hypot(lines[i]['x1'] - lines[i]['x0'], lines[i]['y1'] - lines[i]['y0']) for i in keep_l) print(u'旧 I²C 铜:簇内保留 %d 段 %.1f mm / %d 孔;' u'簇外拆掉 %d 段 %.1f mm / %d 孔' % (len(keep_l), klen, len(keep_v), len(drop_l), dlen, len(drop_v))) vlines, vvias = virtual(pads, vias, lines, drop_l, drop_v) print(u'── 规划(拆完之后的碎片重连)──') pads_raw, _nth, _nthb = PO.load_pads(False) obs_board = [o for o in PO.obstacles(False) if not (o[0] in NETS and _dropped(o, lines, drop_l, vias, drop_v))] routes = None for rnd in range(4): routes = run_plan(pads, vvias, vlines, nets) if not routes: sys.exit(u'一条也没算出来') routes = chamfer(routes, pads, vvias, vlines) msgs, rects = plane_report(routes, pads_raw, obs_board) if not msgs: print(u' 平面完整性:✅ Inner2 上 +12V 1 块 / +3V3 2 块,没切出孤岛') break for m in msgs: print(u' ⚠️ ' + m) if rnd == 3: print(u' ❌ 四轮都还有孤岛 —— 不下发') sys.exit(1) print(u' → 把这 %d 格钉成 L3 硬障碍,重绕一轮' % len(rects)) BLOCK.extend(rects) summarize(routes) bad = verify(routes, pads, vvias, vlines) for b in bad[:40]: print(u' ✗ ' + b) print(u' 校核:%s' % (u'✅ 全过' if not bad else u'❌ %d 条' % len(bad))) worse = [] for net, n, det in connectivity(routes, pads, vvias, vlines): was = len(frags(net, pads, vias, lines)) # 动板之前,板上实际的碎片数 flag = u'' if n > was: flag = u' ⛔ 比改动前差了(原来 %d 片)' % was worse.append(net) elif n < was: flag = u' ↑ 比改动前少 %d 片' % (was - n) print(u' 连通性 %s:%d 个碎片 %s%s' % (net, n, det if n > 1 else u'✅', flag)) if bad: sys.exit(1) if worse: # ⚠️ 这条保险是必需的:本脚本会把这些网络在簇框外的铜**全部拆掉**重来。 # 拆得动、重连不上的话,板子会比动手之前更差,而净距校核照样全绿。 # # 但**不要因此把整轮毙掉** —— `NETS` 是一张「候选表」,里面难免有挪不动的 # (2026-09-06 实测 `BUCK_PG`:五个分支要去板子四角,而 L3 上 y<34 往东 # 只剩 x115~116 那条 1.0 mm 缝,`+12V` 把别的通道全占了)。 # 把变差的那几个**踢出去重算一遍**,剩下的照常下发。 print(u' → %s 挪进内层会变差,踢出候选表重算一轮' % ' / '.join(worse)) keep = [n for n in nets if n not in worse] if not keep: sys.exit(u'⛔ 候选表里一个都留不下 —— 不下发') # ⚠️ **拆除清单必须跟着重算**。第一版只换了 `run_plan` 的网络表, # 而 `drop_l/drop_v` 还是按完整候选表算的 ⇒ `--push` 会把被踢掉那个网络的 # 铜删掉、又不给它重布,板子直接变差(净距校核照样全绿)。 globals()['NETS'] = tuple(keep) keep_l, drop_l, keep_v, drop_v = split_old(pads, vias, lines) vlines, vvias = virtual(pads, vias, lines, drop_l, drop_v) print(u' 重算后要拆:%d 段 / %d 孔(%d 个网络)' % (len(drop_l), len(drop_v), len(keep))) del BLOCK[:] routes = run_plan(pads, vvias, vlines, keep) routes = chamfer(routes, pads, vvias, vlines) msgs, _r = plane_report(routes, pads_raw, obs_board) for m in msgs: print(u' ⚠️ ' + m) if msgs: sys.exit(u'⛔ 重算之后仍有孤岛 —— 不下发') summarize(routes) bad = verify(routes, pads, vvias, vlines) for b in bad[:20]: print(u' ✗ ' + b) if bad: sys.exit(u'⛔ 重算之后校核不过 —— 不下发') nets = keep worse2 = [] for net, n, det in connectivity(routes, pads, vvias, vlines): was = len(frags(net, pads, vias, lines)) print(u' 连通性 %s:%d 个碎片 %s%s' % (net, n, det if n > 1 else u'✅', u'' if n <= was else u' ⛔ 仍然变差')) if n > was and net in keep: worse2.append(net) if worse2: sys.exit(u'⛔ %s 重算之后仍然变差 —— 不下发' % ' / '.join(worse2)) if not push: print(u'\n(只算不下发。要下发加 --push)') return print(u'── 下发 ──') RS.drop(lines, vias, drop_l, drop_v, u'I²C 干线改走 Inner2') pads2, vias2, lines2 = NL.geom(True) bad2 = verify(routes, pads2, vias2, lines2) if bad2: for b in bad2[:40]: print(u' ✗ ' + b) sys.exit(u'拆完之后按实测铜重核不过,没下发') # ⚠️ `RS.push` 会把下发的东西写进模块全局 `ROUTES`(`_pcb1_sig_routes.json`)—— # 那是 `pcb1_route_sig` 自己"我画过哪些铜"的记录,覆盖掉的话它的 `--clean` # 会去删本脚本的线、而放过它自己的。换一个文件名。 RS.ROUTES = os.path.join(HERE, '_pcb1_i2c_l3_routes.json') RS.push({('%s%s%03d' % (net, chr(0), i)): path for i, (net, path) in enumerate(routes)}) print(u'⚠️ 新的 L3 走线会让平面退让 —— 现在必须跑:') print(u' /c/Python314/python .claude/pcb1_pour.py --push') print(u' /c/Python314/python .claude/pcb1_pour.py --verify') print(u' /c/Python314/python .claude/drc_read.py') # ══════════════════ 平面完整性:干线不许把电源面切出孤岛 ══════════════════ #: Inner2 上每个电源网络**应该**有几个连通域(和 `pcb1_pour.grid_checks` 同一套判据)。 PLANE_EXPECT = (('+12V', 1), ('+3V3', 2)) def plane_report(routes, pads_raw, obs): u"""干线画上去之后,Inner2 的电源面还是不是原来那么多块。 ⚠️ **这是本轮抓到的唯一一条真缺陷,而且是静默的**:`3V3-SW` 被 SDA 的斜线、 SCL 的 y=50 横线和平面东边界围出一块 7.2 mm² 的孤岛,里面正好有 `R415.2` 的 +3V3 焊盘和它的过孔 —— 于是 · 铺铜的「浮铜」检查不响(那块铜有本网落脚点); · 「焊盘覆盖」检查不响(焊盘确实落在本网铜上); · 连接性 DRC 不响(过孔把它接到顶层去了)。 只有**数连通域**看得见。所以这一条必须在规划阶段就跑,不能等铺铜。 栅格用 `pcb1_pour.Grid`(G=0.25、斜线按包围盒挖),和 `pcb1_pour.py` 是同一段代码 —— 两套实现各自自洽证明不了板上那块铜就是计划里那块铜。 """ obs2 = list(obs) for net, path in routes: s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: h = w / 2.0 obs2.append((net, lay, min(x0, x1) - h, min(y0, y1) - h, max(x0, x1) + h, max(y0, y1) + h)) for vx, vy in v_: r = VIA_D / 2.0 obs2.append((net, None, vx - r, vy - r, vx + r, vy + r)) msgs, rects = [], [] for net, expect in PLANE_EXPECT: regs = [r for r in PO.REGIONS if r['net'] == net and r['layer'] == PO.L_PWR] g = PO.Grid(net, PO.L_PWR, regs, pads_raw, obs2) if g.ncomp == expect: continue keep = set(lab for lab, _n in sorted(g.sizes.items(), key=lambda kv: -kv[1])[:expect]) for lab, n in sorted(g.sizes.items(), key=lambda kv: -kv[1]): if lab in keep: continue cells = [(i, j) for j in range(g.ny) for i in range(g.nx) if g.lab[j][i] == lab] xs = [i for i, _j in cells] ys = [j for _i, j in cells] msgs.append(u'%s 在 Inner2 上多出一块孤岛 %.1f mm²' u'(x %.1f~%.1f y %.1f~%.1f)' % (net, n * PO.G * PO.G, min(xs) * PO.G, (max(xs) + 1) * PO.G, min(ys) * PO.G, (max(ys) + 1) * PO.G)) for i, j in cells: rects.append((i * PO.G, j * PO.G, (i + 1) * PO.G, (j + 1) * PO.G)) return msgs, rects if __name__ == '__main__': main()