# -*- coding: utf-8 -*- u"""R2 底板 PCB 布线(M8d)。 /c/Python314/python .claude/r2/route_base.py # 算 + 落盘缓存,不下发 /c/Python314/python .claude/r2/route_base.py --replan # 强制重算(默认吃缓存) /c/Python314/python .claude/r2/route_base.py --dp-only # 只算七对 D±,不改缓存 /c/Python314/python .claude/r2/route_base.py --push # 下发导线 + 过孔 /c/Python314/python .claude/r2/route_base.py --clear # 清掉全部布线 **引擎复用刀片那一份 `router.py`**(0.25 网格 + 8 邻接 A*,见它的文件头), 这里只做三件事:把板级常量换成底板的、加两条两层板独有的约束、写自己的主流程。 ## 两层板独有的两条约束(刀片上没有) 1. **底层(L2)是完整地平面 —— 走一格就是在平面上割一刀。** ⇒ 底层全层收「过路费」`BOT_PEN`:不封死(方案 §6.2 允许 D± 走廊之外跳线), 但只有当顶层要绕远 `BOT_PEN` 倍以上时才划算。短跳线照走,长干线不会跑下去。 ⛔ 过路费必须**每次 `pen_pads` 之后重新打**:`pen_pads` 头一句就是 `reset_pen()`, 直接写 `g.pen` 会被它清掉,而且**不报错** —— 现象是底层突然多出一堆长线。 2. **D± 走廊下方一刀不许割**(§6.2 第 1 条,这是 2 层方案成立的前提)。 ⇒ 分两相布: **第一相**七对 D± **只准走顶层**(`TOP_ONLY`,底层整层封死); **第二相**把第一相的铜按 `DIFF_KEEP` 膨胀,在底层**封死**,再布其余所有网。 于是任何跳线都不可能钻到 D± 底下。 ## 差分对:靠「贴着走」的过路费成对 `router.py` 是逐网布的,不认识差分对。做法:`DP` 布完之后,给整个顶层打一层 过路费、只把 `DP` 铜周围 `HUG_BAND` 内的格点留成 0,`DM` 自然就贴着 `DP` 走。 实测中心距落在网格的 1.5 mm(= 1.2 线宽 + 0.3 缝),Zdiff 比方案 §6.3 那张表 (1.2/0.2 → 94 Ω)略高,仍在 USB 2.0 的 90 Ω ±15% 窗口里。首板不勾阻抗控制。 ## `GND` 不布线 333 只地脚全靠**两层铺铜 + 缝合过孔**(`pour_base.py`)。底层本来就是整块地平面, 顶层的地铜面被信号切成多块岛 —— **每块岛至少要有一个缝合过孔**, 否则会被当孤岛整块删掉,而 `rebuildCopperRegion()` / 铺铜管理器 / DRC **三个都不报错** (刀片 M4c 的教训,方案 §24.4)。`pour_base --check` 就是查这一条的。 ## `+12V` 的 9 A 靠铺铜,不靠这里的线宽 这里给 `+12V` 1.5 mm(= 单槽 1.5 A 的要求)、`VIN_*` 2.0 mm。 入口链那段 9 A 要 ≥8 mm 净铜宽(§6.4),由 `pour_base` 在顶层铺一块 `+12V` 铜面 + **断面校核**保证(办法沿用 R1 `pcb1_pour.py` 的 `CUTS`)。 ⇒ 只看这份脚本的线宽会以为入口链严重欠载流,别慌,看铺铜那一步。 """ import io import json import math import os import sys # `--hash-seed N`:固定字符串哈希(网名集合的遍历顺序 → 直连 / 排网的平局先后)。 # **只对 CPython 有意义**:CPython 的集合按哈希序遍历、字符串哈希按进程随机, # 固定之后结果可复现,也可以扫几个种子取最好的。 # ⚠ PyPy 的集合一律按**插入序**遍历,哈希值不影响结果 —— 实测 8 个种子输出逐字节相同。 # 哈希种子只能在解释器启动前设,所以带着环境变量重启自己。 if '--hash-seed' in sys.argv: _seed = sys.argv[sys.argv.index('--hash-seed') + 1] if os.environ.get('PYTHONHASHSEED') != _seed: os.execve(sys.executable, [sys.executable] + sys.argv, dict(os.environ, PYTHONHASHSEED=_seed)) sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) import pcb_base as B # noqa: E402 import router as RT # noqa: E402 import rules_base as RB # noqa: E402 # 用户明确要求所有布线运算都放到 10.5.5.13。快照、缓存报告和下发仍可在本机做, # 但任何会启动 A* / 差分对试算的入口必须由 remote_route.ps1 在服务器上显式解锁。 _COMPUTE_FLAGS = ('--replan', '--dp-only') if any(flag in sys.argv for flag in _COMPUTE_FLAGS) and os.environ.get('DCDC_ROUTE_SERVER') != '1': sys.exit(u'!! 布线运算禁止在本机执行;请使用 .claude/r2/remote_route.ps1(服务器 10.5.5.13)') # ── 把 router 的板级常量换成底板的 ──────────────────────────────────────── RT.B = B RT.PCB = B.PCB RT.NX = int(round(B.BW / RT.G)) + 1 RT.NY = int(round(B.BH / RT.G)) + 1 RT.PADS = os.path.join(HERE, '_base_pads.json') RT.FINGER_KEEP_Y = 0.0 # 底板没有金手指区 RT.KELVIN = [] RT.KELVIN_PADS = set() RT.CLR = 0.21 # 设计规则是 0.20,留一点给网格吸附 RT.W_DEFAULT = 0.2 # 两层板走 JLC 标准工艺,信号 0.2 RT.EDGE_CLR = 0.4 # ⛔ **两层正交那条约定在底板上要关掉**。刀片上是「器件面纵向、另一面横向」 # (方案 §25.6),做法是逆着方向走贵 `CROSS_COST=3` 倍。底板只有**顶层**能布信号 # (底层是完整地平面),于是「顶层偏好横向」变成「每往北走一格贵 3 倍」—— # 而 D± 全都是南北向的。实测现象:`USB5` 一对布出 **37 段**的锯齿路径, # 把扇出带占满,紧接着 `USB4` 一段都布不出来。看着像拥塞,其实是**代价函数**。 # `LAYER_DIR` 留空 ⇒ `_along()` 一律返回 True,八个方向同价。 RT.LAYER_DIR = {} CACHE = os.path.join(HERE, '_base_routes.json') CACHE_SCHEMA = 7 # v7: live-layout D±, rail bridge, and revised 12V trunk BOARD_VIAS = os.path.join(HERE, '_base_board_vias.json') class _NoNotch(object): u"""`build_grid` 要 `pcb_pour.notch_cut()`(金手指防呆缺口)—— 底板没有。 给一个板外的退化矩形,比在 `router.py` 里加 if 干净(那份是刀片的事实来源)。 """ @staticmethod def notch_cut(): return (-10.0, -10.0, -9.0, -9.0) RT.P = _NoNotch def _rule_min(net, auto): u"""线宽下限:查底板自己的 `rules_base.NET_TRACK`,不是刀片那份。""" cls = RB.NET_TRACK.get(net) if cls is None: return 0.15 return RB.TRACK_RULES[cls]['strokeWidthMin'] RT.rule_min = _rule_min # ⛔ **通孔焊盘(layer 12)**:刀片是全贴片的,`router.py` 里一句 # `if lay not in LAY: continue` 就把它们从占用图里漏掉了 —— 底板有 DC 座 / # 两个风扇座 / 应急排针 / 两颗直立电解,漏掉的后果是布线器直接从通孔焊盘上压过去。 # 而 `pad_nodes` 又会拿 `g.own[12]` 去查,直接 KeyError(这次是**响**的,算运气好)。 # ⇒ 拆成两条记录:顶层那条参与布线,底层那条只当障碍(脚号加后缀,`bypad` 里滤掉)。 BOT_SUFFIX = '#b' # ⛔ **通孔焊盘要额外膨胀**:`J1`(DC 座)和 `J23`(USB-C)的孔是 **SLOT(长槽)**, # 槽比焊盘大,而布线器只按矩形焊盘建模 ⇒ 线贴着焊盘走就压在槽上, # DRC 报「挖槽区域 到 导线 0 mil」(实测 22 条,全是 `VIN_FUSED` 贴着 J1)。 TH_GROW = 0.6 def split_th(pads): out = [] for p in pads: if p[6] == 12: a = list(p); a[6] = 1; a[4] += TH_GROW; a[5] += TH_GROW b = list(p); b[6] = 2; b[1] = str(p[1]) + BOT_SUFFIX b[4] += TH_GROW; b[5] += TH_GROW out += [a, b] else: out.append(p) return out def apply_route_fix(pads): u"""在板端尚未重抓焊盘时,把 ROUTE_FIX 平移到计划位置。 `_base_pads.json` 是 EasyEDA 当前板端的读回值;M12 的两颗让路电阻已经写进 `place_base.ROUTE_FIX`,但桥接中断时无法立即重抓。这里用同一份 live 布局快照 算平移量,使离线路由能先验证新位置。板端移动并重新 `--adopt-live --dump` 后, live 坐标等于目标坐标,平移量自然归零,不会重复移动。 """ import place_base as PL pads, moved = PL.route_fixed_pads(pads) # 与 pour_base 共用同一份换算 if moved: print(u' 路由预检计划改位:' + ' '.join(moved)) return pads def load_board_vias(): u"""读取板端既有过孔;`--offline` 时使用本地快照供计算服务器复现。""" if '--offline' in sys.argv: if not os.path.exists(BOARD_VIAS): raise RuntimeError(u'离线计算缺少 _base_board_vias.json;先在 EDA 可达机器运行一次预检') return [tuple(v) for v in json.load(io.open(BOARD_VIAS, encoding='utf-8'))] vias = RT.load_vias() io.open(BOARD_VIAS, 'w', encoding='utf-8').write( json.dumps(vias, ensure_ascii=False, indent=2)) return vias # ── 线宽 ───────────────────────────────────────────────────────────────── DP_NETS = [] for _name, _dp, _dm in RB.PAIRS: DP_NETS += [_dp, _dm] DIFF_W = 1.0 # 试:1.0/0.2 ⇒ ~100 Ω(USB 窗口 76.5~103.5 内) RT.WIDTH = { 'VIN_RAW': 2.0, 'VIN_FUSED': 2.0, 'VIN_SW': 2.0, '+12V': 1.5, # 单槽 1.5 A;干路 9 A 由铺铜断面保证 'FAN_12V_SW': 0.8, '+5V': 0.6, '+3V3': 0.5, '+3V3_HUB': 0.5, } for _n in DP_NETS: RT.WIDTH[_n] = DIFF_W RT.WIDTH_AUTO = {} # 七对 D± + 全部电源网都不按焊盘封顶(见 router.NO_CAP)。 # ⛔ 封顶对电源网是**静默降载**:`route_groups` 按「这个网最小的那只脚」封顶, # 实测把 `+12V` 从 1.5 压到 **0.30**、`VIN_SW` 从 2.0 压到 0.30 —— 0.30 mm 1 oz # 外层只带 0.65 A,而槽支路要 1.5 A。铺铜只盖到母线,**支路那一段没有铜面兜底**。 # 缩颈交给 `stub_w`(只缩引出脚,不缩干线)。 RT.NO_CAP = tuple(DP_NETS) + ('VIN_RAW', 'VIN_FUSED', 'VIN_SW', '+12V', '+5V', '+3V3', '+3V3_HUB') # ── 两层板独有的两条约束 ───────────────────────────────────────────────── BOT_PEN = 6 # 底层每格的过路费(顶层 0)。见文件头第 1 条 BOT_PEN_W = 2 # 东西功能区的底层过路费:那里不在 D± 干线下方 if '--bot-pen-w' in sys.argv: # 服务器变体试算用:东西两区底层跳线的价格 BOT_PEN_W = int(sys.argv[sys.argv.index('--bot-pen-w') + 1]) DIFF_KEEP = 1.5 # D± 铜两侧多少 mm 内,底层不许有铜 HUG_BAND = 2.2 # DM 贴着 DP 走的自由带半宽 DIFF_GUIDE_W = 3.2 # 「导引线」宽度:够两根 1.2 并排(1.41 + 2×0.81) GUIDE_BAND = 1.6 # 两根真线只许在导引线中心线 ±1.6 里走 HUG_PEN = 12 TOP_ONLY = [False] # 第一相:底层整层封死 _HUG = [None] # [(x0,y0,x1,y1), ...] 或 None _LANE = [None] # (lane_y, [riser x 窗口]) 或 None # ── M9:每对 D± 一条**专属车道**(通道里竖着一条 + 目标走廊里横着一条)───── # 新拓扑下 D± 的路是三段:`U32` 东沿 → 逃逸扇形散到 x=29 → **D± 通道**(x 29~37) # 里竖着走到目标槽的**北侧走廊** → 走廊里横着往东到 x≈59.5 的槽针。 # ⛔ 通道只有 8 mm,一对占 2.7 ⇒ 必须分道,否则先布的那一对会把通道占满 # (M8 实测:不分道时后布的那一对「布了 0 段」,而 DRC / 几何验证一条都不响)。 # 南北两边的对**不重叠**(`U32` 在 y=67,槽 1/2/3 在南、4/5 在北)⇒ 1&5、2&4 可以共用车道。 # ⛔ **车道要按「逃逸斜线有多长」分,不是按槽号**。扇形里外侧的脚要斜爬 ~6.5 mm # 才到自己的 y,斜线终点 x≈32;把它分到 31.25 那条道,等于让斜线落在道外, # 而它会从别人的起跑点上压过去 —— 实测 `USB5_DP` 的斜线正好压在 `USB5_DM` # 的落点 (29.85, 73) 上,占用图那一格变 255(两个网碰过 = 谁也不能进), # 现象是「一对里只进得去一根」,换五种办法都救不回来。 # ⇒ 斜线最长的(USB1/USB5)分最东那条道,最短的(USB3)分最西。 # ⛔ **车道顺序按「扇形横线不许横穿别人的竖段」定,不是按斜线长短**(上一版搞反了, # DRC 报 USB1/USB2 之间 0 mil)。推导:南行的 USB1 扇出 y 最南、竖段只到自己以南, # 所以它的横线必须**最先停**(最西的道);USB2 的扇出 y 在它北边,横线可以更往东。 # 北行同理反过来:USB5 扇出 y 最北 → 最西的道,USB3 → 最东。 DP_LANE_X = {1: 78.75, 2: 74.25, 3: 69.75, 4: 74.25, 5: 78.75} LANE_HALF = 2.25 # 车道 4.5:一对要 2.63,再被一根 0.25 逃逸线横穿 # (吃掉 1.67)仍剩 2.83 ⇒ 装得下 # 逃逸扇形的落点区(所有对共用,靠扇形本身分开)。10 只脚 × 2.0 间距 = 20 mm 跨度, # 以 U32 的 D± 脚中点(y≈66)为中心 ⇒ 56~76,两头各留 1。 U32_BOX = (73.0, 55.0, 82.0, 77.0) RISER_U32 = None # M8 遗留,M9 不用 RISER_HALF = None LANE_LO = LANE_HI = None LANE_Y = {} _orig_build = RT.build_grid _orig_pen_pads = RT.pen_pads _DIFF_SEGS = [] # 第一相布出来的 D± 铜,第二相用来封底层 def _extra_pen(g): u"""底层过路费 + D± 走廊底层封死 + DM 的贴合带。**每次 reset_pen 之后都要重打。**""" # ⛔ 底层过路费**分区收**(M9 起):D± 只在「西通道 x29~37 + 各走廊」里跑, # 西区(x<29,Hub/ESP32/DAC 那一窝,最挤)的底层其实不在任何 D± 底下, # 对它收满价等于白白把唯一的跳线层封掉。M8 全板降价(6→2)之所以更差 # (173→178),是因为那时 D± 横贯全板,降价换来的跳线全落在它底下。 if BOT_PEN: # D± 从槽针 x=45 到东侧通道,这一带下方的地平面最珍贵。 g.penalize(2, B.MID[0], -1, B.DP_CHAN[2], B.BH + 1, BOT_PEN) g.penalize(2, -1, -1, B.MID[0], B.BH + 1, BOT_PEN_W) g.penalize(2, B.DP_CHAN[2], -1, B.BW + 1, B.BH + 1, BOT_PEN_W) if _HUG[0] is not None: pen = g.pen[1] for k in range(len(pen)): if pen[k] < HUG_PEN: pen[k] = HUG_PEN for (x0, y0, x1, y1) in _HUG[0]: i0 = max(0, int(math.floor(x0 / RT.G))) i1 = min(RT.NX - 1, int(math.ceil(x1 / RT.G))) j0 = max(0, int(math.floor(y0 / RT.G))) j1 = min(RT.NY - 1, int(math.ceil(y1 / RT.G))) for j in range(j0, j1 + 1): base = j * RT.NX for i in range(i0, i1 + 1): pen[base + i] = 0 def pour_blocks(): u"""顶层电源铜面 → 布线器的障碍(**按各自的网**标归属)。 ⛔ 不喂进去的话,布线器会大大方方从 12V 高速路 / 铜墙中间穿过去 —— 铺铜时那根线把 9 A 的干道**整段切断**,而铺铜管理器 / DRC / 连通性 三个都只说「有铜」。 """ import pour_base as PB return [(net, r) for (n, net, lay, r, _p) in PB.plan() if lay == 1 and n.startswith('PWR-')] _POURS = [None] _DSIDE = [None] # 当前布的是一对里的哪一根:-1 = DM(西半道),+1 = DP(东半道) def dp_block_rects(i, side=None): u"""布第 i 对 D± 时,顶层要封死的地方(除了它自己的车道和目标走廊)。 ⛔ 通道里**不能整条封**:那一对还要在目标走廊那一段**往东拐出去**。 第一版按「通道里除本车道全封」写,结果只有 USB3 通(它的走廊正好和 `U32` 出口区重叠),其余四对全是「布了 0 段」—— 看着像通道太窄, 其实是出口被自己封死了。⇒ 通道的封锁按 y 取补集: 放行 `U32` 出口区 + 目标走廊那两段。 """ # ⛔ **一对里的两根各占半条道**。共用整条道的下场:先布的那根自己蜿蜒占掉 3.5 mm # (实测车道 4.5 只剩 1~2 格),第二根「没布通」。分半之后各 2.25, # 一根 1.0 的线只要 2×0.71 = 1.42 ⇒ 都装得下,而且天然并排、耦合。 lane = DP_LANE_X[i] if side: lane = lane + side * (LANE_HALF / 2.0) cor = B.usb_corridor(i) # 新针向:D± 焊盘在槽的南排 y0, y1 = B.PROJ[0], B.PROJ[1] py = B.slot_y(B.USB_SLOT_POS[i]) - B.PAD_ROW_DY hi = max(cor[3], py + 1.6) # 走廊上沿往上探到槽内的南排焊盘 keep = sorted([(U32_BOX[1], U32_BOX[3]), (cor[1], hi)]) # 合并重叠区间,再取补集 merged = [] for a, b in keep: if merged and a <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], b) else: merged.append([a, b]) gaps, cur = [], y0 for a, b in merged + [[y1, y1]]: if a - cur > 0.2: gaps.append((cur, a)) cur = max(cur, b) out = [] # ① D± 通道里不属于本对的 x 段(只在放行区间之外才封) half = LANE_HALF / 2.0 if side else LANE_HALF for (a, b) in ((B.DP_CHAN[0], lane - half), (lane + half, B.DP_CHAN[2])): if b - a > 0.2: for (ga, gb) in gaps: out.append((a, ga, b, gb)) # ② 中区里不属于目标走廊的部分(别让它钻进别的走廊) if cor[1] - y0 > 0.2: out.append((B.MID[0], y0, B.MID[1], cor[1])) if y1 - hi > 0.2: out.append((B.MID[0], hi, B.MID[1], y1)) return out _DPI = [None] # 当前在布第几对(1~5),None = 不限 def _block_lane(g): if _DPI[0] is None: return for (a, b, c, d) in dp_block_rects(_DPI[0], _DSIDE[0]): g.block_both(1, a, b, c, d, 0.0, net='#lane') def ant_blocks(halfw): u"""ESP32 天线:**缺口(没板材)+ 净空(不许有铜)**,两层都封死。 ⛔ 这一条以前是漏的:`place_base`(摆位)认净空、`pour_base`(铺铜)认净空, **布线器一个字都不知道** ⇒ A* 可以大大方方从天线底下穿线过去, 而 DRC / `board_check` / 几何校验一条都不响(设计文档 §PCB 要点写着 「天线区域下方禁止铺铜、**禁止走线**」,后半条从来没人执行)。 ⚠ 净空**只往外膨胀**,朝模组那一侧不膨胀:净空自带 0.5 侧余量, 再往模组里吃 halfw 会把最靠里那几只脚的出口封死。 """ import place_base as PL k = PL.antenna_keepout(PL.sizes()) n = B.notch_rect() e = RT.EDGE_CLR + halfw ax = PL.hand()['U20'][2] kk = {90: (k[0] - halfw, k[1] - halfw, k[2], k[3] + halfw), 270: (k[0], k[1] - halfw, k[2] + halfw, k[3] + halfw), 0: (k[0] - halfw, k[1], k[2] + halfw, k[3] + halfw), 180: (k[0] - halfw, k[1] - halfw, k[2] + halfw, k[3])}[int(round(ax)) % 360] return [kk, (n[0] - e, n[1] - e, n[2] + e, n[3] + e)] def build_grid(pads, halfw, vias=()): g = _orig_build(pads, halfw, vias) for (a, b, c, d) in ant_blocks(halfw): for l in (1, 2): g.block_both(l, a, b, c, d, 0.0, net='#ant') if _POURS[0] is None: _POURS[0] = pour_blocks() for (net, r) in _POURS[0]: g.block_both(1, r[0], r[1], r[2], r[3], 0.0, net=net) _block_lane(g) if TOP_ONLY[0]: g.block_both(2, -1, -1, B.BW + 1, B.BH + 1, 0.0, net='#topOnly') else: for (l, x0, y0, x1, y1, ww, nn) in _DIFF_SEGS: k = DIFF_KEEP + ww / 2.0 + halfw RT.block_track(g, 2, x0, y0, x1, y1, k, '#diffKeep') _extra_pen(g) return g def pen_pads(g, pads, halfw): _orig_pen_pads(g, pads, halfw) _extra_pen(g) RT.build_grid = build_grid RT.pen_pads = pen_pads # ⛔ 0.25 不行:十根线一起走 45°,相邻两条的**垂直**间距只有 0.5×cos45 = 0.354, # 减去线宽剩 0.104,而规则要 0.2(DRC 实测报「距离 4mil,应 ≥7.9mil」一大片)。 # 0.15 ⇒ 0.204,刚好过;JLC 两层最小线宽 0.127,做得了。 ESC_W = 0.15 # 逃逸段线宽(贴着 0.5 mm 脚距走) ESC_STUB = 0.6 # 出脚先直着走多长再拐 45°(短一点,斜线才来得及收) ESC_X = 81.0 # U32 西排逃逸的终点 x(= D± 通道东沿) # ⛔ **不能取 1.5**(= 1.2 干线 + 0.3 缝)。占用图里一根已布的干线往两侧各膨胀 # `halfw + ww/2 = 0.81 + 0.6 = 1.41`,1.5 只剩 **0.09 mm** 余量 ⇒ # 每对里第一根(DP)布完之后,第二根(DM)在起跑点**一个格点都找不到**。 # 实测现象整整齐齐:七对里六对都是「DP 通、DM 出不来」,看着像板子太挤。 ESC_PITCH = 1.6 # 散开之后的线距(≥1.41 的膨胀量即可;越大斜线越长) ESC_S_Y = 57.5 # 南排(USB6 / 上行)逃逸的终点 y ESC_SLOT = 1.8 # 槽针 / 模组脚逃逸出去多深(够干线起步即可; # 太深会把横跑段顶出走廊,见 dp_build 里那段) def _pair_of(net): u"""`USB3_DP` → 3;不是编号对(USB6 / 上行)返回 None。""" if net.startswith('USB') and net[3].isdigit() and int(net[3]) <= B.SLOT_N: return int(net[3]) return None def pair_run_y(i, net): u"""编号 D± 在目标槽走廊内的横向干线 y。""" # USB3 的 DM 等长蛇形紧邻槽端地脚;整对南移 0.8 mm 留足 0.2 mm 净距。 extra = 0.8 if i == 3 else 0.0 return round(B.usb_corridor(i)[3] - (3.2 if net.endswith('_DP') else 1.2) - extra, 3) def hub_escape(pads): u"""`U32` **西沿**十只下行 D± 脚的逃逸扇形 + 虚拟起跑点。 ⛔ **1.2 mm 的干线没法从 0.5 mm 间距的 QFN 脚上起步**(M8 §28 的教训,换布局照样成立): 十根线要从 0.5 的脚距散到 1.5,光斜着散开就要 4 mm。现象不是短路而是「布了 0 段」—— `pad_nodes` 在焊盘四周一个可起步的格点都找不到,看着像板子太挤。 ⇒ 先用 0.25 mm 细线把十只脚扇开到 1.5 间距(0.5 脚距下净距 0.25,过 DRC 的 0.20), 扇到东侧 D± 通道的各自车道,在那儿放虚拟焊盘当干线起跑点。 """ esc, launch = [], {} want = set() for nm, dp, dm in RB.PAIRS: if nm not in ('USB6', 'USBUP'): want.add(dp) want.add(dm) ds = [q for q in pads if q[0] == 'U32' and q[7] in want] ds.sort(key=lambda q: q[3]) if len(ds) != 10: print(u' [!] U32 西排找到 %d 只 D± 脚(应为 10)' % len(ds)) return [], {} mid = (ds[0][3] + ds[-1][3]) / 2.0 for i, q in enumerate(ds): px, py = q[2] - q[4] / 2.0, q[3] ty = round(mid + (i - (len(ds) - 1) / 2.0) * ESC_PITCH, 3) # ⛔ **每一对扇到自己的车道 x 上**,不要都停在同一条竖线(x=29)。 # 停在同一条线上的话,先布的那对横穿「出口区」时就把后面几对的 # 起跑点堵死 —— 实测五对里三对报「U32.launch 出不来」, # 而换车道宽度 / 换先后顺序 / 加导引线三种办法都救不回来。 # 扇线本身在不同的 y 上,互相不交叉;南边的对去南边的槽、北边的去北边, # 脚序(USB1 最南 … USB5 最北)与槽序同向,所以横穿也不会撞上别人的干线。 net = q[7] # 一对里的两根在车道内**左右分开 1.5**(DP 在东、DM 在西): # 共用一条竖线的话,先布的那根会压过后布那根的起跑点(实测「出不来」)。 # 1.5 > 占用图要求的 1.41,正好是差分对该有的中心距。 tx = DP_LANE_X.get(_pair_of(net), ESC_X) # ⛔ **DP 在西、DM 在东**(不是反过来)。构造出来的路径是「竖 → 横 → 竖」: # DM 走靠焊盘那条横线(南)、DP 走外面那条(北)。要不交叉,走南边横线的 # 那一根必须在通道里**靠东**,否则它往东跑时会横穿伙伴的竖段 # (自查实测 `USB5_DM 与 USB5_DP 净距 −0.001`)。 # 方向不同,左右要反过来:构造出来的路径是「竖 → 横 → 竖」, # **先转弯的那一根必须在通道里靠西**,否则它向槽端横跑时会切过 # 伙伴尚未结束的竖段。方向必须按当前脚的实际起跑点和目标横线判断; # 不能再用固定 y 阈值,U32 随手工布局移动后 USB3 会被误判并自交叉。 _i = _pair_of(net) _yr = pair_run_y(_i, net) _north = _yr > ty _east = (net.endswith('_DP')) if not _north else (net.endswith('_DM')) tx += 0.75 if _east else -0.75 x1 = px - ESC_STUB x2 = round(x1 - abs(ty - py), 3) esc.append((1, px, py, x1, py, ESC_W, net)) if abs(ty - py) > 1e-6: esc.append((1, x1, py, x2, ty, ESC_W, net)) if abs(tx - x2) > 1e-6: esc.append((1, x2, ty, tx, ty, ESC_W, net)) launch.setdefault(net, []).append(['U32', 'launch', round(tx, 3), ty, 0.3, 0.3, 1, net, 'RECT']) return esc, launch def hub_escape_aux(pads): u"""`U32` **北沿**四只脚(`USB6_DP/DM` + 上行 `UP_DP/DM`)的逃逸扇形。""" esc, launch = [], {} want = set() for nm, dp, dm in RB.PAIRS: if nm in ('USB6', 'USBUP'): want.add(dp) want.add(dm) cy32 = None for q in pads: if q[0] == 'U32' and str(q[1]) == '65': cy32 = q[3] ds = [q for q in pads if q[0] == 'U32' and q[7] in want and (cy32 is None or q[3] > cy32)] ds.sort(key=lambda q: q[2]) if len(ds) != 4: print(u' [!] U32 北排找到 %d 只 D± 脚(应为 4)' % len(ds)) return [], {} mid = (ds[0][2] + ds[-1][2]) / 2.0 for i, q in enumerate(ds): px, py = q[2], q[3] + q[5] / 2.0 tx = round(mid + (i - (len(ds) - 1) / 2.0) * ESC_PITCH, 3) y1 = py + ESC_STUB y2 = round(y1 + abs(tx - px), 3) net = q[7] esc.append((1, px, py, px, y1, ESC_W, net)) if abs(tx - px) > 1e-6: esc.append((1, px, y1, tx, y2, ESC_W, net)) # 从当前焊盘几何推导 Hub 北沿。旧版固定写成 y=79,是 M9 布局遗留; # U32 移到 y=52.903 后会凭空多走约 20 mm,并把 USB6/上行对拉进控制区。 aux_y = round(max(p[3] + p[5] / 2.0 for p in ds) + 2.0, 3) if abs(y2 - aux_y) > 1e-6: esc.append((1, tx, y2, tx, aux_y, ESC_W, net)) launch.setdefault(net, []).append(['U32', 'launch', tx, aux_y, 0.3, 0.3, 1, net, 'RECT']) return esc, launch def aux_hub_escape(pads): u"""U32 北沿辅助对逃逸;上行对用一小段底层完成与 USB6 的局部换序。""" def pad(net): hit = [q for q in pads if q[0] == 'U32' and q[7] == net] if len(hit) != 1: raise RuntimeError(u'U32 %s 焊盘数为 %d' % (net, len(hit))) q = hit[0] return (q[2], q[3] + q[5] / 2.0) esc, launch, vias = [], {}, [] usb = { 'USB6_DP': [(90.2, 60.0)], 'USB6_DM': [(91.8, 60.0)], } for net, tail in usb.items(): s = pad(net) pts = [s, (s[0], s[1] + 0.6), tail[0]] esc += _poly(net, pts, ESC_W) launch[net] = [['U32', 'launch', tail[0][0], tail[0][1], 0.3, 0.3, 1, net, 'RECT']] # 顶层从 0.5 mm 脚距散开,过孔下到底层;两根底线保持 1.6 mm 中心距。 spec = { 'UP_DP': ([(87.8, 59.2)], [(93.2, 61.5)]), 'UP_DM': ([(87.8, 60.8)], [(93.2, 63.1)]), } for net, (a, b) in spec.items(): s = pad(net) esc += _poly(net, [s, (s[0], s[1] + 0.6), a[0]], ESC_W) esc += [(2, a[0][0], a[0][1], b[0][0], b[0][1], DIFF_W, net)] vias += [(a[0][0], a[0][1], net), (b[0][0], b[0][1], net)] launch[net] = [['U32', 'launch', b[0][0], b[0][1], 0.3, 0.3, 1, net, 'RECT']] return esc, launch, vias def _slot_refs(): return [B.USB_SLOT_REF[i] for i in range(1, B.SLOT_N + 1)] SLOT_REFS = _slot_refs() def topology_check(pads): u"""用实测焊盘验证新拓扑。这些条件不成立时,坐标调得再好也不能下发。""" bad = [] def one(ref, net): return [q for q in pads if q[0] == ref and q[7] == net] ep = [q for q in pads if q[0] == 'U32' and str(q[1]) == '65'] if not ep: bad.append(u'U32 EP 焊盘不存在') return bad cx = ep[0][2] spans = [] for i in range(1, B.SLOT_N + 1): ref = B.USB_SLOT_REF[i] hp = one('U32', 'USB%d_DP' % i) sp = one(ref, 'USB%d_DP' % i) if len(hp) != 1 or len(sp) != 1: bad.append(u'USB%d 端点不唯一:U32=%d / %s=%d' % (i, len(hp), ref, len(sp))) continue if hp[0][2] >= cx: bad.append(u'USB%d 在 U32 上没有朝西出脚' % i) if not (B.DP_CHAN[0] < DP_LANE_X[i] < B.DP_CHAN[2]): bad.append(u'USB%d 车道 x=%.2f 超出 D± 通道' % (i, DP_LANE_X[i])) c = B.usb_corridor(i) if not (c[1] <= sp[0][3] - ESC_SLOT <= c[3]): bad.append(u'USB%d 槽端向南逃逸后没有落入目标走廊' % i) spans.append((i, hp[0][3], sp[0][3])) # 两端 y 顺序必须一致,否则平面内一定交叉。 for a, b in zip(spans, spans[1:]): if (a[1] - b[1]) * (a[2] - b[2]) <= 0: bad.append(u'USB%d/USB%d 两端 y 顺序相反,D± 必然交叉' % (a[0], b[0])) return sorted(set(bad)) def pair_escape(pads, refs, out_dir): u"""给**任何一端脚距太密的 D± 焊盘**接一段 0.25 mm 逃逸,末端放虚拟起跑点。 ⛔ 这一条 M9 是必须的:槽针是 **1.0 mm 脚距**(盘 0.7 宽), 而 D± 干线 1.2 mm + 净距 0.21 ⇒ 半宽 0.81,`pad_nodes` 在焊盘四周 **一个可起步的格点都找不到**(实测 `J34.46` = 0 个), 现象是「差分对 USB1 布了 0 段」,看着像通道太挤,其实是起不了步。 M8 侥幸能布是因为那时槽转 90°、焊盘长边(2.0)正好顺着走线方向; M9 转过来之后长边朝南北,这条侥幸就没了。 `out_dir`: 'N'/'S'/'E'/'W',焊盘往哪一侧逃。 """ esc, launch = [], {} D = {'N': (0.0, 1.0), 'S': (0.0, -1.0), 'E': (1.0, 0.0), 'W': (-1.0, 0.0)}[out_dir] for ref, nets in refs.items(): depth = 3.2 if ref in ('U20', 'J23') else ESC_SLOT ax = 0 if D[0] == 0 else 1 # 0 = 按 x 排,1 = 按 y 排 by = {} for q in pads: if q[0] == ref and q[7] in nets: by.setdefault(q[7], []).append(q) if len(by) != 2: print(u' [!] %s 上只找到 %d 个 D± 网' % (ref, len(by))) continue # Type-C 的 A6/B6 与 A7/B7 是交错四脚。J23 必须选 A6/A7 这组: # 它们的 DP/DM 左右顺序与 U32 一致;选“分得最开”的 B7/B6 会把 # 顺序反过来,两根线在单层平面内必然交叉。B6/B7 会在主流程里 # 保留为独立目标,不得像旧版那样静默丢掉。 na, nb = sorted(by) if ref == 'J23': prefer = {'UP_DP': 'A6', 'UP_DM': 'A7'} qa = next(q for q in by[na] if str(q[1]) == prefer[na]) qb = next(q for q in by[nb] if str(q[1]) == prefer[nb]) best = (abs(qa[2 + ax] - qb[2 + ax]), qa, qb) else: best = None for qa in by[na]: for qb in by[nb]: d0 = abs(qa[2 + ax] - qb[2 + ax]) if best is None or d0 > best[0]: best = (d0, qa, qb) ps = sorted([best[1], best[2]], key=lambda q: q[2 + ax]) mid = (ps[0][2 + ax] + ps[1][2 + ax]) / 2.0 for k, q in enumerate(ps): half = (q[5] / 2.0) if D[1] else (q[4] / 2.0) sx = q[2] + D[0] * half sy = q[3] + D[1] * half t = round(mid + (k - 0.5) * ESC_PITCH, 3) # 散到 1.5 间距 # 先直着走 ESC_STUB,再 45° 斜到目标位,最后直着走到 ESC_SLOT 深度 x1, y1 = sx + D[0] * ESC_STUB, sy + D[1] * ESC_STUB d = abs((t - (sx if ax == 0 else sy))) x2, y2 = (t, y1 + D[1] * d) if ax == 0 else (x1 + D[0] * d, t) x3, y3 = (x2, sy + D[1] * depth) if ax == 0 else (sx + D[0] * depth, y2) net = q[7] esc.append((1, sx, sy, x1, y1, ESC_W, net)) if d > 1e-6: esc.append((1, x1, y1, x2, y2, ESC_W, net)) if abs((x3 - x2) + (y3 - y2)) > 1e-6: esc.append((1, x2, y2, x3, y3, ESC_W, net)) launch.setdefault(net, []).append([ref, 'launch', round(x3, 3), round(y3, 3), 0.3, 0.3, 1, net, 'RECT']) return esc, launch def hug_rects(segs, band=HUG_BAND): out = [] for (l, x0, y0, x1, y1, _w, _n) in segs: if l != 1: continue out.append((min(x0, x1) - band, min(y0, y1) - band, max(x0, x1) + band, max(y0, y1) + band)) return out def rail_absorb(bypad): u"""软铺铜(`pour_base.RAILS`)盖住的脚不再交给 A*。 ⛔ 不做这一步的实测后果:`+3V3` 一个网 47 只脚、A* 把它当一棵树布, 摆位一动就崩(168 条里它一个网占 45)。这些脚本来就该靠铜面连,不该有线。 ⛔ **一个网有多块铜时必须一起处理**(第一版是按块逐个跑的,踩了两个坑): ① 第二块在**第一块已经加过虚拟落点的表**上再跑一遍 ⇒ 落点指数膨胀 (47 只脚变成 126 个目标),实测直接炸到 239 条; ② 两块铜之间**没有连接** —— 同网不同岛,铺完还是断的, 而铺铜管理器 / DRC 都只说「有铜」。 ⇒ 现在:先按网归并所有块 → 区内的脚一次性摘掉 → 区外的脚**每只只给一个**落点 (落在离它最近的那块上)→ 再给块与块之间各加一对落点,逼布线器把它们接起来。 ⚠ 判据不在这里。软铺铜可能被信号切成孤岛 —— **唯一判据是 `board_check --base` 从板上读回铜做并查集**(方案 §27.7 ④)。 """ import pour_base as PB byn = {} for name, net, r in PB.RAILS: byn.setdefault(net, []).append((name, r)) bridges = [] for net, rects in byn.items(): ps = bypad.get(net) if not ps: continue def inside(q, r): return r[0] <= q[2] <= r[2] and r[1] <= q[3] <= r[3] ins = [q for q in ps if any(inside(q, r) for _n, r in rects)] out = [q for q in ps if q not in ins] if not ins: continue virt = [] for q in out: best = None for _n, r in rects: vx = min(max(q[2], r[0] + 1.0), r[2] - 1.0) vy = min(max(q[3], r[1] + 1.0), r[3] - 1.0) d = (vx - q[2]) ** 2 + (vy - q[3]) ** 2 if best is None or d < best[0]: best = (d, vx, vy) virt.append(['RAIL', 'rail', round(best[1], 3), round(best[2], 3), 0.6, 0.6, 1, net, 'RECT']) # 块与块之间也要接起来(同网不同岛,铺铜自己不会连)。几何是已知的, # 直接构造固定铜桥;把两端伪装成焊盘交给 A* 会在铜区中心判成「出不来」。 for i in range(len(rects) - 1): ra, rb = rects[i][1], rects[i + 1][1] ax = min(max((rb[0] + rb[2]) / 2.0, ra[0] + 1.0), ra[2] - 1.0) ay = min(max((rb[1] + rb[3]) / 2.0, ra[1] + 1.0), ra[3] - 1.0) bx = min(max(ax, rb[0] + 1.0), rb[2] - 1.0) by = min(max(ay, rb[1] + 1.0), rb[3] - 1.0) bridges.append((1, round(ax, 3), round(ay, 3), round(bx, 3), round(by, 3), RT.WIDTH.get(net, RT.W_DEFAULT), net)) if out: bypad[net] = out + virt else: bypad.pop(net, None) print(u' 软铺铜 %-9s %d 块 吸收 %2d 只脚,区外 %2d 只(每只一个落点)+ %d 条固定铜桥' % (net, len(rects), len(ins), len(out), len(rects) - 1)) return bridges def wall_build(): u"""`+3V3` 槽墙之间的**底层桥**:按构造画,不走 A*。 ⛔ 为什么必须是底层:每个槽的 `SDA_i`/`SCL_i` 在北排 x 75/76,要一路往西去 `U35`, 十根线全都要横穿 x 70.6~73.4 那一条。顶层连墙 = 把它们拦腰截断, 而**布线器只会报「没布通」,DRC 与铺铜管理器一个字都不说**。 ⚠ 缝在地平面上:每段 9.5 mm、宽 0.8,都在 x=72。D± 全在 x<66, 不碰方案 §6.2 第 1 条「D± 走廊下方一刀不许割」。 """ bs, vs = B.wall3v3_bridges() segs = [(2, x, y0, x, y1, w, '+3V3') for (x, y0, y1, w) in bs] vias = [(x, y, '+3V3') for (x, y) in vs] return segs, vias TAP_W = 0.30 # PWR9A 规则下限;这一脚只走栅极电容的充放电电流 TAP_Y = 0.70 # 贴板边:边距 0.4 + 半线宽 0.15 + 0.15 余量 def gate_tap(pads): u"""`C1.1`(VIN_FUSED)→ `DZ1.1` 的固定细线:沿板边从 DZ1 正下方穿过去。 ⛔ 为什么不交给 A*: ① `VIN_FUSED` 是 2.0 mm 的 9 A 网,0402 的焊盘周围找不到一个能起步的格点 (实测「VIN_FUSED: C1.1 出不来」);而这一脚本来只需要细线。 ② 板边那条缝在 0.25 网格上表示不出来:板边膨胀到 0.76、DZ1 下沿膨胀到 0.87, 格点 0.75 / 1.0 两头都被判死,而真实几何下 0.30 的线放在 y=0.70 两边都有余量。 拓扑:GATE 从 Q1.4 向南接 DZ1.2,再**绕 C1 北侧**接 C1.2 / R1.2;这根线在南侧, 两者不相交,于是 C1.1 不会被 GATE 围在板边(单层平面上唯一的解)。 """ def pad(ref, pin): hit = [q for q in pads if q[0] == ref and str(q[1]) == pin and q[6] == 1] if len(hit) != 1 or hit[0][7] != 'VIN_FUSED': raise RuntimeError(u'%s.%s 应是唯一的顶层 VIN_FUSED 焊盘' % (ref, pin)) return hit[0] c, d = pad('C1', '1'), pad('DZ1', '1') segs = _poly('VIN_FUSED', [(c[2], c[3]), (c[2], TAP_Y), (d[2], TAP_Y), (d[2], d[3])], TAP_W) bad = dp_obstacle_check(segs, pads, nets=('VIN_FUSED',)) for s in segs: if min(s[2], s[4]) - s[5] / 2.0 < RT.EDGE_CLR - 1e-6: bad.append(u'VIN_FUSED 细线离南板边不足 %.2f' % RT.EDGE_CLR) for n, (hx, hy) in B.MOUNT_HOLES.items(): e = (1, hx, hy, hx, hy, 0, '#') if _seg_dist(s, e) - B.HOLE_D / 2.0 - s[5] / 2.0 < 0.3: bad.append(u'VIN_FUSED 细线压到安装孔 %s' % n) if bad: raise RuntimeError(u'C1.1 固定细线几何校验不过:' + u';'.join(bad)) return segs, c def wall_absorb(bypad): u"""`+3V3` 槽墙盖住的 15 只槽脚从 A* 摘掉,墙外的脚每只给一个落点。 ⛔⛔ 落点的位号必须是 `WALL`(`router.PRE_WIRED`):那才是「这几个落点已经被铜 连在一起了」的意思,Prim 会把它们**全部当种子**,一只脚就近接到墙上就完事。 不带这个标记的话(`rail_absorb` 那种普通落点),Prim 把落点也当普通节点, **逼 A* 在墙上再布线把落点串起来** —— 实测 `+3V3` 从最北那个落点一路蜿蜒到 板子最南(100 mm,横穿五条走廊,压在 I²C 的出脚道上),而布线器只报「布通了」。 ⚠ 十个落点(每块墙的南北两个走廊帽):走廊帽整宽 2.8 mm,出口最多; 针排那三段窄,落点放上去会「挑得出来、引出脚发不出来」。 """ ps = bypad.get('+3V3') if not ps: return rects = B.wall3v3_rects() def inside(q): return any(r[0] <= q[2] <= r[2] and r[1] <= q[3] <= r[3] for r in rects) ins = [q for q in ps if inside(q)] out = [q for q in ps if not inside(q)] if not ins: return caps = [r for r in rects if r[2] - r[0] > 2.0 and r[3] - r[1] < 2.0] virt = [['WALL', 'wall', round((r[0] + r[2]) / 2.0, 3), round((r[1] + r[3]) / 2.0, 3), 0.6, 0.6, 1, '+3V3', 'RECT'] for r in caps] bypad['+3V3'] = out + virt print(u' 3V3 槽墙 %d 块 吸收 %2d 只槽脚,墙外 %2d 只 + %d 个 WALL 落点(互相不布线)' % (len(rects), len(ins), len(out), len(virt))) def bus_absorb(bypad): u"""12V 干道已连成一块铜:覆盖的插槽脚不再参与 A*。""" ps = bypad.get('+12V') if not ps: return rects = B.bus_rects() def inside(q): x0, y0 = q[2] - q[4] / 2.0, q[3] - q[5] / 2.0 x1, y1 = q[2] + q[4] / 2.0, q[3] + q[5] / 2.0 return any(r[0] <= x0 and x1 <= r[2] and r[1] <= y0 and y1 <= r[3] for r in rects) ins = [q for q in ps if inside(q)] out = [q for q in ps if q not in ins] r = B.BUS_W landing = ['WALL', '12v-bus', round((r[0] + r[2]) / 2.0, 3), round(r[1] + 2.0, 3), 0.8, 0.8, 1, '+12V', 'RECT'] bypad['+12V'] = out + [landing] print(u' 12V 干道 %d 块 吸收 %2d 只槽脚,干道外 %2d 只 + 1 个 WALL 落点' % (len(rects), len(ins), len(out))) def balance_numbered_pairs(segs, esc, pairs=None, prefer_horizontal=False): u"""在较短线的专属车道内加 45° 微蛇形,将对内长差压到 1 mm 以内。 优先使用最长的直线段;若竖向段容不下(当前 USB3 只有 3 mm),就使用 横向干线。补偿始终朝远离伴线的一侧展开,避免侵入两线之间的耦合间隙。 """ tol = RB.DIFF['differentailPairLenTolerMax'] if hasattr(RB, 'DIFF') else 1.0 def length(net): return sum(math.hypot(s[3] - s[1], s[4] - s[2]) for s in list(esc) + segs if s[6] == net) targets = pairs or [('USB%d_DP' % i, 'USB%d_DM' % i) for i in range(1, B.SLOT_N + 1)] for dp, dm in targets: ld, lm = length(dp), length(dm) short, delta = (dp, lm - ld) if ld < lm else (dm, ld - lm) if delta <= tol: continue # 只补到 0.25 mm 余量,避免网格/小数误差又越过 1 mm。 need = delta - 0.25 total_a = need / (2.0 * (math.sqrt(2.0) - 1.0)) amps = [] left = total_a while left > 1e-6: a = min(0.70, left) amps.append(a) left -= a used = 2.0 * sum(amps) # 两端各留 0.2 mm 直线,避免补偿尖点正好落在转角上。 cand = [] for k, s in enumerate(segs): if s[6] != short: continue dx, dy = abs(s[3] - s[1]), abs(s[4] - s[2]) span = max(dx, dy) if min(dx, dy) < 1e-6 and span >= used + 0.4: axis = 0 if dy > dx else 1 preferred = 1 if prefer_horizontal else 0 cand.append((0 if axis == preferred else 1, -span, axis, k, s)) if not cand: raise RuntimeError(u'%s 没有可容纳 %.2f mm 等长段的直线车道' % (short, used)) _pref, neg_span, _axis, k, s = min(cand) _span = -neg_span partner = dm if short == dp else dp repl = [] if _axis == 0: # 竖向主线,补偿在 x 方向展开 pp = [(abs(q[4] - q[2]), q[1]) for q in segs if q[6] == partner and abs(q[3] - q[1]) < 1e-6] partner_coord = max(pp)[1] if pp else s[1] away = 1.0 if s[1] > partner_coord else -1.0 sign = 1.0 if s[4] > s[2] else -1.0 x, y = s[1], s[2] + sign * ((_span - used) / 2.0) if abs(y - s[2]) > 1e-6: repl.append((s[0], x, s[2], x, y, s[5], s[6])) for a in amps: y2 = y + sign * a repl.append((s[0], x, y, x + away * a, y2, s[5], s[6])) y3 = y2 + sign * a repl.append((s[0], x + away * a, y2, x, y3, s[5], s[6])) y = y3 if abs(y - s[4]) > 1e-6: repl.append((s[0], x, y, s[3], s[4], s[5], s[6])) else: # 横向主线,补偿在 y 方向展开 pp = [(abs(q[3] - q[1]), q[2]) for q in segs if q[6] == partner and abs(q[4] - q[2]) < 1e-6] partner_coord = max(pp)[1] if pp else s[2] away = 1.0 if s[2] > partner_coord else -1.0 sign = 1.0 if s[3] > s[1] else -1.0 x, y = s[1] + sign * ((_span - used) / 2.0), s[2] if abs(x - s[1]) > 1e-6: repl.append((s[0], s[1], y, x, y, s[5], s[6])) for a in amps: x2 = x + sign * a repl.append((s[0], x, y, x2, y + away * a, s[5], s[6])) x3 = x2 + sign * a repl.append((s[0], x2, y + away * a, x3, y, s[5], s[6])) x = x3 if abs(x - s[3]) > 1e-6: repl.append((s[0], x, y, s[3], s[4], s[5], s[6])) segs[k:k + 1] = repl def dp_build(bypad, esc=()): u"""**按构造**画五对编号 D±(不走 A*)。 ⛔ 为什么不用 A*:换了七种办法(车道宽度 3.2/4.5、先后顺序、导引线、分道扇出、 半条道、降线宽、去掉 HUG)都是「一对里只进得去一根」。读占用图看到的真相是: **先布的那一根自己蜿蜒**,4.5 mm 的车道被它占掉 3.5,第二根再也挤不进去; 而 A* 没有「贴着伙伴走」的概念。 ⇒ 几何已经完全规则(通道空、走廊空、每对一条车道),路径本来就是一个 L: `U32` 落点 →(车道里竖着走)→(走廊里横着走)→ 槽侧落点。直接画。 ⚠ DM 走靠焊盘那条横线(+1.5)、DP 走外面那条(+3.0);通道里谁东谁西由 `hub_escape` 按走向定(后转弯的靠西),两者配合才不交叉。 """ segs = [] for i in range(1, B.SLOT_N + 1): cor = B.usb_corridor(i) for suf in ('_DP', '_DM'): net = 'USB%d%s' % (i, suf) ps = bypad.get(net) or [] a = [q for q in ps if q[0] == 'U32'] b = [q for q in ps if q[0] == B.USB_SLOT_REF[i]] if not a or not b: print(u' [!] %s 少了落点(U32 %d / 槽 %d)' % (net, len(a), len(b))) continue x0, y0 = a[0][2], a[0][3] x1, y1 = b[0][2], b[0][3] # ⛔ 横跑段的 y 必须**从走廊南沿算**,不能从逃逸落点往上加: # 落点一深(ESC_SLOT)就顶出过道 —— 实测 USB5 跑到 y 113.4/114.9, # 而给它留的 `DP_N_LANE` 只有 108.74~112.74,直接从风扇块 # (C738 / R3 / L2 / L3)身上压过去,DRC 报 34 条「焊盘到导线 0 mil」。 # 两条横线间距 2.0:DM 的末段竖线顶到落点(走廊南沿 + ESC_SLOT), # DP 的横线必须比它再高 1.2 才够净距(实测 2.7 时只有 0.9,差 0.3)。 yr = pair_run_y(i, net) w = DIFF_W if abs(yr - y0) > 1e-6: segs.append((1, x0, y0, x0, yr, w, net)) if abs(x1 - x0) > 1e-6: segs.append((1, x0, yr, x1, yr, w, net)) if abs(y1 - yr) > 1e-6: segs.append((1, x1, yr, x1, y1, w, net)) balance_numbered_pairs(segs, esc) bad = dp_selfcheck(segs + [e for e in esc]) if bad: raise RuntimeError(u'构造出来的 D± 净距不过:' + u';'.join(bad[:8])) return segs def _poly(net, pts, width): return [(1, a[0], a[1], b[0], b[1], width, net) for a, b in zip(pts, pts[1:]) if abs(a[0] - b[0]) + abs(a[1] - b[1]) > 1e-9] def aux_dp_build(bypad): u"""按空走廊构造 USB6 与上行 USB,不再让单网 A* 抢占伙伴的通道。""" def point(net, ref, pin=None): hit = [q for q in bypad.get(net, []) if q[0] == ref and (pin is None or str(q[1]) == pin)] if len(hit) != 1: raise RuntimeError(u'%s 的 %s.%s 端点数为 %d' % (net, ref, pin or '*', len(hit))) return (hit[0][2], hit[0][3]) out = [] u6dp0, u6dp1 = point('USB6_DP', 'U32'), point('USB6_DP', 'U20') u6dm0, u6dm1 = point('USB6_DM', 'U32'), point('USB6_DM', 'U20') # U31/RT601 从中间挡住直线;两根线成对绕其东侧,再从 U20 西侧空带北上。 p = [(90.5, 60.75), (90.5, 69.5), (91.0, 70.0), (92.5, 72.0), (92.5, 75.5), (90.0, 78.0), (82.0, 78.0), (80.75, 79.25), (80.75, 110.1), (85.22, 110.1)] m = [(92.1, 60.75), (92.1, 68.9), (92.6, 69.4), (94.1, 71.5), (94.1, 75.5), (91.6, 79.6), (83.6, 79.6), (82.35, 80.85), (82.35, 111.7), (85.80, 111.7)] out += _poly('USB6_DP', [u6dp0, p[0]], 0.3) + _poly('USB6_DP', p, DIFF_W) out += _poly('USB6_DP', [p[-1], u6dp1], 0.3) out += _poly('USB6_DM', [u6dm0, m[0]], 0.3) + _poly('USB6_DM', m, DIFF_W) out += _poly('USB6_DM', [m[-1], (86.82, 111.7), u6dm1], 0.3) updp0, updp1 = point('UP_DP', 'J23', 'launch'), point('UP_DP', 'U32') updm0, updm1 = point('UP_DM', 'J23', 'launch'), point('UP_DM', 'U32') # 上行对沿东板边走,避开 Hub 去耦列;两次 90 度转弯保持 1.6 mm 中心距。 p = [(94.5, 12.5), (103.2, 12.5), (103.2, 61.5)] m = [(96.1, 14.1), (104.8, 14.1), (104.8, 63.1)] out += _poly('UP_DP', [updp0, p[0]], 0.3) + _poly('UP_DP', p, DIFF_W) out += _poly('UP_DP', [p[-1], updp1], 0.3) out += _poly('UP_DM', [updm0, m[0]], 0.3) + _poly('UP_DM', m, DIFF_W) out += _poly('UP_DM', [m[-1], updm1], 0.3) # Type-C 的 B6/B7 也要并回同名网络。DP 从焊盘北侧绕,DM 从南侧绕,互不交叉。 b6 = point('UP_DP', 'J23', 'B6') b7 = point('UP_DM', 'J23', 'B7') out += _poly('UP_DP', [b6, (95.321, 8.547), (96.5, 9.25), (96.5, 10.25), (96.25, 12.5), (94.5, 12.5)], 0.3) out += _poly('UP_DM', [b7, (93.823, 6.3), (94.821, 6.3), (94.821, 7.597)], ESC_W) return out def _seg_dist(a, b): u"""两段线段之间的最短距离。""" import math as _m def p2s(px, py, x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 L2 = dx * dx + dy * dy t = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / L2)) return _m.hypot(px - (x0 + t * dx), py - (y0 + t * dy)) return min(p2s(a[1], a[2], b[1], b[2], b[3], b[4]), p2s(a[3], a[4], b[1], b[2], b[3], b[4]), p2s(b[1], b[2], a[1], a[2], a[3], a[4]), p2s(b[3], b[4], a[1], a[2], a[3], a[4])) def dp_selfcheck(segs, clr=0.2): u"""构造出来的 D± 之间**净距自查**。 ⛔ 按构造画的东西**没有 A* 的占用图兜底**,几何必须自己查:第一版画完直接下发, DRC 才发现不同对之间 0 mil(车道顺序排反、扇形横线横穿别人的竖段)。 """ bad = [] for i in range(len(segs)): a = segs[i] for j in range(i + 1, len(segs)): b = segs[j] if a[6] == b[6] or a[0] != b[0]: continue need = clr + a[5] / 2.0 + b[5] / 2.0 d = _seg_dist(a, b) if d < need - 1e-6: bad.append(u'%s(%.2f,%.2f→%.2f,%.2f) 与 %s(%.2f,%.2f→%.2f,%.2f) 净距 %.3f < %.3f' % (a[6], a[1], a[2], a[3], a[4], b[6], b[1], b[2], b[3], b[4], d - a[5] / 2.0 - b[5] / 2.0, clr)) return bad def _seg_rect_dist(s, r): if ((r[0] <= s[1] <= r[2] and r[1] <= s[2] <= r[3]) or (r[0] <= s[3] <= r[2] and r[1] <= s[4] <= r[3])): return 0.0 edges = [(1, r[0], r[1], r[2], r[1], 0, '#'), (1, r[2], r[1], r[2], r[3], 0, '#'), (1, r[2], r[3], r[0], r[3], 0, '#'), (1, r[0], r[3], r[0], r[1], 0, '#')] return min(_seg_dist(s, e) for e in edges) def dp_obstacle_check(segs, pads, clr=0.2, nets=None): u"""构造线没有 A* 占用图兜底,必须显式检查焊盘和硬功率铜。`nets` 缺省 = 七对 D±。""" bad = [] for s in segs: if s[6] not in (nets or DP_NETS): continue for q in pads: if q[6] != s[0] or q[7] == s[6]: continue r = (q[2] - q[4] / 2.0, q[3] - q[5] / 2.0, q[2] + q[4] / 2.0, q[3] + q[5] / 2.0) d = _seg_rect_dist(s, r) - s[5] / 2.0 if d < clr - 1e-6: bad.append(u'%s(%.2f,%.2f→%.2f,%.2f) vs %s.%s(%s) 净距 %.3f' % (s[6], s[1], s[2], s[3], s[4], q[0], q[1], q[7] or 'NC', d)) for net, r in pour_blocks(): if net == s[6]: continue d = _seg_rect_dist(s, r) - s[5] / 2.0 if d < clr - 1e-6: bad.append(u'%s 切入 %s 功率铜(净距 %.3f)' % (s[6], net, d)) return sorted(set(bad)) # ── 主流程 ─────────────────────────────────────────────────────────────── def plan_all(priority=()): pads = apply_route_fix(split_th(RT.load_pads())) tb = topology_check(pads) if tb: raise RuntimeError(u'底板拓扑自检不过:' + u';'.join(tb)) board_vias = load_board_vias() th = sum(1 for p in pads if str(p[1]).endswith(BOT_SUFFIX)) print(u'板上已有过孔 %d 个;通孔焊盘 %d 只(已拆成两层)' % (len(board_vias), th)) bypad = {} for p in pads: if not p[7] or p[7] in RT.SKIP or str(p[1]).endswith(BOT_SUFFIX): continue bypad.setdefault(p[7], []).append(p) rail_segs = rail_absorb(bypad) bus_absorb(bypad) wall_absorb(bypad) segs, vias, logs = list(rail_segs), [], [] # `+3V3` 槽墙之间的底层桥:**最先画**,好让后面所有网都把它当障碍绕开 wsegs, wvias = wall_build() segs += wsegs vias += wvias # C1.1 → DZ1.1 的固定细线:同样最先画、不许拆,C1.1 从 A* 里摘掉(见 gate_tap) tsegs, tpad = gate_tap(pads) segs += tsegs bypad['VIN_FUSED'] = [q for q in bypad.get('VIN_FUSED', []) if not (q[0] == tpad[0] and str(q[1]) == str(tpad[1]))] print(u' C1.1 → DZ1.1:板边固定细线 %d 段 %.2f mm' % (len(tsegs), TAP_W)) # 地岛 / GATE 缺口里 GND 贴片脚的专用过孔(pour_base.GND_PAD_VIAS):布线器必须先看见它们, # 否则信号线会从孔位上压过去,缝合那一步再把孔打上就是短路 import pour_base as PB gv = [(x, y, 'GND') for (x, y) in PB.gnd_pad_vias()[1]] vias += gv print(u' GND 贴片脚专用过孔 %d 个(D1.2 / C5.1 / R1.1)' % len(gv)) # `U32` 的十四只 D± 脚换成虚拟起跑点(西排 10 + 北排 4)。 esc, launch = hub_escape(pads) aux_esc, aux_launch, aux_vias = aux_hub_escape(pads) esc += aux_esc vias += aux_vias for k, v in aux_launch.items(): launch.setdefault(k, []).extend(v) # 槽针 / U20 / USB-C 那三处脚距太密的,也各接一段逃逸(见 pair_escape) slot_refs = {} for i, ref in enumerate(SLOT_REFS, start=1): slot_refs[ref] = {'USB%d_DP' % i, 'USB%d_DM' % i} more = [pair_escape(pads, slot_refs, 'S'), pair_escape(pads, {'U20': {'USB6_DP', 'USB6_DM'}}, 'N'), pair_escape(pads, {'J23': {'UP_DP', 'UP_DM'}}, 'N')] for e2, l2 in more: esc += e2 for k, v in l2.items(): launch.setdefault(k, []).extend(v) segs += esc j23_escape_pin = {'UP_DP': 'A6', 'UP_DM': 'A7'} for net in DP_NETS: drop = {'U32'} | set(SLOT_REFS) | {'U20', 'J23'} keep = [q for q in bypad.get(net, []) if q[0] not in drop] # Type-C 同名的另一只 D± 脚也必须接上;只摘掉已经由 # pair_escape 连到 launch 的 A6/A7,保留 B6/B7 给 A* 做局部交叉。 if net in j23_escape_pin: keep += [q for q in bypad.get(net, []) if q[0] == 'J23' and str(q[1]) != j23_escape_pin[net]] bypad[net] = keep + launch.get(net, []) print(u' U32 逃逸扇形:%d 段 %.2f mm 细线(西排 10 只进东侧车道,北排 4 只向北)' % (len(esc), ESC_W)) # ⛔ **第一相把「焊盘过路费」降到 2**(默认 10):D± 是第一批布的,没有「后面的网」 # 要保护,收下来只会让它们绕开满板的焊盘光环而蜿蜒(M8 实测一对布出 40 段)。 _pad_pen = RT.PAD_PEN RT.PAD_PEN = 2 # ── 第一相:七对 D± 全部按构造画,避免逐网 A* 抢占伙伴通道 ───────────── dpb = dp_build(bypad, esc) segs += dpb print(u' 五对编号 D±:按构造画了 %d 段(不走 A*,见 dp_build)' % len(dpb)) aux = aux_dp_build(bypad) balance_numbered_pairs(aux, esc, [('USB6_DP', 'USB6_DM'), ('UP_DP', 'UP_DM')], prefer_horizontal=True) segs += aux print(u' USB6/上行 D±:按构造画了 %d 段(不走单网 A*)' % len(aux)) dbad = dp_selfcheck([s for s in segs if s[6] in DP_NETS]) dbad += dp_obstacle_check([s for s in segs if s[6] in DP_NETS], pads) if dbad: raise RuntimeError(u'构造 D± 几何校验不过(%d 条):%s' % (len(dbad), u';'.join(dbad[:80]))) RT.PAD_PEN = _pad_pen _DIFF_SEGS[:] = [s for s in segs if s[0] == 1] print(u'第一相:D± %d 段,底层 D± 走廊封死 %.1f mm' % (len(segs), DIFF_KEEP)) if '--dp-only' in sys.argv: return segs, vias, logs # ── 第二相:其余全部 ─────────────────────────────────────────────── rest = set(bypad) - set(DP_NETS) RT.AUTO_CACHE.clear() blocked = list(pour_blocks()) blocked += [('#ant', r) for r in ant_blocks(0.0)] # 有优先集时只处理上一版失败网;给已成功网络提前铺短铜只会占掉共享通道。 direct_nets = rest & set(priority) if priority else rest local_map = {net: bypad[net] for net in direct_nets} dsegs, merged = RT.direct_links(pads, local_map, segs, vias, blocked_rects=blocked) segs += dsegs for net, groups in merged.items(): drop = set() for group in groups: drop |= set(list(group)[1:]) bypad[net] = [q for q in bypad[net] if (q[0], q[1]) not in drop] print(u' 普通信号短直连:%d 段 / %d 个网络(硬功率铜与天线禁区已参与判定)' % (len(dsegs), len(merged))) fixed_s, fixed_v = len(segs), len(vias) logs += RT.route_groups(bypad, pads, segs, vias, board_vias, {}, priority=priority, only=rest) if logs and '--repair' in sys.argv: print(u' 首轮 %d 条没搞定,进入拆线重布' % len(logs)) segs, vias, logs = rip_repair(bypad, pads, segs, vias, logs, board_vias, rest, fixed_s, fixed_v) return segs, vias, logs def pour_nets(): u"""靠铜面 / 铜墙成网的网络:它们的焊盘本来就没有线,几何连通性判据不适用。""" import pour_base as PB out = {net for (_n, net, _r) in PB.RAILS} out |= {net for (net, _r) in pour_blocks()} return out | {'+12V', '+3V3'} def rip_repair(bypad, pads, segs, vias, logs, board_vias, rest, fixed_s, fixed_v): u"""把 `router.repair`(拆线重布)接到底板上。 ⛔ 不能直接把整张 `bypad` 交给它,有两处会静默出错: ① D± 七对也在 `bypad` 里(落点形式)。它们的铜是第一相按构造画的、在固定段里, 一旦被选为「挡路的网」,repair 会用 A* 在落点之间**再布一遍** —— 板上多出 一根同名线,净距自查不会报(同网)。⇒ 只交 `rest`。 ② repair 用 `check_connected` 验收,它按「每只焊盘都要有线端」判。 铜面 / 铜墙网(+3V3、+12V、各 RAIL)的焊盘本来就没线,会被判断线 —— 只会让重布更难被接受,不会放进坏结果,但等于这些网永远拆不动。 ⇒ 验收时去掉铜面网、通孔焊盘的底层副本和单脚网。 前 `fixed_s` / `fixed_v` 条(槽墙桥、逃逸扇形、D±、直连)一根都不拆。 """ import time sub = {n: bypad[n] for n in rest if n in bypad} skip = pour_nets() | set(DP_NETS) cnt = {} for q in pads: cnt[q[7]] = cnt.get(q[7], 0) + 1 chk_pads = [q for q in pads if q[7] in sub and q[7] not in skip and cnt[q[7]] >= 2 and not str(q[1]).endswith(BOT_SUFFIX)] orig_chk, orig_rg = RT.check_connected, RT.route_groups t0, n = time.time(), [0] def chk(_pads, s, v): return orig_chk(chk_pads, s, v) def rg(*a, **k): n[0] += 1 out = orig_rg(*a, **k) print(u' 拆线重布 #%d:重布 %d 个网,失败 %d 条(累计 %.0f s)' % (n[0], len(k.get('only') or ()), len(out), time.time() - t0)) sys.stdout.flush() return out RT.check_connected, RT.route_groups = chk, rg try: return RT.repair(sub, pads, segs, vias, logs, board_vias, {}, fixed_s, fixed_v) finally: RT.check_connected, RT.route_groups = orig_chk, orig_rg def report(segs, vias, logs): tot = sum(math.hypot(s[3] - s[1], s[4] - s[2]) for s in segs) bot = sum(math.hypot(s[3] - s[1], s[4] - s[2]) for s in segs if s[0] == 2) print(u'导线 %d 段 / 总长 %.1f mm(底层 %.1f mm = %.0f%%)/ 过孔 %d' % (len(segs), tot, bot, 100.0 * bot / max(tot, 1e-9), len(vias))) nets = set(s[6] for s in segs) print(u'涉及 %d 个网络' % len(nets)) # D± 长度:每个网自己的铜有多长 print(u'差分对实测铜长:') for name, dp, dm in RB.PAIRS: for n in (dp, dm): L = sum(math.hypot(s[3] - s[1], s[4] - s[2]) for s in segs if s[6] == n) nb = sum(1 for s in segs if s[6] == n and s[0] == 2) print(u' %-9s %6.1f mm%s' % (n, L, u' ⚠ 有 %d 段在底层' % nb if nb else u'')) if logs: print(u'[!] %d 条没搞定:' % len(logs)) for m in logs[:40]: print(u' ' + m) if __name__ == '__main__': if '--clear' in sys.argv: RT.clear_mine() sys.exit(0) if '--snapshot-vias' in sys.argv: print(u'底板既有过孔快照:%d 个 -> %s' % (len(load_board_vias()), BOARD_VIAS)) sys.exit(0) c = json.load(io.open(CACHE, encoding='utf-8')) if os.path.exists(CACHE) else {} cache_ok = (c.get('schema') == CACHE_SCHEMA and c.get('slot_rot') == B.SLOT_ROT and c.get('usb_slot_ref') == {str(k): v for k, v in B.USB_SLOT_REF.items()}) if '--replan' not in sys.argv and cache_ok: segs, vias, logs = [tuple(s) for s in c['segs']], [tuple(v) for v in c['vias']], c['logs'] print(u'(用缓存 _base_routes.json)') else: if os.path.exists(CACHE) and not cache_ok: print(u'(忽略 rot=180 / 西侧 D± 旧缓存,按当前拓扑重算)') best, prio = None, set() # 用上一轮的失败网络给新一轮排序,避免为了得到第二轮 # 优先级而重算一遍完全相同的首轮。 if '--priority-cache' in sys.argv and os.path.exists(CACHE): old = json.load(io.open(CACHE, encoding='utf-8')) prio = set(RT._msg_net(m) for m in old.get('logs', [])) print(u'(用上轮 %d 条失败记录优先排网)' % len(old.get('logs', []))) # `--passes N`:改动布线模型时先跑一轮拿信号,别一上来就等四轮(一轮 ~20 min) if '--passes' in sys.argv: RT.PASSES = int(sys.argv[sys.argv.index('--passes') + 1]) for it in range(RT.PASSES): segs, vias, logs = plan_all(prio) print(u' 第 %d 轮:%d 条没搞定' % (it + 1, len(logs))) if best is None or len(logs) < len(best[2]): best = (segs, vias, logs) if not logs: break prio |= set(RT._msg_net(m) for m in logs) segs, vias, logs = best if '--dp-only' not in sys.argv: io.open(CACHE, 'w', encoding='utf-8').write(json.dumps({ 'schema': CACHE_SCHEMA, 'slot_rot': B.SLOT_ROT, 'usb_slot_ref': B.USB_SLOT_REF, 'segs': segs, 'vias': vias, 'logs': logs}, ensure_ascii=False)) report(segs, vias, logs) if '--push' in sys.argv: if logs: sys.exit(u'!! 仍有 %d 条未布通,拒绝下发不完整路由' % len(logs)) RT.push(segs, vias)