文件
phonerail/.claude/pcb2_route_dp.py
T
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

695 行
32 KiB
Python
原始文件 Blame 文件历史

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