文件
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

2010 行
101 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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 --refresh ... # 先 pcb2_route_dp.dump() 刷障碍表再算(**每次 --push 之后板子就变了,下一次 --net 必须带**)
/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, KSEP,
L_TOP, L_BOT, CLR, VIA_D, VIA_H, seg_rect, seg_pt, seg_seg,
chamfer_routes, sharp_corners, turn_deg, SHARP, CHAMFER_R, seg_slack)
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 路程(压住"没事就打孔")
# 2026-09-07 用户定的布线规则:**顶层走水平、底层走纵向**(经典 H/V 分层)。
# 做成**软代价**而不是硬禁止:逆向走法每 mm 按 DIR_PENALTY 倍计价,45° 斜段按中间值计。
# 硬禁止会让出线段 / 1~2 mm 的短跳全布不通(焊盘出线方向由封装决定,不由层决定)。
# 取 2.0:一段 5 mm 的逆向直线代价 10 mm,正好等于「打两个孔翻到顺向层再走 5 mm」(2×2.5+5),
# 所以 >5 mm 的逆向走线会被自动翻层,<5 mm 的短跳留在原层 —— 这就是想要的分界。
# ⚠️ pcb1_route_sig.py 复用本引擎的 route_grid,下板同样生效。
DIR_PREF = {L_TOP: 'H', L_BOT: 'V'}
DIR_PENALTY = 2.0
# 斜步:第一版取 (1+DIR_PENALTY)/2 = 1.5(按分量计价的「一致」值),实测 15 根 POGO 顶层斜段 73 mm、
# 水平 31 mm —— 45° 斜走等于同时吃到横竖两个分量,比「先横后竖」便宜,规则形同虚设。
# 改成 DIAG_K = DIR_PENALTY × 1.2:斜走 1 mm 的代价 (2.4×√2 = 3.4) 略高于横 1 + 竖 2 = 3,
# 长斜段被拆成横竖(polish 再并成 L 形),只剩出线口和 45° 倒角那种短斜段。
DIAG_K = DIR_PENALTY * 1.2
# 过孔禁区 (x0, y0, x1, y1) mm。2026-09-07:`VBUS_EN2` 的过孔 (87.1, 70.2) 压在 Inner1 `VO2_P` 岛的颈
# (`pcb2_pour.ISLANDS` 的 VO2 第一块矩形 x 80~87.3,是这块岛去 J31 的唯一通道)东沿上,
# 反焊盘把颈从 7.3 掐到 6.30(要 ≥ 7.0,4 A / ΔT 20 °C)。外层布线器不知道内层有岛,只能显式禁掉。
# 范围比矩形各放 0.5(反焊盘半径 0.305 + 净距 0.2)。别的岛的颈目前 ≥ 7.2,没列。
# 2026-09-07 第三十轮:VO2 的第二块矩形(Hub 北边横带 x 87~100.5 / y 75.5~83,高 7.5)也要禁:
# K1_PWR (97.6,78.4) / K1_VDN (97.0,79.2) / +3V3_HUB (98.7,77.7) 三个孔把这条带从 7.5 掐到 5.30。
VIA_KEEPOUT = [(79.5, 67.1, 87.8, 83.5), (86.5, 75.0, 101.0, 83.5)]
TURN_COST = 0.30 # 拐一次弯折算成多少 mm(换来长直段,少几百条碎线)
BBOX_PAD = 10.0 # 每次连线的搜索窗口比两端包围盒大多少
ASTAR_W = 1.3 # 加权 A*:h 乘这个数。放弃严格最优换速度
MAX_CELLS = 3200000 # 单层格数超过这个才把栅格放粗一档(只有跨半块板的网络会碰到)
# 2026-09-07:1.2M 时全板窗口 (220×135) 在 0.1 栅格下 2.97M 格 -> 被放粗到 0.2,
# 排母 2.54 间距的 0.84 缝在 0.2 栅格上整片不可达;3.2M 让全板窗口保住 0.1。
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):
ok = ((self.viafree >= CLR + VIA_R + self.qerr)
& (self.holefree >= VIA_H / 2.0 + HOLE_CLR + self.qerr))
# 过孔禁区(Inner1 电源岛的颈):外层布线器看不见内层的岛,一个过孔的反焊盘就能把 4 A 的颈掐窄。
if VIA_KEEPOUT:
xs = self.x0 + np.arange(self.nx) * self.g
ys = self.y0 + np.arange(self.ny) * self.g
for x0, y0, x1, y1 in VIA_KEEPOUT:
ok &= ~(((xs >= x0) & (xs <= x1))[None, :] & ((ys >= y0) & (ys <= y1))[:, None])
return ok
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):
pref = DIR_PREF.get(LAY[li])
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):
# H/V 分层:顶层纵向步 / 底层横向步按 DIR_PENALTY 计价,斜步取中间值
if dx and dy:
k = DIAG_K if pref else 1.0
elif (pref == 'H' and dy) or (pref == 'V' and dx):
k = DIR_PENALTY
else:
k = 1.0
rows.append(fr)
cols.append(to)
vals.append(np.full(len(fr), g * k * (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` 当种子,别的部分对这一段本来也没用。
# 2026-09-07:加一档 **'near'**:窗口 = 目标 + 「已连上那坨里离目标最近的一件」。
# 大网络(+3V3_HUB 33 个焊盘)的远支路(site1 的 U41)在 'tgt' 小窗里没有种子,
# 直接退到 'all' 全板大窗 -> 栅格放粗 -> 排母缝 / 元件簇进不去。'near' 窗只框住真正要连的那一段。
tries = [('tgt', 0, 1), ('tgt', 0, 2), ('tgt', 0, 4), ('tgt', 1, 2), ('tgt', 1, 4),
('near', 0, 1), ('near', 0, 2), ('near', 1, 1), ('near', 1, 2), ('near', 2, 2),
('all', 0, 1), ('all', 0, 2), ('all', 0, 4), ('all', 1, 1), ('all', 1, 2),
('all', 1, 4), ('all', 2, 1), ('all', 2, 4)]
bc = _bbox(b.shapes, 0.0)
bcx, bcy = (bc[0] + bc[2]) / 2.0, (bc[1] + bc[3]) / 2.0
def _d2(s_):
bb = _bbox([s_], 0.0)
return (((bb[0] + bb[2]) / 2.0 - bcx) ** 2 + ((bb[1] + bb[3]) / 2.0 - bcy) ** 2)
a_near = min(a.shapes, key=_d2) if a.shapes else None
for mode, grow, fine in tries:
if mode == 'tgt':
base = b.shapes
elif mode == 'near':
if a_near is None:
continue
base = b.shapes + [a_near]
else:
base = 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 in ('tgt', 'near'):
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)
else:
_anchor_start(path, a.shapes)
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 via_corners(routes, limit=SHARP):
u"""换层点两侧的线在平面上成"直角"的那些:[(key, i, (x, y), 转向角)]。
`sharp_corners` 故意跳过换层点(那是过孔);用户 2026-09-07 要求换层处也不许 90°。"""
out = []
for key, path in sorted(routes.items()):
for i in range(1, len(path) - 2):
b, b2 = path[i], path[i + 1]
if not _same(b, b2) or b[2] == b2[2]:
continue
a, c = path[i - 1], path[i + 2]
if _same(a, b) or _same(b2, c):
continue
t = turn_deg(a, b, c)
if t > limit:
out.append((key, i, (b[0], b[1]), t))
return out
def via_chamfer_routes(routes, pads, vias, lines):
u"""换层点的 45° 化(2026-09-07 用户要求:「两层中的走线连接处不要垂直,增加一段锐角走线」)。
顶层横线进孔、底层竖线出孔,在平面上就是一个直角。做法和普通拐角一样切一刀,
但过孔要挪到斜切段的一端:
A 型:进线层上 R->Q 走 45°,过孔放在 Q(= 原孔位沿出线方向挪 d),出线从 Q 直着走;
B 型:过孔放在 R(= 原孔位沿进线方向退 d),出线层上 R->Q 走 45°。
先试 A 再试 B,半径按 CHAMFER_R 逐档退让;斜切段过 `seg_slack`,新孔位过 `_via_legal`。
校核用的是**改之前**的其它段(保守)。返回 (新 routes, 切掉的数量)。
"""
mine, mvias = [], []
for key, path in routes.items():
for x0, y0, x1, y1, lay, w in expand(path)[0]:
mine.append((netof(key), lay, x0, y0, x1, y1, w))
for vx, vy in expand(path)[1]:
mvias.append((netof(key), vx, vy))
def via_legal(x, y, net, old=None):
for x0, y0, x1, y1 in VIA_KEEPOUT:
if x0 <= x <= x1 and y0 <= y <= y1:
return False
for q in pads:
if q['net'] != net and q['lay'] in (12, L_TOP, L_BOT):
if seg_rect((x, y), (x, y), q['x'], q['y'], q['hw'], q['hh']) - VIA_R < CLR - 1e-4:
return False
if q['lay'] == 12 and math.hypot(x - q['x'], y - q['y']) < q['hw'] + VIA_H / 2.0 + HOLE_CLR:
return False # 钻孔间距不分网络;焊盘半宽当孔半径,保守
for v in vias:
d = math.hypot(x - v['x'], y - v['y'])
if v['net'] != net and d - VIA_R - v['r'] < CLR - 1e-4:
return False
if d < VIA_H + HOLE_CLR - 1e-4:
return False
for n2, vx, vy in mvias:
if old is not None and abs(vx - old[0]) < 1e-6 and abs(vy - old[1]) < 1e-6:
continue # 本路径自己原来那个孔(马上要挪走)
d = math.hypot(x - vx, y - vy)
if n2 != net and d - 2 * VIA_R < CLR - 1e-4:
return False
if d < VIA_H + HOLE_CLR - 1e-4:
return False
for l in lines:
if l['net'] != net and seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], x, y) - VIA_R - l['w'] / 2.0 < CLR - 1e-4:
return False
for n2, lay, x0, y0, x1, y1, w in mine:
if n2 != net and seg_pt(x0, y0, x1, y1, x, y) - VIA_R - w / 2.0 < CLR - 1e-4:
return False
return True
out, ncut = {}, 0
for key, path in routes.items():
net = netof(key)
ok = lambda x0, y0, x1, y1, lay, w, _n=net: (
seg_slack(x0, y0, x1, y1, lay, w, _n, pads, vias, lines, mine, mvias) >= -1e-4)
res = []
i = 0
while i < len(path):
b = path[i]
if not (1 <= i <= len(path) - 3):
res.append(b)
i += 1
continue
b2 = path[i + 1]
a, c = path[i - 1], path[i + 2]
if not _same(b, b2) or b[2] == b2[2] or _same(a, b) or _same(b2, c) or turn_deg(a, b, c) <= SHARP:
res.append(b)
i += 1
continue
ax, ay = b[0] - a[0], b[1] - a[1]
cx, cy = c[0] - b[0], c[1] - b[1]
la, lc = math.hypot(ax, ay), math.hypot(cx, cy)
wa, wb = a[3], b2[3]
done = None
for r in CHAMFER_R:
d = min(r, la / 2.0, lc / 2.0)
if d < 0.05:
break
R = (round(b[0] - ax / la * d, 4), round(b[1] - ay / la * d, 4))
Q = (round(b[0] + cx / lc * d, 4), round(b[1] + cy / lc * d, 4))
# A 型:斜段在进线层,孔在 Q
if ok(R[0], R[1], Q[0], Q[1], a[2], wa) and via_legal(Q[0], Q[1], net, (b[0], b[1])):
done = [(R[0], R[1], a[2], wa), (Q[0], Q[1], a[2], wa), (Q[0], Q[1], b2[2], wb)]
break
# B 型:孔在 R,斜段在出线层
if ok(R[0], R[1], Q[0], Q[1], b2[2], wb) and via_legal(R[0], R[1], net, (b[0], b[1])):
done = [(R[0], R[1], a[2], wa), (R[0], R[1], b2[2], wb), (Q[0], Q[1], b2[2], wb)]
break
if done is None:
res.append(b)
i += 1
continue
res.extend(done)
ncut += 1
i += 2 # 跳过 b2(旧孔位的第二个点)
out[key] = res
return out, ncut
def reanchor_routes(routes, pads):
u"""切角 / 焊盘矩形角两类问题的兜底:让每条路径的两端都落在本网的铜里。返回补的段数。"""
by_net = {}
for key, path in routes.items():
by_net.setdefault(netof(key), []).append(key)
padix = {}
for q in pads:
if q['net']:
padix.setdefault(q['net'], []).append(q)
nfix = 0
for net, keys in by_net.items():
segs, vias = [], []
for k in keys:
sg, vv = expand(routes[k])
segs += [(k, x0, y0, x1, y1, lay, w) for x0, y0, x1, y1, lay, w in sg]
vias += [(k, x, y) for x, y in vv]
for k in keys:
path = routes[k]
if len(path) < 2:
continue
for at_end in (False, True):
x, y, lay, w = path[-1] if at_end else path[0]
inside = False
for q in padix.get(net, []):
if (q['lay'] == 12 or q['lay'] == lay) and math.hypot(x - q['x'], y - q['y']) <= min(q['hw'], q['hh']) + 1e-6:
inside = True
break
if not inside:
for k2, vx, vy in vias:
if math.hypot(x - vx, y - vy) <= VIA_R + 1e-6:
inside = True
break
if not inside:
for k2, x0, y0, x1, y1, l2, w2 in segs:
if k2 == k or l2 != lay:
continue
if seg_pt(x0, y0, x1, y1, x, y) <= w2 / 2.0 + 1e-6:
inside = True
break
if inside:
continue
best = None
for k2, x0, y0, x1, y1, l2, w2 in segs:
if k2 == k or l2 != lay:
continue
dx, dy = x1 - x0, y1 - y0
L2 = dx * dx + dy * dy
tt = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, ((x - x0) * dx + (y - y0) * dy) / L2))
qx, qy = x0 + tt * dx, y0 + tt * dy
d = math.hypot(x - qx, y - qy)
if best is None or d < best[0]:
best = (d, qx, qy)
for q in padix.get(net, []):
if q['lay'] == 12 or q['lay'] == lay:
d = math.hypot(x - q['x'], y - q['y'])
if best is None or d < best[0]:
best = (d, q['x'], q['y'])
if best is None or best[0] > 1.5:
print(u' [!!] %s 的%s点 (%.3f,%.3f) L%d 不在本网铜里,附近 1.5 mm 内也没有可接的铜'
% (k.replace(KSEP, '|'), u'终' if at_end else u'起', x, y, lay))
continue
pt = (round(best[1], 4), round(best[2], 4), lay, w)
if at_end:
path.append(pt)
else:
path.insert(0, pt)
nfix += 1
return nfix
def _anchor_start(path, shapes):
u"""从「已连上的一坨」出发的路径,起点若落在某个**矩形焊盘形状**里但没在圆心,补一个圆心点。
2026-09-07 第三十轮实测:Comp 的注释早就写了「圆焊盘存的是外接矩形,停在角上搭不到铜」,
单焊盘的起终点因此钉在圆心;但 MST 后续每条边的**起点**是整坨铜的掩膜(矩形),
Dijkstra 挑离目标最近的种子 = 矩形角,离 Ø1.7 排母脚圆心 1.2 mm,铜外 0.2 ——
KEY_SRCLK/KEY_RCLK/+5V 在 J32 上、VBUS2/+3V3_HUB 在贴片盘上各留了一个孤岛(DRC 连接性 10 条)。
补的这一段全程在本网焊盘内部 / 焊盘自己的净距圈里,不会碰别的网。
"""
if not path:
return
x, y, lay, w = path[0]
if os.environ.get('ANCHOR_DEBUG'):
nb = []
for s_ in shapes:
if s_[0] == 'seg':
d = seg_pt(s_[1], s_[2], s_[3], s_[4], x, y) - s_[5]
elif s_[0] == 'via':
d = math.hypot(x - s_[1], y - s_[2]) - s_[3]
else:
d = seg_rect((x, y), (x, y), s_[1], s_[2], s_[3], s_[4])
if d < 1.0:
nb.append((round(d, 3), s_))
nb.sort(key=lambda t: t[0])
print(u' [dbg] 起点 (%.3f,%.3f) L%d 附近形状: %s' % (x, y, lay, nb[:4]))
# 起点已经落在本网的导线 / 过孔铜里(比如出线段的末端)就不用补
for s_ in shapes:
if lay not in s_[-1]:
continue
if s_[0] == 'seg' and seg_pt(s_[1], s_[2], s_[3], s_[4], x, y) <= s_[5] + 1e-6:
return
if s_[0] == 'via' and math.hypot(x - s_[1], y - s_[2]) <= s_[3] + 1e-6:
return
best = None
for s_ in shapes:
if s_[0] != 'rect' or lay not in s_[-1]:
continue
_, cx, cy, hw, hh, _ = s_
d = math.hypot(x - cx, y - cy)
if d <= math.hypot(hw, hh) + 0.05 and (best is None or d < best[0]):
best = (d, cx, cy)
if best is not None and best[0] >= 1e-3:
path.insert(0, (round(best[1], 4), round(best[2], 4), lay, w))
return
if best is not None:
return
if os.environ.get('ANCHOR_DEBUG'):
cont = []
for s_ in shapes:
if lay not in s_[-1]:
continue
if s_[0] == 'seg' and seg_pt(s_[1], s_[2], s_[3], s_[4], x, y) <= s_[5] + 1e-6:
cont.append(s_)
elif s_[0] == 'via' and math.hypot(x - s_[1], y - s_[2]) <= s_[3] + 1e-6:
cont.append(s_)
print(u' [dbg] 起点 (%.3f,%.3f) L%d 落在: %s' % (x, y, lay, cont[:3]))
# 诊断:起点不在本网任何形状里(2026-09-07 追 VBUS2 / +3V3_HUB 的孤岛)
near = []
for s_ in shapes:
if s_[0] == 'seg':
near.append((seg_pt(s_[1], s_[2], s_[3], s_[4], x, y) - s_[5], s_))
elif s_[0] == 'via':
near.append((math.hypot(x - s_[1], y - s_[2]) - s_[3], s_))
else:
near.append((math.hypot(x - s_[1], y - s_[2]), s_))
near.sort(key=lambda t: t[0])
print(u' [?] 起点 (%.3f,%.3f) L%d 不在本网铜里;最近 %s' % (x, y, lay, near[:2]))
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 个)。⇒ 大网络必须排在点对点的小网络之后。
# 2026-09-07:`HUB_RST` 提到 `VBUS_UP` 之前。R716.1 唯一的出口是 R716/R711 之间 0.66 的底层缝,
# 缝东头是 USB5 竖线墙、必须换层;VBUS_UP 先布时从 R709.1 出来在顶层 y 67.2 横穿卫星圈东行,
# 和 +3V3 前置线(顶层 y 66.2)夹成 0.8 的带,塞不下换层孔 -> HUB_RST 布不通(第三十轮实测)。
hub = ['GND'] + lanes0 + ['HUB_RST', 'VBUS_UP'] + [
'RBIAS', 'VDD18', 'VDD18PLL', 'XTAL1', 'XTAL2',
'CFGSEL0', 'CFGSEL1', 'NONREM0', 'NONREM1', 'PRTDIS6', 'PRTDIS7',
'+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` 原来就是这么布不通的)。
# 2026-09-06 595 上载板:移位链 4 根(各 4 个焊盘:J32 + 三片)+ 两根级联 + 15 根 K 栅极线(点对点),
# 排在 POGO 之前 —— K 线从走廊中段扇到五个站,比站内的 POGO 短线更需要先挑路。
# 2026-09-07 第三十轮(U21 rot 0 / U22 rot 180 之后):**K 线排在移位链控制线之前**。
# U22 南排的 K4/K5 六根只能从南带(UP 差分线边 60.74 ~ 焊盘下沿 62.77,2 mm)换层出去;
# KEY_* 先布时把这条带在顶层横着占满(U21 南排 -> U22 北排 -> J32 北排的直路),K4_VDN/K5_VUP/K5_VDN 布不通。
# 控制线有别的路(U22 两排脚之间 y 64.75~68.25 那 3.5 mm 的通道、J32 北侧),K 线没有。
keys = ['K%d_%s' % (n, k) for n in range(1, 6) for k in ('PWR', 'VUP', 'VDN')]
keys += ['KEY_SER', 'KEY_SRCLK', 'KEY_RCLK', 'KEY_OE#', 'SR_CAS', 'SR_CAS2']
ctrl = [n for n in _pogo_by_length()] + ['SDA', 'SCL']
# 2026-09-06:K 线 / 移位链提前到 `up` 之前 —— 它们是点对点细线,单独布 0.6 s 就通,
# 排在 +5V / VBUS / 站内局部网之后就被挤死(实测两轮各 11~12 根布不通)。
out, seen = [], set()
for n in hub + keys + 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
# ⚠️ 2026-09-06:原来一个请求里「逐条删 + 逐条建 1589 段」,桥接 30 s 超时(EasyEDA 那边还在继续画),
# 下一步抓几何抓到画了一半的板子。现在拆成:一次性批量删 -> 每 250 段一个请求建线 -> 建孔 -> 存盘。
JS_PUSH_DEL = u"""
const NETS = %s, BOXES = %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]);
const dl = [], dv = [];
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) dl.push(o.getState_PrimitiveId());
}
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) dv.push(o.getState_PrimitiveId());
}
for (let i = 0; i < dl.length; i += 300) await eda.pcb_PrimitiveLine.delete(dl.slice(i, i + 300));
for (let i = 0; i < dv.length; i += 300) await eda.pcb_PrimitiveVia.delete(dv.slice(i, i + 300));
return {del_lines: dl.length, del_vias: dv.length};
"""
JS_PUSH_LINES = u"""
const SEGS = %s, MIL = %f; let nl = 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++;
}
return nl;
"""
JS_PUSH_VIAS = u"""
const VIAS = %s, MIL = %f; let nv = 0;
for (const [net, x, y] of VIAS) {
await eda.pcb_PrimitiveVia.create(net, x*MIL, y*MIL, %f*MIL, %f*MIL); nv++;
}
return nv;
"""
JS_PUSH_SAVE = u"""
await eda.pcb_Document.save();
await new Promise(r => setTimeout(r, 2500));
return 1;
"""
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_:
# 2026-09-07:穿过**本网焊盘中心**的线段在中心处拆成两段。EasyEDA 建一条穿过焊盘的线时会在焊盘中心截断、
# 后半段直接丢掉(KEY_RCLK 穿 U22.12 去接出线段末端,板上只剩到焊盘中心那一半,DRC 连接性 2 条)。
pts = [(x0, y0)]
L = math.hypot(x1 - x0, y1 - y0)
cuts = []
for q in PADS_ALL:
if q['net'] != net or (q['lay'] != 12 and q['lay'] != lay) or L < 1e-6:
continue
t = ((q['x'] - x0) * (x1 - x0) + (q['y'] - y0) * (y1 - y0)) / (L * L)
if 0.02 < t < 0.98 and seg_pt(x0, y0, x1, y1, q['x'], q['y']) < 0.15: # 0.15 < 焊盘半宽 0.3,拆出的小折角仍在焊盘里
cuts.append((t, q['x'], q['y']))
for _t, qx, qy in sorted(cuts):
pts.append((qx, qy))
pts.append((x1, y1))
for (ax, ay), (bx, by) in zip(pts, pts[1:]):
segs.append([net, lay, round(ax, 4), round(ay, 4), round(bx, 4), round(by, 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'})
from eda import run
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,1800));" % PCB)
def call(js, what):
r = run(OPEN + js, timeout=600)
if not r.get('success'):
sys.exit(u'下发失败(%s): %s' % (what, json.dumps(r, ensure_ascii=False)[:600]))
return r['result']
# 2026-09-07:BM24 框里的 GND 梳子只有在这一轮**重画 GND** 时才删。
# 原来无条件删:`--net HUB_RST ...` 补布把 65 个 BM24 GND 脚的逃逸段全删了没重画(gnd_patch 报 65 个悬空域)。
has_gnd = any(netof(k) == 'GND' for k in routes)
d = call(JS_PUSH_DEL % (json.dumps(nets), json.dumps(gnd_boxes() if has_gnd else []), MIL), u'删旧铜')
nl = 0
for i in range(0, len(segs), 250):
nl += call(JS_PUSH_LINES % (json.dumps(segs[i:i + 250]), MIL), u'建线 %d~' % i)
nv = 0
for i in range(0, len(vs), 250):
nv += call(JS_PUSH_VIAS % (json.dumps(vs[i:i + 250]), MIL, VIA_H, VIA_D), u'建孔 %d~' % i)
call(JS_PUSH_SAVE, u'存盘')
print(u'下发完成:先删 %d 线 / %d 孔,再画 %d 线 / %d 孔(分批,每批 250)'
% (d['del_lines'], d['del_vias'], nl, nv))
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, strict=True):
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)
if strict:
sys.exit(u'\nBM24 的手写常量和板上对不上(%d 条)—— 先改常量再布线。' % len(bad))
# 2026-09-07:pcb2_place 的 PW/PH 改成 67x44 后,站 1~3(rot 180)的元件在板上仍按旧 67.09x44.66 摆着,
# 和 plan() 差 (0.09, 0.66)。只重布不碰 J7n 的网络(如 POGO*)时降级成警告;
# 整板 --push 或重画 BM24 梳子之前必须先 pcb2_place.py --push 把站 1~3 摆到新坐标。
print(u' ⚠ BM24 常量和板上对不上(%d 条),但这一轮 --net 的网络都不碰 J7n,按警告放行' % len(bad))
return
print(u'BM24 手写常量与板上实测一致(五个站 × 13 个 GND 脚 + THERM/CC1 + R76n/R77n)✔')
def main():
only = [a for a in sys.argv[1:] if not a.startswith('--')]
if '--refresh' in sys.argv:
from pcb2_route_dp import dump as _dump
_dump()
pads, vias, lines = geom()
ALL_NETS.update(p['net'] for p in pads if p['net'])
PADS_ALL[:] = pads
j7nets = set(p['net'] for p in pads if p['ref'].startswith('J7') and p['net'])
j7_assert(pads, strict=not only or bool(set(only) & j7nets))
# ⚠️ 下发时要删掉五个 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)
# ⚠️ 2026-09-06:带 `--net` 补布时只能摘**这一轮要重画的**网络,其余 115 个网络的旧铜是真障碍——
# 原来按完整候选表摘,第二遍把 +3V3_HUB / +5V 直接画到第一遍的线上(DRC 319 条 0 mil)。
mine = (set(only) if only else 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%s%d' % (net, KSEP, 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()
raw = dict(routes)
routes = chamfer_routes(routes, pads, vias, lines, smooth=True)
# ⚠️ `smooth=True` 会挪动路径(最多 0.15 mm),不只是拿掉铜;而它的净距校核拿的是切之前的自身路径
# ⇒ 切完再 check(),把牵涉到违规的那几条整条退回未切版本(下板 pcb1_route_sig §26.15 同款;
# 2026-09-06 载板 `$22N139` 就这么被抹到 GND 线上 −0.200)。
for _round in range(4):
bad0 = check(routes, pads, vias, lines, verbose=False)
hurt = set()
for t_, n1, n2, lay_, d_, a_, b_ in bad0:
for key, path in routes.items():
if netof(key) not in (n1, n2) or key not in raw or routes[key] == raw[key]:
continue
for x0, y0, x1, y1, ll, ww in expand(path)[0]:
if seg_seg((x0, y0), (x1, y1), a_, b_) < 1e-6:
hurt.add(key)
break
if not hurt:
break
print(u' 45° 化后有 %d 条压线,把 %s 退回未切的版本' % (len(bad0), ' '.join(sorted(netof(k) for k in hurt))))
for k in hurt:
routes[k] = raw[k]
# ── 换层点 45° 化(2026-09-07 用户要求)──
nv0 = len(via_corners(routes))
raw2 = dict(routes)
routes, ncut = via_chamfer_routes(routes, pads, vias, lines)
for _round in range(4):
bad0 = check(routes, pads, vias, lines, verbose=False)
hurt = set()
for t_, n1, n2, lay_, d_, a_, b_ in bad0:
for key, path in routes.items():
if netof(key) not in (n1, n2) or key not in raw2 or routes[key] == raw2[key]:
continue
for x0, y0, x1, y1, ll, ww in expand(path)[0]:
if seg_seg((x0, y0), (x1, y1), a_, b_) < 1e-6:
hurt.add(key)
break
for vx, vy in expand(path)[1]:
if seg_seg((vx, vy), (vx, vy), a_, b_) < 1e-6:
hurt.add(key)
break
if not hurt:
break
print(u' 换层 45° 化后有 %d 条压线,把 %s 退回' % (len(bad0), ' '.join(sorted(netof(k) for k in hurt))))
for k in hurt:
routes[k] = raw2[k]
nv1 = len(via_corners(routes))
print(u'换层点直角:%d -> %d(切了 %d 处)' % (nv0, nv1, ncut))
# ── 端点复接(2026-09-07 第三十轮)──
# 切角会挪走直角处的铜,而后布的支路起点可能正钉在那个角上(VBUS2 (105.9,99.1)、+3V3_HUB (198.1,70.0));
# 另外从整坨出发的路径起点也可能落在圆焊盘外接矩形的角上(J32.7/9、J32.40)。
# 统一在这里兜底:路径两端若不在本网任何铜(别的路径 / 过孔 / 焊盘)里,补一段接到最近的本网铜。
nfix = reanchor_routes(routes, pads)
if nfix:
print(u' 端点复接:补了 %d 段' % nfix)
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()