文件
zhou yang和Claude Opus 5 efcba30a0c R2 底板改 4 层(D15):规则 / 几何 / 背面摆位 / 4 层布线与铺铜,已下发
- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base)
- 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base)
- 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、
  东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base)
- 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面
- 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、
  J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder
  开关(route_base、remote_route.ps1);剩 37 个网待手工补
- 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base);
  已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查)
- 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
2026-09-10 21:02:10 +08:00

1157 行
54 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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()