# -*- coding: utf-8 -*- u"""载板 11 对差分的**手工布线**(事实来源:本文件的 `PATHS` / `SITE_J`)。 /c/Python314/python .claude/pcb2_route_dp.py --dump # 抓一次障碍表(改布局后必跑) /c/Python314/python .claude/pcb2_route_dp.py # 只算 + 本地校核,不下发 /c/Python314/python .claude/pcb2_route_dp.py --push # 下发(先删本脚本自己上一轮画的线) /c/Python314/python .claude/pcb2_route_dp.py --corridors # 扫底层还剩哪些横向走廊(定高速路用) ## 为什么不用自动布线器 2026-09-06 按下板那一轮的做法配好并跑过(`pcb2_route_diff.py` 留着,配置/断言/回滚都在里面): **22 个网络里只画出 J23 自己那两条 A6↔B6 / A7↔B7 的短接线,全板 0 个过孔**, 30 秒内就"跑完"了,console 只有一句 `ProgressManager: can not remove progressGroup`。 换句话说它根本没在找路。差分对本来也是最该手工控制的东西(耦合、等长、参考面), 所以改成这里逐段算坐标 + 本地校核。 ## 坐标怎么写 五个手机位的服务带完全同构,所以 `SITE_J`(U5n↔J7n 那 5 对短差分) 用**站内坐标 (u, v)** 写一遍就够:`v < 0` = 接口边外侧, `pcb2_place.to_board()` 会把它映射到五个位置(4/5 站自动镜像)。 长差分对(U32↔U5n、J23↔U32)没有对称性,直接写绝对坐标。 路径格式:`[(x, y, layer, width), ...]`,相邻两点**层相同**就画一段导线, **层不同**就在该点打一个过孔(Ø0.61/0.305)。宽度是"从这一点到下一点"的线宽。 ## 三条几何约束(都是实测卡出来的) 1. **BM24 出线只能直着往外走**:前排(v=-4.06)往外、后排往里, 同排相邻焊盘净距 0.17 mm,横向穿不过去(§25.8)。 2. **出线头 0.35 mm 那一段必须收窄到 0.20 mm**:0.35 间距 − 两边焊盘各 0.09 ⇒ 0.20 的线两侧各剩 0.16 mm。`pcb2_fanout.py` 给 J7n 的 GND 脚出线也是 0.20, 同一套做法。离开焊盘阵列(|v| > 4.6)之后再放宽到 0.28。 3. **`*J` 这一对在 J7n 侧是交叉的**(DMJ 在 J7n 上偏西、在 U5n 上偏东),一层布不过去 —— v5(全部底层)改成 **DPJ 用两个过孔翻到顶层**从 DMJ 上方跳过去,DMJ 全程底层 45° 斜进焊盘。 顶层那一小段底下 Inner1 必须是一整块铜(site4 为此把 VO4 岛东界收到 87,见 SITE_J 注释)。 """ import json import math import os import sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run from pcb2_clear import half, deg from pcb2_place import SITES, to_board PCB = '942ed1560d81c1e7' MIL = 39.3700787401575 GEOM = os.path.join(HERE, '_pcb2_route_geom.json') L_TOP, L_BOT = 1, 2 W = 0.28 # 差分默认线宽(规则 differentialPair) W_ESC = 0.20 # 刚出焊盘阵列、两根线还只隔 0.35 mm 时的宽度(0.35−0.10−0.10 = 0.15 = 对内间距) W_ARR = 0.11 # **还在焊盘阵列里**那一小段(长 0.59 mm)。 # BM24 焊盘 0.1803 宽、间距 0.35 ⇒ 相邻焊盘边到边 0.2597 mm。 # 要留住 0.20 的一般安全间距,线宽上限 = 0.35 − 0.1803 − 2×0.20 … # 直接算:0.35 − 0.09015(焊盘半宽) − 0.20 = 0.05985 半宽 ⇒ **0.1197 mm**。 # 取 0.11(净距 0.205)。0.12 会差 0.00015 mm 卡在judge线上 —— 实测过。 # ⚠️ 这比差分规则的 strokeWidthMin 还窄,所以 pcb2_rules 把它降到了 0.10。 CLR = 0.20 # 一般安全间距 CLR_PAIR = 0.15 # 对内间距(规则 USBPAIR) VIA_D, VIA_H = 0.61, 0.305 PAIR_OF = {} for _n in range(1, 6): PAIR_OF['USB%d_DP' % _n] = 'USB%d_DM' % _n PAIR_OF['USB%d_DM' % _n] = 'USB%d_DP' % _n PAIR_OF['USB%d_DPJ' % _n] = 'USB%d_DMJ' % _n PAIR_OF['USB%d_DMJ' % _n] = 'USB%d_DPJ' % _n PAIR_OF['UP_DP'] = 'UP_DM' PAIR_OF['UP_DM'] = 'UP_DP' KSEP = chr(0) def netof(key): u"""路径的 key -> 网络名。`NET#2` 这样的后缀表示**同一个网络的第二条支路** (J23 的 A6↔B6 / A7↔B7 短接线就是这么加的),下发时按 `NET` 建线。""" # ⚠️ 分隔符不能用 '#':载板上有 `KEY_OE#`(2026-09-06 595 上载板之后),按 '#' 拆会把它拆成 # `KEY_OE`,校核时和自己的焊盘当成异网(下板 §26.19 踩过同一条)。用 chr(0)。 return key.split(KSEP)[0] # ── 站内坐标(u, v):J7n ↔ U5n 的短差分(v5:全部**底层**,接口边朝板边)──────── # 实测锚点(对五个站都成立,`--dump` 后 endpoints_ok 会逐站断言): # J7n.4 = *_DMJ (48.65, 36.06) J7n.6 = *_DPJ (49.00, 36.06) 北排(朝 U5n),焊盘顶 36.30 # U5n.3 = *_DPJ (47.85, 38.351) U5n.1 = *_DMJ (49.75, 38.351) 南排(朝 J7n),焊盘底 37.815 # U5n.4 = *_DP (47.85, 40.649) U5n.6 = *_DM (49.75, 40.649) 北排(朝走廊 / Hub) # 两排之间只有 1.515 mm,而 DMJ 在 J7n 上偏西、在 U5n 上偏东 —— 必须交叉一次。 # 过孔只能落在 y 36.805~37.31 这条带里(离两排焊盘各 0.505)或者 x ≤ 47.08 / x ≥ 50.52 的两侧; # 让 **DPJ 翻顶层**:出焊盘 0.54 后浅斜向东北到 (50.0, 37.0) 打孔(离 DMJ 的 45° 斜线 0.40), # 顶层西去 (47.2, 37.4) 下来,45° 进 U5n.3 的西南角。DMJ 全程底层:(48.65, 36.85) 起 45° 斜进 U5n.1, # 斜线离 U5n.2(GND) 焊盘角 0.25。 # ⚠ site4 的这段顶层线在板坐标 x 87.8~90.9 —— VO4 岛东界为此从 89 收到 87(pcb2_pour ISLANDS), # 否则顶层参考面在 x=89 跨岛。site5 的整段落在 VO5 岛内部(一整块铜,不跨界),site1~3 在 GND 上。 SITE_J = { 'DPJ': [(49.00, 36.06, L_BOT, W_ARR), # 出 J7n.6,焊盘阵列里 0.11 (49.00, 36.60, L_BOT, W_ESC), # 出了阵列,0.20(与 DMJ 还只隔 0.35) (49.50, 36.80, L_BOT, W), # 浅斜向东北(21.8°);前半段仍 0.20,离 DMJ 竖段 0.16、离 J7n.8 角 0.27 (50.00, 37.00, L_BOT, W), # 到孔位(离 DMJ 的 45° 斜线 0.40、离 U5n.1 焊盘 0.51) (50.00, 37.00, L_TOP, W), # ↑ 过孔,顶层西行 (47.20, 37.40, L_TOP, W), (47.20, 37.40, L_BOT, W), # ↓ 过孔(U5n.3 西南 0.26) (47.85, 38.05, L_BOT, W), # 45° 进焊盘 (47.85, 38.351, L_BOT, W)], 'DMJ': [(48.65, 36.06, L_BOT, W_ARR), # 出 J7n.4 (48.65, 36.65, L_BOT, W_ESC), (48.65, 36.85, L_BOT, W), (49.75, 37.95, L_BOT, W), # 45° 斜过 DPJ 的孔下方(孔在顶层) (49.75, 38.351, L_BOT, W)], # 进 U5n.1 } # ── 绝对坐标:长差分对 + Hub 西缘的两条 +3V3 车道 ───────────────────── # 坐标与拓扑说明在 `pcb2_dp_long.py`。+3V3 那三条的端点要查电容焊盘,所以 build() 才展开。 from pcb2_dp_long import build as _long_build # ══════════ 障碍表 ══════════ JS_DUMP = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1800)); const name = {}; for (const c of await eda.pcb_PrimitiveComponent.getAll()) name[c.getState_PrimitiveId()] = c.getState_Designator(); const ids = Object.keys(name).sort((a, b) => b.length - a.length); const pads = []; for (const p of await eda.pcb_PrimitivePad.getAll()) { const pid = p.getState_PrimitiveId(); let hit = ''; for (const id of ids) if (pid.length > id.length && pid.indexOf(id) === 0) { hit = name[id]; break; } pads.push([hit, p.getState_PadNumber(), p.getState_Net(), p.getState_Layer(), p.getState_X(), p.getState_Y(), p.getState_Rotation(), p.getState_Pad()]); } const vias = []; for (const o of await eda.pcb_PrimitiveVia.getAll()) vias.push([o.getState_Net(), o.getState_X(), o.getState_Y(), o.getState_Diameter()]); const lines = []; for (const o of await eda.pcb_PrimitiveLine.getAll()) { const L = o.getState_Layer(); if (L !== 1 && L !== 2) continue; // 只要铜层,丝印/装配层不算障碍 lines.push([o.getState_PrimitiveId(), o.getState_Net(), L, o.getState_StartX(), o.getState_StartY(), o.getState_EndX(), o.getState_EndY(), o.getState_LineWidth()]); } return {pads: pads, vias: vias, lines: lines}; """ % PCB def dump(): r = run(JS_DUMP, timeout=300) if not r.get('success'): sys.exit(u'抓障碍表失败: ' + json.dumps(r, ensure_ascii=False)[:400]) json.dump(r['result'], open(GEOM, 'w', encoding='utf-8'), ensure_ascii=False) d = r['result'] print(u'障碍表已存:焊盘 %d / 过孔 %d / 铜层导线 %d' % (len(d['pads']), len(d['vias']), len(d['lines']))) def geom(): d = json.load(open(GEOM, encoding='utf-8')) pads = [] for ref, num, net, layer, x, y, rot, shape in d['pads']: cx, cy, hw, hh, rr = half(shape, rot, x, y) pads.append({'ref': ref, 'num': num, 'net': net or '', 'lay': layer, 'x': cx, 'y': cy, 'hw': hw, 'hh': hh}) vias = [{'net': n or '', 'x': x / MIL, 'y': y / MIL, 'r': (dm / MIL) / 2.0} for n, x, y, dm in d['vias']] lines = [{'id': i, 'net': n or '', 'lay': l, 'x0': a / MIL, 'y0': b / MIL, 'x1': c / MIL, 'y1': e / MIL, 'w': w / MIL} for i, n, l, a, b, c, e, w in d['lines']] # 2026-09-07:封装自带的非金属化孔(J23 里两个 Ø0.60,`pcb2_npth.py --write` 产出)当成无网络的通孔盘, # 两个布线器就都会躲开(原来只有 pcb2_vo_vias 读它,`$16N224` 的过孔直接打在孔上,DRC「挖槽区域到过孔 0mil」)。 npth = os.path.join(HERE, '_pcb2_npth.json') if os.path.exists(npth): for cx, cy, r in json.load(open(npth, encoding='utf-8')): pads.append({'ref': 'NPTH', 'num': '', 'net': '', 'lay': 12, 'x': cx, 'y': cy, 'hw': r, 'hh': r}) return pads, vias, lines # ══════════ 几何 ══════════ def seg_pt(ax, ay, bx, by, px, py): u"""点到线段的距离。""" dx, dy = bx - ax, by - ay L2 = dx * dx + dy * dy t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L2)) return math.hypot(px - (ax + t * dx), py - (ay + t * dy)) def seg_seg(a, b, c, d): u"""两条线段的最短距离(含相交判定)。""" (ax, ay), (bx, by), (cx, cy), (dx_, dy_) = a, b, c, d d1x, d1y = bx - ax, by - ay d2x, d2y = dx_ - cx, dy_ - cy den = d1x * d2y - d1y * d2x if abs(den) > 1e-12: t = ((cx - ax) * d2y - (cy - ay) * d2x) / den s = ((cx - ax) * d1y - (cy - ay) * d1x) / den if 0 <= t <= 1 and 0 <= s <= 1: return 0.0 return min(seg_pt(ax, ay, bx, by, cx, cy), seg_pt(ax, ay, bx, by, dx_, dy_), seg_pt(cx, cy, dx_, dy_, ax, ay), seg_pt(cx, cy, dx_, dy_, bx, by)) def seg_rect(a, b, cx, cy, hw, hh): u"""线段到轴对齐矩形的距离(保守:矩形按外接矩形算)。""" x0, x1, y0, y1 = cx - hw, cx + hw, cy - hh, cy + hh corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] # 端点在矩形里 -> 0 for px, py in (a, b): if x0 <= px <= x1 and y0 <= py <= y1: return 0.0 return min(seg_seg(a, b, corners[i], corners[(i + 1) % 4]) for i in range(4)) def expand(path): u"""路径 -> (线段列表, 过孔列表)。 语义(写路径时按这个理解):**第 i 个点上的 layer / width 是「从这一点走到下一点」 那一段的层与线宽**;最后一个点的 layer/width 只是占位、不用。 层在第 i+1 个点变化 ⇒ 在**该点**打一个过孔。 """ segs, vias = [], [] n = len(path) for i in range(n - 1): x0, y0, l0, w0 = path[i] x1, y1, l1, _w1 = path[i + 1] if abs(x1 - x0) > 1e-9 or abs(y1 - y0) > 1e-9: segs.append((x0, y0, x1, y1, l0, w0)) if i + 1 < n - 1 and l1 != l0: vias.append((x1, y1)) return segs, vias def build(pads=None): u"""把 SITE_J(站内坐标)+ 长差分(绝对坐标)展开成 {net: path}。pads 给了才带 +3V3 三条。""" out = {} for n in SITES: for key, band in SITE_J.items(): net = 'USB%d_%s' % (n, key) p = [] for u_, v_, lay, w in band: bx, by, _ = to_board(n, u_, v_, 0) p.append((round(bx, 4), round(by, 4), lay, w)) out[net] = p out.update(_long_build(pads)) return out # ══════════ 走廊扫描(给长差分对定"高速路"用)══════════ def corridors(y, x0, x1, lay=L_BOT, w=W, gap=1.2): u"""某个 y 上,`lay` 层从 x0 到 x1 还剩哪些 >= gap 的空档(已扣掉净距 + 半线宽)。 长差分对的走线不该靠猜 —— 先扫一遍就知道哪条纬线是通的。 多层焊盘(layer 12,即通孔)在两个信号层上都算障碍。 """ pads, vias, lines = geom() CL = CLR + w / 2.0 iv = [] for p_ in pads: if p_['lay'] not in (lay, 12): continue if abs(p_['y'] - y) <= p_['hh'] + CL: iv.append((p_['x'] - p_['hw'] - CL, p_['x'] + p_['hw'] + CL)) for v in vias: if abs(v['y'] - y) <= v['r'] + CL: d = math.sqrt((v['r'] + CL) ** 2 - (v['y'] - y) ** 2) iv.append((v['x'] - d, v['x'] + d)) for l in lines: if l['lay'] != lay: continue lo, hi = sorted((l['y0'], l['y1'])) if lo - l['w'] / 2 - CL <= y <= hi + l['w'] / 2 + CL: iv.append((min(l['x0'], l['x1']) - CL, max(l['x0'], l['x1']) + CL)) iv = sorted(e for e in iv if e[1] > x0 and e[0] < x1) merged = [] for a, b in iv: if merged and a <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], b) else: merged.append([a, b]) free, cur = [], x0 for a, b in merged: if a - cur > gap: free.append((round(cur, 1), round(a, 1))) cur = max(cur, b) if x1 - cur > gap: free.append((round(cur, 1), round(x1, 1))) return free # ⚠️ 下面这张表是 v3 布局扫的,v5 已经作废(走廊里没有槽了、Hub 在 (100, 68));要用先 `--corridors` 重扫。 # 2026-09-06 v3 扫出来的结论(`--corridors` 可复现),长差分对照着这几条走: # # | 用途 | 纬线 | 底层通的区间 | # |---|---|---| # | 北高速(西行)| **y = 75.0 / 76.0** | x 40~105 **整段通** | # | 北高速(东行)| y = 75.0 | 100~112.0、115.1~215(112~115 被 Hub 北侧卫星挡)| # | 北高速(东行,绕开 Hub)| **y = 84.0** | 100~129.1、130.2~200.6、201.7~215(只剩两个 1.1 mm 的豁口)| # | 南高速(东行)| **y = 56.0** | x 100~180 **整段通** | # | 南高速(西行)| **y = 60.5 / 61.0** | x 45~104 **整段通** | # | 竖向(Hub 西侧)| **x = 100.0 / 101.0** | y 55~78 **整段通**(x=102 在 y<60.2 有 C740/过孔)| # # ⚠️ **奇偶性**:一根线在"西行 → 北行 → 西行"这样右转再左转之后, # 左右关系**回到原样**。而五路的 U32 引脚次序与目的地次序对不上 # (南边三对 1/2/3 去北边、北边两对 4/5 去南边), # 所以**每一对都至少需要一次换层交叉**,和 `*J` 那五对一样。 # 交叉要挑 Inner1 是 GND 的窗口做:**x 100~139 / y 50~67.5 是一大片 GND** # (VO4 岛止于 x=100、VO2 岛止于 y=67.6、VO5 岛从 x=139.5 起), # Hub 西侧的换层就放这里。 # ══════════ 校核 ══════════ def check(routes, pads, vias, lines, verbose=True): bad = [] padix, viaix = {}, {} for p in pads: for L in ((L_TOP, L_BOT) if p['lay'] == 12 else (p['lay'],)): padix.setdefault(L, []).append(p) # 本轮自己画的东西也要互相检查 mine = [] for key, path in routes.items(): net = netof(key) s, v = expand(path) for x0, y0, x1, y1, lay, w in s: mine.append({'net': net, 'lay': lay, 'a': (x0, y0), 'b': (x1, y1), 'w': w}) for x, y in v: viaix.setdefault(net, []).append((x, y)) # 同一网络的多条支路之间不用查净距(它们本来就该连在一起) def need(n1, n2): return CLR_PAIR if PAIR_OF.get(n1) == n2 else CLR for m in mine: h = m['w'] / 2.0 for p in padix.get(m['lay'], []): if p['net'] == m['net']: continue d = seg_rect(m['a'], m['b'], p['x'], p['y'], p['hw'], p['hh']) - h if d < need(m['net'], p['net']) - 1e-4: bad.append((u'线-焊盘', m['net'], u'%s.%s(%s)' % (p['ref'], p['num'], p['net'] or u'空'), m['lay'], d, m['a'], m['b'])) for v in vias: if v['net'] == m['net']: continue d = seg_pt(m['a'][0], m['a'][1], m['b'][0], m['b'][1], v['x'], v['y']) - v['r'] - h if d < need(m['net'], v['net']) - 1e-4: bad.append((u'线-过孔', m['net'], v['net'] or u'空', m['lay'], d, m['a'], m['b'])) for l in lines: if l['lay'] != m['lay'] or l['net'] == m['net']: continue d = seg_seg(m['a'], m['b'], (l['x0'], l['y0']), (l['x1'], l['y1'])) - h - l['w'] / 2.0 if d < need(m['net'], l['net']) - 1e-4: bad.append((u'线-旧线', m['net'], l['net'] or u'空', m['lay'], d, m['a'], m['b'])) for o in mine: if o is m or o['lay'] != m['lay'] or o['net'] == m['net']: continue d = seg_seg(m['a'], m['b'], o['a'], o['b']) - h - o['w'] / 2.0 if d < need(m['net'], o['net']) - 1e-4: bad.append((u'线-新线', m['net'], o['net'], m['lay'], d, m['a'], m['b'])) # 过孔 vs 一切 for net, vs in viaix.items(): for (x, y) in vs: R = VIA_D / 2.0 for L in (L_TOP, L_BOT): for p in padix.get(L, []): if p['net'] == net: continue d = seg_rect((x, y), (x, y), p['x'], p['y'], p['hw'], p['hh']) - R if d < need(net, p['net']) - 1e-4: bad.append((u'过孔-焊盘', net, u'%s.%s(%s)' % (p['ref'], p['num'], p['net'] or u'空'), L, d, (x, y), (x, y))) for v in vias: if v['net'] == net: continue d = math.hypot(x - v['x'], y - v['y']) - v['r'] - R if d < need(net, v['net']) - 1e-4: bad.append((u'过孔-过孔', net, v['net'] or u'空', 0, d, (x, y), (x, y))) for l in lines: if l['net'] == net: continue d = seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], x, y) - R - l['w'] / 2.0 if d < need(net, l['net']) - 1e-4: bad.append((u'过孔-旧线', net, l['net'] or u'空', l['lay'], d, (x, y), (x, y))) for m in mine: if m['net'] == net: continue d = seg_pt(m['a'][0], m['a'][1], m['b'][0], m['b'][1], x, y) - R - m['w'] / 2.0 if d < need(net, m['net']) - 1e-4: bad.append((u'过孔-新线', net, m['net'], m['lay'], d, (x, y), (x, y))) if verbose: seen = set() for t, n1, n2, lay, d, a, b in sorted(bad, key=lambda e: e[4]): k = (t, n1, n2) if k in seen: continue seen.add(k) print(u' [!!] %-9s %-11s vs %-22s L%-2s 净距 %+.3f 段(%.2f,%.2f)-(%.2f,%.2f)' % (t, n1, n2, lay, d, a[0], a[1], b[0], b[1])) if len(bad) > len(seen): print(u' (同类重复 %d 条已折叠)' % (len(bad) - len(seen))) return bad # ══════════ 45° 拐角(2026-09-06 用户拍板:全板不许有直角走线)══════════ SHARP = 46.0 # 转向角超过这个度数就算"直角",要切 CHAMFER_R = (0.50, 0.40, 0.30, 0.25, 0.20, 0.15, 0.10, 0.06) def turn_deg(a, b, c): u"""在 b 点的**转向角**(0 = 直着走,90 = 直角,45 = 标准的斜切拐)。""" 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) if la < 1e-9 or lc < 1e-9: return 0.0 dot = max(-1.0, min(1.0, (ax * cx + ay * cy) / (la * lc))) return math.degrees(math.acos(dot)) def sharp_corners(routes, limit=SHARP): u"""列出所有"直角"拐点:[(key, 序号, (x, y), 转向角), ...]。 换层点(拐点两侧不同层)不算 —— 那是过孔,不是拐角。 """ out = [] for key, path in sorted(routes.items()): for i in range(1, len(path) - 1): a, b, c = path[i - 1], path[i], path[i + 1] if a[2] != b[2] or b[2] != c[2]: continue t = turn_deg(a, b, c) if t > limit: out.append((key, i, (b[0], b[1]), t)) return out def seg_slack(x0, y0, x1, y1, lay, w, net, pads, vias, lines, mine=(), mvias=()): u"""一段线的**净距余量**:`min(实际净距 − 该对网络要求的净距)`,>= 0 就是合规。 ⚠️ 必须按 `need()` 算余量,不能只算净距再拿 `CLR` 比 —— 差分对内是 **0.15**,`*_DPJ` 和 `*_DMJ` 就是贴着这个数并排走的。 只按 0.20 判的话,这五对的拐角一律"切不动"(实测就是这么卡住的)。 `mine` 是本轮新画的其它段:`[(net, lay, x0, y0, x1, y1, w), ...]`。 """ h = w / 2.0 a, b = (x0, y0), (x1, y1) best = 1e9 for p in pads: if p['net'] == net or (p['lay'] != 12 and p['lay'] != lay): continue best = min(best, seg_rect(a, b, p['x'], p['y'], p['hw'], p['hh']) - h - (CLR_PAIR if PAIR_OF.get(net) == p['net'] else CLR)) for v in vias: if v['net'] == net: continue best = min(best, seg_pt(x0, y0, x1, y1, v['x'], v['y']) - v['r'] - h - (CLR_PAIR if PAIR_OF.get(net) == v['net'] else CLR)) for l in lines: if l['net'] == net or l['lay'] != lay: continue best = min(best, seg_seg(a, b, (l['x0'], l['y0']), (l['x1'], l['y1'])) - l['w'] / 2.0 - h - (CLR_PAIR if PAIR_OF.get(net) == l['net'] else CLR)) for n2, l2, ax, ay, bx, by, w2 in mine: if n2 == net or l2 != lay: continue best = min(best, seg_seg(a, b, (ax, ay), (bx, by)) - w2 / 2.0 - h - (CLR_PAIR if PAIR_OF.get(net) == n2 else CLR)) # ⚠️ 本轮新打的过孔也要算 —— 只查线不查孔的话, # 抹平微台阶时会把线挪进别人的过孔里(实测 USB5_DM 撞 USB3_DP 的孔,差 0.05)。 for n2, vx, vy in mvias: if n2 == net: continue best = min(best, seg_pt(x0, y0, x1, y1, vx, vy) - VIA_D / 2.0 - h - (CLR_PAIR if PAIR_OF.get(net) == n2 else CLR)) return best def chamfer45(path, ok, limit=SHARP): u"""把折线里所有"直角"拐点切成 45°,半径由 `ok()` 逐个试出来。 ⚠️ 两件事必须清楚: 1. **只切转向角 > 45° 的拐点。** 已经是 45° 的拐(正 + 斜)本来就是标准形态, 再切一刀会变成两个 22.5°,反而更糟。 2. **切完只需要校核斜切段本身**:被缩短的那两段是原来那两段的子集, 净距只增不减。所以 `ok()` 只喂斜切段。 半径取 `min(r, 前段/2, 后段/2)`,相邻拐点各占一半,不会重叠。 """ if len(path) < 3: return list(path) out = [path[0]] for i in range(1, len(path) - 1): a, b, c = path[i - 1], path[i], path[i + 1] if a[2] != b[2] or b[2] != c[2] or turn_deg(a, b, c) <= limit: out.append(b) 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) done = False for r in CHAMFER_R: d = min(r, la / 2.0, lc / 2.0) if d < 0.05: break b1 = (round(b[0] - ax / la * d, 4), round(b[1] - ay / la * d, 4)) b2 = (round(b[0] + cx / lc * d, 4), round(b[1] + cy / lc * d, 4)) if ok(b1[0], b1[1], b2[0], b2[1], a[2], a[3]): out.append((b1[0], b1[1], a[2], a[3])) out.append((b2[0], b2[1], b[2], b[3])) done = True break if not done: out.append(b) # 切不动:这一处的通道宽度物理上不允许 out.append(path[-1]) return out def smooth_micro(path, ok, tol=0.15): u"""抹掉**微台阶**:偏离前后两点连线不到 `tol` 的中间点,直接删掉。 栅格布线器在很挤的地方简化不动时会退回逐格走,产出一串 0.05 mm 的小台阶 (实测 `+3V3_HUB` 穿 Hub 南侧 A 圈那一段就是 8 个 0.05 的台阶)。 这种点**不是拐角**,切 45° 也没意义(半径被 `min(r, 段长/2)` 压到 0.025), 只能整段拉直。判据取"偏离量 < 0.15 mm": 真正的 45° 拐点偏离前后连线很远,不会被误删。 """ changed = True pts = list(path) while changed and len(pts) > 2: changed = False i = 1 while i < len(pts) - 1: a, b, c = pts[i - 1], pts[i], pts[i + 1] # ⚠️ 换层点不动;**线宽换挡点更不能动** —— # BM24 出线靠 0.11 / 0.20 / 0.28 三档才合法, # 把换挡点当成"共线冗余点"删掉,整条就按最宽的那一档画,当场违规。 if a[2] != b[2] or b[2] != c[2] or abs(a[3] - b[3]) > 1e-9: i += 1 continue if seg_pt(a[0], a[1], c[0], c[1], b[0], b[1]) <= tol and ok(a[0], a[1], c[0], c[1], a[2], a[3]): pts.pop(i) changed = True else: i += 1 return pts def chamfer_routes(routes, pads, vias, lines, smooth=False): u"""对一整组路径做 45° 化。校核用的是**未切之前**的其它段(保守:切只会拿掉铜)。 `smooth=True` 会先抹掉微台阶 —— **只给布线器的产出用**。 差分对是手写的几何(耦合、等长、换层窗口都是算过的),抹平会让一对里的两根 各自被改动、并排关系走样,所以那边只切拐角、不抹台阶。 """ 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)) out = {} 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) out[key] = chamfer45(smooth_micro(path, ok) if smooth else path, ok) return out def endpoints_ok(routes, pads): u"""路径两端必须真的落在本网络的焊盘上。""" bad = [] for key, path in routes.items(): net = netof(key) mine = [p for p in pads if p['net'] == net] for end in (path[0], path[-1]): hit = [p for p in mine if abs(end[0] - p['x']) <= p['hw'] + 1e-3 and abs(end[1] - p['y']) <= p['hh'] + 1e-3] if not hit: bad.append((net, end)) return bad def lengths(routes): out = {} for net, path in routes.items(): L = sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(path, path[1:])) out[net] = L return out # ══════════ 下发 ══════════ JS_PUSH = u""" const NETS = %s, SEGS = %s, VIAS = %s, MIL = %f; // 先删掉这些网络上已有的线与过孔(本脚本自己上一轮的产物),再重画 let dl = 0, dv = 0; const S = new Set(NETS); for (const o of await eda.pcb_PrimitiveLine.getAll()) { const L = o.getState_Layer(); if ((L === 1 || L === 2) && S.has(o.getState_Net())) { await eda.pcb_PrimitiveLine.delete(o.getState_PrimitiveId()); dl++; } } for (const o of await eda.pcb_PrimitiveVia.getAll()) if (S.has(o.getState_Net())) { await eda.pcb_PrimitiveVia.delete(o.getState_PrimitiveId()); dv++; } let nl = 0, nv = 0; for (const [net, lay, x0, y0, x1, y1, w] of SEGS) { await eda.pcb_PrimitiveLine.create(net, lay, x0 * MIL, y0 * MIL, x1 * MIL, y1 * MIL, w * MIL); nl++; } for (const [net, x, y] of VIAS) { await eda.pcb_PrimitiveVia.create(net, x * MIL, y * MIL, %f * MIL, %f * MIL); nv++; } await eda.pcb_Document.save(); await new Promise(r => setTimeout(r, 2000)); return {del_lines: dl, del_vias: dv, lines: nl, vias: nv}; """ def push(routes): segs, vias = [], [] for key, path in routes.items(): net = netof(key) s, v = expand(path) for x0, y0, x1, y1, lay, w in s: segs.append([net, lay, x0, y0, x1, y1, w]) for x, y in v: vias.append([net, x, y]) js = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,1800));" % PCB) + ( JS_PUSH % (json.dumps(sorted(set(netof(k) for k in routes))), json.dumps(segs), json.dumps(vias), MIL, VIA_H, VIA_D)) r = run(js, timeout=900) if not r.get('success'): sys.exit(u'下发失败: ' + json.dumps(r, ensure_ascii=False)[:500]) d = r['result'] print(u'下发完成:先删 %d 线 / %d 孔,再画 %d 线 / %d 孔' % (d['del_lines'], d['del_vias'], d['lines'], d['vias'])) def main(): if '--dump' in sys.argv: dump() return if '--corridors' in sys.argv: print(u'底层走廊扫描(净距 %.2f + 半线宽 %.2f):' % (CLR, W / 2)) for y in (56.0, 60.5, 61.0, 75.0, 76.0, 84.0): print(u' y=%5.1f %s' % (y, corridors(y, 40, 215))) return pads, vias, lines = geom() # 下发时这些网络整网重画,所以算的时候当它们不存在(否则会跟自己上一轮的线打架) ours = set(netof(k) for k in build(pads)) vias = [v for v in vias if v['net'] not in ours] lines = [l for l in lines if l['net'] not in ours] routes = build(pads) n0 = len(sharp_corners(routes)) routes = chamfer_routes(routes, pads, vias, lines) n1 = len(sharp_corners(routes)) print(u'45° 化:直角拐点 %d -> %d' % (n0, n1)) for key, i, xy, t in sharp_corners(routes): print(u' [!!] 切不动的直角: %-12s (%.2f, %.2f) 转向 %.0f°' % (key, xy[0], xy[1], t)) segs = sum(len(expand(p)[0]) for p in routes.values()) nv = sum(len(expand(p)[1]) for p in routes.values()) print(u'%d 个网络 / %d 段导线 / %d 个过孔' % (len(routes), segs, nv)) ep = endpoints_ok(routes, pads) if ep: print(u' [!!] 端点没落在本网焊盘上:') for net, e in ep: print(u' %-12s (%.3f, %.3f)' % (net, e[0], e[1])) print(u'') print(u'净距校核(一般 %.2f / 对内 %.2f mm):' % (CLR, CLR_PAIR)) bad = check(routes, pads, vias, lines) if not bad: print(u' 全部通过 ✔') L = lengths(routes) print(u'') print(u'长度 / 对内偏斜:') for a, b in sorted(set(tuple(sorted((k, PAIR_OF[k]))) for k in routes if k in PAIR_OF)): if a in L and b in L: print(u' %-12s %6.2f %-12s %6.2f 偏斜 %5.2f mm %s' % (a, L[a], b, L[b], abs(L[a] - L[b]), u'✔' if abs(L[a] - L[b]) <= 5.0 else u'✘ 超过 5 mm 容差')) if '--push' in sys.argv: if bad or ep: sys.exit(u'\n!! 有未解决的问题,不下发。') print(u'') push(routes) if __name__ == '__main__': main()