文件
zhou yang和Claude Opus 5 312089326e R2 底板:按用户布局重布风扇窗口 + L1 走廊重开 + 位号丝印重排
布线(窗口整体重布,已下发):
- 拆窗口内 43 段(用户手布的那批,用户选「拆掉重来」),补 139 段 / 7 孔,
  GND 回流孔 10 个;fan_check 对板上真实快照逐项净距达标(含「新孔 vs 内层线」)。
- ⛔ 根因:pcb_base.PD_L1 里的 L1 走廊是按**旧摆位**开的(注释坐标全是旧的),
  用户重摆后与件对不上 ⇒ 四条长途网必然布不通。重开六条走廊后未完成 4 → 2。
  走廊必须盖住焊盘正上方,只盖沿途不行。断面:几何 11.80 / 铺铜后实测 9.82(要 8.0)。
- 与拆线前逐网对比:没引入任何新的连通性问题(GND 未接 58→48、+3V3 2→0、
  两路 TACH 2/1→0、+12V 一字不差)。

定版:板上铜以**用户自己布好的那版**为准,不再改动。

丝印(silk_base.py,已下发并读回核对 150/150):
- 压焊盘 128→4、压过孔 48→8、越框 8→3、互相重叠 34 对→0;字号不变。
- 三条坑:rotation 是度且不归一化(900=180°);别拿「本体≥5mm」当保留白名单
  (会豁免掉 R9A/D3/U13 这些该重排的件);「离本体」必须设硬上限 + 降级策略。

新工具:silk_base.py(丝印重排)、l1_section.py(L1 12V 主块逐 y 断面核算)、
fan_rip.py(按窗口拆线,拆前后比对全板线/孔总数防连带删除)。

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

304 行
17 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""**窗口内整体重布**:把一块矩形区域里的几个网连同周边 `+3V3` / `GND` 局部一起重布。
bash .claude/r2/remote_repair_window.sh # 入口(本机)
DCDC_ROUTE_SERVER=1 pypy3 .claude/r2/repair_window.py
## 和 `repair_base.py` 的区别(为什么要单独一份)
`repair_base` 是**按网**补:给它 `+3V3`,它就在**全板**范围重布这个网 —— 2026-09-12 实测,
它把新的 `+3V3` 线从 **J34~J37 插槽针排的焊盘中间穿过去**(`fan_check` 十几处 −0.2,
还压 U13.7 −0.449)。插槽 1 mm 间距、手焊,那是致命的。
这一份改成**按窗口**补,三条约束缺一不可:
1. **`bypad` 只装窗口内的脚**。窗口外的同网脚一概不参与 ⇒ 布线器不会去动全板的主干。
2. **窗口外的已有铜照常留在占用图里**。它们是「本网已有的铜」,A* 从窗口内的脚出发,
碰到就算到达 —— 这就是和外面主干的接口,不需要显式指定接口点。
3. **窗口外加高过路费**(`OUT_PEN`)。不是封死:万一窗口内绕不出去,它还能出去一点点,
但代价高到不会乱跑。封死会让本来能绕的网直接报「没布通」。
拆线(窗口内目标网的旧铜)由本机 `remote_repair_window.sh` 先做掉,这里只读拆完的板面快照。
输入:`_base_fan_input.json`(板上现有铜)+ `_base_window.json`(窗口与网名,本机写)。
输出:`_base_window_repair.json`(只有新增的段与孔)。
"""
import io
import json
import math
import os
import sys
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)
if not os.environ.get('DCDC_ROUTE_SERVER'):
sys.exit(u'⛔ 布线(A*)只在 10.5.5.13 上跑(CLAUDE.md):bash .claude/r2/remote_repair_window.sh')
import router as RT # noqa: E402
import route_base as RB # noqa: E402
CFG = json.load(io.open(os.path.join(HERE, '_base_window.json'), encoding='utf-8'))
WIN = tuple(CFG['window']) # (x0, y0, x1, y1)
NETS = [n.replace('@', '$') for n in CFG['nets']]
OUT_PEN = int(CFG.get('out_pen', 40))
# 朝插槽那一侧的过路费:必须高到 A* 不会为了抄近路钻进槽区(槽体 x 28~70,手焊 1 mm 针排)
SLOT_PEN = int(CFG.get('slot_pen', 200))
# 虚拟落点沿主干往窗内退多少(避开压在主干线上导致起步格点为 0)
RETREAT = float(CFG.get('retreat', 0.75))
# 窗口内「别的网」的焊盘外扩多少封死(收费挡不住,见 _pen 的注释)
PAD_KEEP = float(CFG.get('pad_keep', 0.10))
cp = json.load(io.open(os.path.join(HERE, '_base_fan_input.json'), encoding='utf-8'))
# ── L1 走廊(`pcb_base.PD_L1` 的运行时追加)─────────────────────────────────
# ⛔ A6 那一带 L1 整片是 `+12V` 主块(x 0.5~27.8)⇒ 过孔上得去但走不动,等于单层,
# 而 L4 已被 22 件占满。`PD_L1` 里那几条走廊是**按旧摆位**开的(注释里写的坐标就是旧的),
# 用户重摆之后与件对不上 ⇒ 长途网(FAN_EN / 两路 TACH / FAN_PWM_L)必然布不通。
# 这里让走廊跟着 `_base_window.json` 走,免得每试一组就改 `pcb_base.py`。
# ⚠ 判据(§6.4):逐 y 的水平断面上,Σ 走廊 x 跨度 ≤ 27.30 − 8.00 = 19.30 mm。
# 本机 `l1_section.py` 核;走廊 x 一律 ≤ 27.5,槽指头在 x ≥ 27.5,天然不碰。
_extra = CFG.get('pd_l1_extra')
if _extra is not None:
_keep = [(n, r) for n, r in RB.B.PD_L1 if not n.startswith('FAN')]
RB.B.PD_L1 = _keep + [(n, tuple(r)) for n, r in _extra]
print(u'L1 走廊:保留 %d 条非风扇 + 本轮 %d 条 %s'
% (len(_keep), len(_extra), ' '.join(sorted(set(n for n, _ in _extra)))))
pads = RB.load_pads()
def in_win(x, y, m=0.0):
return WIN[0] - m <= x <= WIN[2] + m and WIN[1] - m <= y <= WIN[3] + m
# ── 窗口外加过路费:包住整块板,挖掉窗口 ──────────────────────────────────
_orig_pen = RT.pen_pads
def _toll_pads():
u"""窗口内**不属待布网**的焊盘 —— 必须一起收过路费,否则新线会贴着它们走。
2026-09-12 实测:不收的话,`+3V3` 贴 `Q8.3` 只剩 **0.003**、`FAN_OUT_TACH_RAW` 贴
`Q10.2` 只剩 **0.027**(判据 0.15),`fan_check` 一跑就拦下来、整版不能下发。
`route_base._pen` 本来就是靠 `GND_PEN_PADS` / `LATER_PEN_PADS` 收这笔费的,
我覆盖 `RT.pen_pads` 时漏了这一步。
⛔ 判据是「**不是当前正在布的那个网**」,不是「不在待布网集合里」。第一版写成后者,
于是 `Q8.3`(网 `$6N48`,本身也在待布名单里)被漏掉 —— 实测它周围过路费始终是 0,
而 `+3V3` 的新线就贴着它走到 **0.003**。同属待布名单的别的网,对当前这个网来说一样是障碍。
"""
out = []
for p in pads:
if p[6] not in RT.LAY or str(p[1]).endswith(RB.TH_SUFFIX):
continue
if in_win(p[2], p[3]):
out.append(p)
return out
def _pen(g, ps, halfw):
u"""窗口外收过路费。⛔ **不能各向同性**(2026-09-12 实测)。
朝插槽那一侧(x > 窗口东界)必须近乎封死:`OUT_PEN` 从 40 降到 12 的那一轮,
`$6N64` / `FAN_EN` 立刻跑到 x 28.75~32.50 —— **正是槽体 x 28~70 的插槽区**,
183 段里 50 段跑出窗口(之前每轮都是 0)。而降它本是为了让跨窗口的网够得着 U20 那边的主干。
⇒ 分开定价:东侧(插槽方向)用 `SLOT_PEN`,其余三面用 `OUT_PEN`。
"""
_orig_pen(g, list(ps) + _toll_pads(), halfw)
# ⛔ 光收过路费**挡不住**(2026-09-12 第二次印证,同「插槽方向」那一条):
# 实测 `Q8.3` 周围过路费确实是 10,A* 照样贴着它走到 **0.003**;`Q10.2` 同理 0.027。
# `penalize` 只是定价,`block_rect` 才是墙 ⇒ 窗口内**别的网**的焊盘按 `PAD_KEEP` 外扩封死。
# 代价实测很小:外扩 0.10 时窗口内可走格点 7297 → 6802(72% → 68%,少 495 个)。
# ⛔ 外扩量必须按**当前这一档的线宽**算,不能写死。2026-09-12 实测:按 0.15 细线定的
# `PAD_KEEP=0.10` 挡不住 0.40 的 `+3V3`(它半宽就有 0.20)—— 照样贴 `Q8.3` 到 **0.003**。
# `route_groups` 是按线宽分档建图的,每档传进来的 `halfw` 正是「半宽 + 净距」,直接用它。
# (一刀切取最粗的 0.56 会把细线通道也削掉 1/3:窗口内可走格点 68% → 52%。)
r = max(PAD_KEEP, halfw)
for p in _toll_pads():
g.block_rect(p[6], p[2] - p[4] / 2 - r, p[3] - p[5] / 2 - r,
p[2] + p[4] / 2 + r, p[3] + p[5] / 2 + r, net=p[7])
for l in RT.LAY:
for r in RB.B.rect_minus((0.0, 0.0, RB.B.BW, RB.B.BH), [WIN]):
if r[0] >= WIN[2] - 1e-6:
continue # 东侧(插槽方向)下面单独封死,不是加价
g.penalize(l, r[0], r[1], r[2], r[3], OUT_PEN)
# ⛔ 插槽方向必须**封死**,不能只加价:本机实测过路费确实加到了 200
# (窗口内 0 / 插槽区 200),A* 照样钻 —— `$6N64` 宁可付 200×若干格也要从
# x 25.75 抄到 27.75,因为窗口内绕不过去。`penalize` 只是定价,`block_rect` 才是墙。
# 槽体 x 28~70 是 1 mm 间距的手焊针排,走线进去是致命的。
g.block_rect(l, WIN[2], 0.0, RB.B.BW, RB.B.BH, net='#slotwall')
RT.pen_pads = _pen
segs = [tuple(s) for s in cp['segs']]
vias = [tuple(v) for v in cp['vias']]
board_vias = [tuple(v) for v in cp['board_vias']]
# ⛔ 只装**窗口内**的脚;窗口外的同网铜留在 segs 里当「已有铜」。
bypad = {}
outside = {}
for p in pads:
if not p[7] or p[7] not in NETS or str(p[1]).endswith(RB.TH_SUFFIX):
continue
if in_win(p[2], p[3]):
bypad.setdefault(p[7], []).append(p)
else:
outside[p[7]] = outside.get(p[7], 0) + 1
# ⛔⛔ 2026-09-12 实测(第一版**漏了这一步,是设计缺陷**):
# 「窗口外的同网铜留在占用图里,A* 碰到就算到达」——**不成立**。
# `route_groups` 布的是 `bypad[net]` 里的脚**两两相连**,窗外主干根本不是它的终点判据。
# 于是「窗外还有脚」的网全军覆没:`FAN_PWM` / `FAN_EN` 窗内只剩 1 只脚 ⇒ 跨度 0、无事可做;
# `FAN_TACH_IN` / `FAN_TACH_OUT` / `+3V3` 窗内那两只脚彼此并不需要连(各自要去的是窗外主干)
# ⇒ 实测这 5 个网**一段都没产出**,而窗外脚数为 0 的 8 个网全部正常产出。
# ⇒ 把主干在**窗口边界处的端点**做成虚拟焊盘塞进 bypad,A* 才有终点可去
# (同 `route_base` 用 `finger_escape()` 的 launch 点喂金手指的办法)。
def _clip(v, lo, hi):
return max(lo, min(hi, v))
edge_pts = {}
for s in cp['segs']:
lay, x1, y1, x2, y2, w, net = s[0], s[1], s[2], s[3], s[4], s[5], s[6]
if net not in NETS or lay not in RT.LAY:
continue
a, b = in_win(x1, y1), in_win(x2, y2)
if a == b:
continue # 整段在窗内或整段在窗外,都不是接口
if lay != RB.B.BOTTOM:
# ⛔ 落点不能取在 **L1**:那一层在这一带整片是 `+12V` 大铜,格点是 255(多网叠加,
# 谁都不能走)。实测 `FAN_EN` 的落点取到 L1 之后「起步格点 0 个」,直接报「出不来」。
continue
ix, iy = (x1, y1) if a else (x2, y2) # 取落在窗内的那一头
# ⛔ 端点正好压在主干线上(`FAN_PWM` 实测净距 −0.127)⇒ 起步格点也是 0。
# 沿这一段往**窗内**退 RETREAT,让出一个能起步的格点。
ox, oy = (x2, y2) if a else (x1, y1) # 段的另一头(窗外那一侧)
seg_len = math.hypot(ix - ox, iy - oy)
if seg_len > 1e-6:
ux, uy = (ix - ox) / seg_len, (iy - oy) / seg_len
ix, iy = ix + ux * min(RETREAT, seg_len * 0.4), iy + uy * min(RETREAT, seg_len * 0.4)
edge_pts.setdefault(net, []).append((lay, round(ix, 4), round(iy, 4), w))
# 主干整条都在窗外的网:取离窗口最近的那个端点,夹到窗边上当落点
for net in NETS:
if net in edge_pts or not outside.get(net):
continue
best = None
for s in cp['segs']:
# ⛔ 同样只在 L4 找:窗缘落点分支第一版漏了这条,`FAN_EN` 的落点取到 **L1**,
# 而那一层这一带整片是 `+12V` 大铜、格点是 255(多网叠加,谁都不能走),
# 于是「起步格点 0 个 ⇒ 出不来」,本机单独验那个坐标却有 16 个可起步 —— 差别就在层。
if s[6] != net or s[0] != RB.B.BOTTOM:
continue
for (px, py) in ((s[1], s[2]), (s[3], s[4])):
cx = _clip(px, WIN[0], WIN[2])
cy = _clip(py, WIN[1], WIN[3])
d = ((px - cx) ** 2 + (py - cy) ** 2) ** 0.5
if best is None or d < best[0]:
best = (d, s[0], px, py, s[5])
if best and best[0] <= 6.0: # 主干就在窗边上:直接拿它当落点
edge_pts.setdefault(net, []).append((best[1], round(best[2], 4), round(best[3], 4), best[4]))
elif best:
# ⛔ 主干远在窗外(`FAN_EN` / `FAN_TACH_OUT` 的最近端点在 x≈42~43,**在槽区里面**,
# 离窗 14.5~15.6 mm)。**不能**放宽上限去接它 —— 那等于让 A* 从窗口里拉一条 15 mm 的线
# 横穿插槽区,正是设窗口要避免的事(上一版 `+3V3` 穿针排就是这么来的)。
# ⇒ 在**窗口边缘**给它一个落点:窗内这一段布到边上为止,窗外那一小段单独接。
ex = _clip(best[2], WIN[0] + 0.3, WIN[2] - 0.3)
ey = _clip(best[3], WIN[1] + 0.3, WIN[3] - 0.3)
edge_pts.setdefault(net, []).append((best[1], round(ex, 4), round(ey, 4), best[4]))
print(u' ⚠ %s 的主干离窗 %.1f mm(在槽区里),只布到窗缘 (%.2f, %.2f),窗外那段要另接'
% (net, best[0], ex, ey))
n_virt = 0
for net, pts in edge_pts.items():
pts.sort(key=lambda q: -q[3]) # 宽的(主干)优先
for (lay, px, py, w) in pts[:2]: # 每个网最多给两个落点,够 A* 挑
bypad.setdefault(net, []).append(
['#trunk', 'edge%d' % n_virt, px, py, 0.3, 0.3, lay, net, 'RECT'])
n_virt += 1
print(u'给跨窗口的网补了 %d 个「主干接口」虚拟落点:%s'
% (n_virt, ' '.join(sorted(edge_pts)) or u'无'))
print(u'窗口 x %.2f~%.2f y %.2f~%.2f;窗口外过路费 %d' % (WIN[0], WIN[2], WIN[1], WIN[3], OUT_PEN))
for n in NETS:
print(u' %-18s 窗口内 %d 脚 / 窗口外 %d 脚(外面的不参与,只当已有铜的接口)'
% (n, len(bypad.get(n, [])), outside.get(n, 0)))
drop = [n for n in NETS if not bypad.get(n)]
if drop:
print(u' (这些网窗口内一只脚都没有,跳过:%s)' % ' '.join(drop))
print(u'现有铜 %d 段 / %d 孔' % (len(segs), len(vias)))
n0, v0 = len(segs), len(vias)
RT.AUTO_CACHE.clear()
# ⛔ 诊断:`$6N49` / `$6N74` / `$6N45` 这几条链一路报「一根铜都没有」,而窗口空着 85%、
# 焊盘尺寸与 width_of 都正常 —— 看着不像拥塞,像**布线器压根没处理它们**。
# `route_groups` 按线宽分档、逐档调 A*,这里把「每一档收了哪些网」和每档前后的段数打出来,
# 一眼就能看出某个网是不是从头到尾没进过任何一档。
_orig_rg = RT.route_groups
def _rg(bp, pd, sg, vi, bv, auto, priority=(), only=None):
want = set(only) if only is not None else set(bp)
seen = {}
for net, ps in bp.items():
if only is not None and net not in only:
continue
seen[net] = round(RT.width_of(net, auto, ps), 3)
buckets = {}
for net, w in seen.items():
buckets.setdefault(w, []).append(net)
print(u' 分档(线宽 → 网):')
for w in sorted(buckets, reverse=True):
print(u' %.3f mm: %s' % (w, ' '.join(sorted(buckets[w]))))
miss = sorted(want - set(seen))
if miss:
print(u' ⚠ 这些网在 only 里却没进任何一档:%s' % ' '.join(miss))
before = len(sg)
out = _orig_rg(bp, pd, sg, vi, bv, auto, priority=priority, only=only)
print(u' route_groups 共产出 %d 段' % (len(sg) - before))
by = {}
for s in sg[before:]:
by[s[6]] = by.get(s[6], 0) + 1
for net in sorted(seen):
print(u' %-18s %s' % (net, (u'%d 段' % by[net]) if net in by else u'⛔ 0 段'))
return out
RT.route_groups = _rg
# ⛔ 顺序决定成败:`route_groups` 按「出口数少 → 跨度大」排,`$6N49` 排在 `FAN_12V_SW`(37 段) 之后,
# 走廊被先布的线占掉 ⇒ 它四只脚**两两直连全被挡**(本机实测 −0.499 ~ +0.139,最好的一对差 0.011),
# 报「一根铜都没有」,看着像板子挤,其实是顺序。router 自己的注释里记过同一个坑
# ("同样的摆位,先布它就通")。⇒ 把这几条挤在一起的本地链**顶到 priority 最前面**。
PRI = tuple(CFG.get('priority') or ()) + tuple(n for n in NETS if n not in (CFG.get('priority') or ()))
print(u'布线优先级:%s' % ' '.join(PRI[:6]))
logs = RT.route_groups(bypad, pads, segs, vias, board_vias, {},
priority=PRI, only=set(bypad))
if logs and '--repair' in sys.argv:
print(u' 还剩 %d 条没布通,拆线重布(只拆本次新布的)' % len(logs))
segs, vias, logs = RT.repair(bypad, pads, segs, vias, logs, board_vias, {}, n0, v0)
new_s = [list(s) for s in segs[n0:]]
new_v = [list(v) for v in vias[v0:]]
print(u'新增 %d 段 / %d 孔' % (len(new_s), len(new_v)))
for m in logs:
print(u' [!] ' + m)
# 连通性只看窗口内的脚(窗口外的主干本来就通)
chk = [q for q in RB.check_pads(pads) if q[7] in set(bypad) and in_win(q[2], q[3])]
left = RT.check_connected(chk, segs, vias)
print(u'连通性(窗口内口径):%s' % (u'全通' if not left else left))
# 跑出窗口的新铜要点名 —— 这是这套办法最该盯的失败模式
stray = [s for s in new_s if not (in_win(s[1], s[2], 0.5) and in_win(s[3], s[4], 0.5))]
print(u'跑出窗口的新线段:%d%s' % (len(stray), u' ⚠' if stray else u' ✓'))
for s in stray[:8]:
print(u' %s L%s (%.2f,%.2f)->(%.2f,%.2f)' % (s[6], s[0], s[1], s[2], s[3], s[4]))
json.dump({'window': list(WIN), 'nets': NETS, 'segs': new_s, 'vias': new_v,
'logs': list(logs), 'unconnected': list(left), 'stray': len(stray)},
io.open(os.path.join(HERE, '_base_window_repair.json'), 'w', encoding='utf-8'),
ensure_ascii=False)
print(u'已写 _base_window_repair.json')