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
这个提交包含在:
zhou yang
2026-09-12 12:02:32 +08:00
共同撰写人 Claude Opus 5
父节点 51b570a59f
当前提交 312089326e
共修改 15 个文件,包含 5053 行新增和 1139 行删除
文件差异因一行或多行过长而隐藏
文件差异因一行或多行过长而隐藏
文件差异内容过多而无法显示 加载差异
+321 -165
查看文件
@@ -1,10 +1,4 @@
{
"C1": [
10.25,
18.75,
270,
2
],
"C10": [
12.5,
71.75,
@@ -17,17 +11,95 @@
0,
2
],
"C12": [
26.9,
15.5,
270,
2
],
"C13": [
24.85,
23.86,
180,
2
],
"C14": [
2.95,
28.45,
0,
2
],
"C15": [
8.25,
28.45,
0,
2
],
"C16": [
13.55,
28.45,
0,
2
],
"C17": [
18.85,
28.45,
0,
2
],
"C18": [
19.2,
24.85,
0,
2
],
"C19": [
19.75,
23.6,
0,
2
],
"C2": [
5.5,
35.0,
180,
1
],
"C20": [
19.75,
17.8,
0,
2
],
"C21": [
19.2,
15.1,
90,
2
],
"C22": [
23.9,
15.0,
0,
2
],
"C23": [
23.9,
26.3,
0,
2
],
"C24": [
23.9,
28.3,
0,
2
],
"C3": [
19.5,
35.0,
0,
2
1
],
"C4": [
17.5,
@@ -36,9 +108,9 @@
2
],
"C5": [
9.0,
28.5,
90,
1.5,
21.75,
270,
2
],
"C6": [
@@ -156,9 +228,9 @@
2
],
"C738": [
8.5,
83.5,
180,
7.9248,
78.1729,
270,
2
],
"C739": [
@@ -168,75 +240,75 @@
2
],
"C740": [
38.0,
43.75,
180,
42.926,
41.578,
90,
2
],
"C741": [
36.25,
40.5,
41.148,
37.338,
270,
2
],
"C742": [
58.25,
38.25,
270,
57.7,
38.2,
0,
2
],
"C743": [
42.0,
38.5,
270,
45.3,
41.64,
180,
2
],
"C744": [
43.5,
34.5,
270,
43.18,
36.449,
180,
2
],
"C745": [
42.0,
36.5,
270,
43.18,
37.465,
180,
2
],
"C746": [
40.25,
41.0,
270,
45.3,
44.06,
180,
2
],
"C747": [
58.25,
43.0,
270,
52.95,
45.25,
90,
2
],
"C748": [
58.25,
40.25,
90,
57.7,
39.41,
0,
2
],
"C749": [
44.75,
34.25,
90,
48.75,
32.42,
270,
2
],
"C750": [
40.25,
43.0,
270,
49.2,
45.91,
90,
2
],
"C751": [
43.75,
37.5,
270,
65.5,
36.5,
0,
2
],
"C752": [
@@ -270,33 +342,33 @@
2
],
"D2": [
20.75,
94.25,
12.192,
77.724,
270,
2
],
"DZ1": [
16.25,
21.75,
90,
"D3": [
19.25,
4.0,
270,
2
],
"DZ3": [
25.75,
90.25,
1.397,
82.296,
90,
2
],
"F1": [
12.5,
17.75,
11.8,
90,
2
],
"J1": [
12.5,
10.63,
90,
5.25,
180,
2
],
"J23": [
@@ -306,22 +378,22 @@
2
],
"J26": [
13.5,
94.5,
12.446,
94.289,
0,
2
1
],
"J27": [
13.5,
88.0,
7.112,
84.443,
0,
2
1
],
"J33": [
80.0,
95.5,
0,
2
1
],
"J34": [
49.0,
@@ -365,15 +437,15 @@
0,
2
],
"Q1": [
12.5,
24.5,
"L4": [
10.4,
20.7,
180,
2
],
"Q10": [
26.0,
81.75,
17.018,
81.153,
180,
2
],
@@ -384,33 +456,51 @@
2
],
"Q6": [
21.25,
88.25,
0,
22.1032,
86.3586,
270,
2
],
"Q7": [
24.75,
95.75,
270,
4.318,
79.629,
180,
2
],
"Q8": [
14.0,
83.0,
0,
4.2148,
76.2031,
180,
2
],
"Q9": [
26.0,
77.25,
90,
13.208,
89.916,
180,
2
],
"R1": [
8.75,
21.75,
270,
"R10": [
24.85,
22.64,
0,
2
],
"R11": [
24.85,
21.42,
180,
2
],
"R12": [
24.2,
16.58,
0,
2
],
"R13": [
24.2,
17.8,
0,
2
],
"R2": [
@@ -426,20 +516,20 @@
2
],
"R4": [
1.25,
42.5,
180,
3.937,
42.418,
0,
2
],
"R5": [
4.0,
42.25,
90,
4.318,
45.085,
270,
2
],
"R6": [
6.75,
43.25,
3.937,
41.402,
0,
2
],
@@ -545,6 +635,12 @@
0,
2
],
"R7": [
19.0,
11.25,
90,
2
],
"R706": [
16.75,
71.25,
@@ -558,39 +654,39 @@
2
],
"R708": [
40.25,
38.0,
270,
45.3,
42.85,
180,
2
],
"R709": [
35.75,
37.25,
270,
51.181,
29.845,
90,
2
],
"R710": [
41.75,
34.0,
50.165,
29.845,
270,
2
],
"R711": [
39.5,
34.25,
90,
54.229,
29.845,
270,
2
],
"R712": [
38.0,
37.25,
90,
53.213,
29.845,
270,
2
],
"R716": [
37.25,
34.25,
90,
52.197,
29.845,
270,
2
],
"R717": [
@@ -606,15 +702,15 @@
2
],
"R719": [
64.75,
34.25,
90,
55.245,
29.845,
270,
2
],
"R720": [
38.5,
41.0,
90,
49.071,
29.847,
270,
2
],
"R721": [
@@ -629,88 +725,136 @@
90,
2
],
"R723": [
42.926,
39.751,
270,
2
],
"R724": [
43.2,
35.35,
180,
2
],
"R725": [
43.2,
34.15,
180,
2
],
"R726": [
45.3,
33.6,
180,
2
],
"R727": [
57.7,
34.18,
180,
2
],
"R728": [
57.7,
35.4,
180,
2
],
"R729": [
57.7,
42.45,
180,
2
],
"R733": [
22.25,
85.0,
0,
24.5752,
84.9776,
90,
2
],
"R734": [
22.25,
82.75,
22.5712,
83.6586,
180,
2
],
"R746": [
19.75,
78.5,
4.064,
82.042,
180,
2
],
"R747": [
25.75,
86.0,
180,
5.9356,
78.0532,
90,
2
],
"R748": [
17.0,
77.0,
90,
5.08,
73.914,
180,
2
],
"R749": [
17.5,
83.5,
90,
3.175,
73.914,
0,
2
],
"R750": [
7.0,
89.25,
270,
9.906,
91.313,
180,
2
],
"R751": [
4.75,
89.25,
10.541,
89.408,
90,
2
],
"R752": [
2.5,
88.75,
90,
12.7,
87.884,
180,
2
],
"R753": [
22.75,
76.25,
270,
15.621,
89.535,
90,
2
],
"R754": [
17.75,
80.75,
0,
12.573,
82.55,
180,
2
],
"R755": [
14.75,
79.75,
0,
14.5288,
80.645,
90,
2
],
"R756": [
14.25,
77.5,
0,
16.51,
78.867,
180,
2
],
"R757": [
22.5,
80.0,
90,
18.542,
83.566,
180,
2
],
"R8": [
24.85,
20.2,
180,
2
],
"R9A": [
@@ -750,9 +894,9 @@
2
],
"RT602": [
19.5,
8.0,
0,
26.9,
19.3,
90,
2
],
"Rs6": [
@@ -780,9 +924,9 @@
2
],
"U29": [
2.25,
45.75,
180,
2.032,
45.085,
270,
2
],
"U30": [
@@ -809,6 +953,18 @@
0,
2
],
"U36": [
23.7,
10.4,
270,
2
],
"U37": [
21.0,
20.7,
270,
2
],
"U6": [
56.0,
59.16,
@@ -822,8 +978,8 @@
2
],
"Y1": [
42.8,
41.68,
39.878,
40.64,
270,
2
]
文件差异因一行或多行过长而隐藏
文件差异内容过多而无法显示 加载差异
+100
查看文件
@@ -0,0 +1,100 @@
{
"window": [
0.5,
73.5,
26.84,
97.09
],
"nets": [
"$6N45",
"$6N48",
"$6N49",
"$6N55",
"$6N64",
"$6N74",
"+3V3",
"FAN_12V_SW",
"FAN_EN",
"FAN_IN_TACH_RAW",
"FAN_OUT_TACH_RAW",
"FAN_PWM",
"FAN_PWM_L",
"FAN_TACH_IN",
"FAN_TACH_OUT"
],
"out_pen": 12,
"priority": [
"$6N49",
"$6N64",
"$6N45",
"$6N74",
"$6N48",
"$6N55",
"FAN_IN_TACH_RAW",
"FAN_OUT_TACH_RAW",
"FAN_PWM_L",
"FAN_12V_SW",
"FAN_TACH_IN",
"FAN_TACH_OUT",
"FAN_PWM",
"FAN_EN",
"+3V3"
],
"_note": "插槽方向在 repair_window._pen 里是 block_rect 封死,不是过路费;slot_pen 已证明无效(A* 宁可付 200 也抄近路),故删除 | pd_l1_extra = 按用户 2026-09-12 新摆位重开的 L1 走廊(旧的四条是按旧摆位开的,与件对不上 ⇒ 长途网必然布不通);逐 y 断面 11.80 ≥ 8.0,见 l1_section.py",
"pd_l1_extra": [
[
"FAN_EN",
[
12.0,
76.1,
27.5,
77.1
]
],
[
"FAN_TACH_IN",
[
16.0,
75.15,
27.5,
76.05
]
],
[
"FAN_TACH_IN",
[
13.7,
76.05,
16.2,
90.4
]
],
[
"FAN_TACH_OUT",
[
17.4,
80.7,
27.5,
81.7
]
],
[
"FAN_TACH_OUT",
[
17.4,
81.7,
18.8,
84.1
]
],
[
"FAN_PWM_L",
[
10.3,
83.9,
22.5,
87.3
]
]
]
}
文件差异因一行或多行过长而隐藏
+97
查看文件
@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
u"""按坐标**拆掉窗口内待布网的旧铜**(下发新布线之前的第一步)。
/c/Python314/python .claude/r2/fan_rip.py # 只列清单,不动板
/c/Python314/python .claude/r2/fan_rip.py --push # 真删
⛔ 2026-09-12 实测:**删外层段会连带清掉过孔和它在内层的延续** —— 点名删 6 段,读回发现线少 12 段、
孔少 8 个,多掉的全在 L15/L16。⇒ 拆前后都要比对**全板**线 / 孔总数,不能只数自己点名的那几段。
这里每次都先读回全板、删完再读回,逐项打印差值。
判据与 `repair_window` 同口径:`_base_window.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)
from eda import run # noqa: E402
import pcb_base as B # noqa: E402
MIL = 0.0254
CFG = json.load(io.open(os.path.join(HERE, '_base_window.json'), encoding='utf-8'))
WIN = CFG['window']
NETS = set(n.replace('@', '$') for n in CFG['nets'])
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,2500));" % B.PCB)
READ = OPEN + """
const L=(await eda.pcb_PrimitiveLine.getAll()||[]).filter(l=>[1,2,15,16].includes(l.layer))
.map(l=>[l.primitiveId,l.net,l.layer,l.startX,l.startY,l.endX,l.endY,l.lineWidth]);
const V=(await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[v.primitiveId,v.net,v.x,v.y]);
return {L, V};
"""
def inw(x, y):
return WIN[0] <= x <= WIN[2] and WIN[1] <= y <= WIN[3]
def read():
r = run(READ, timeout=300)
assert r.get('success'), json.dumps(r, ensure_ascii=False)[:400]
return r['result']
def main():
d = read()
L, V = d['L'], d['V']
outer = [l for l in L if l[2] in (1, 2)]
inner = [l for l in L if l[2] in (15, 16)]
print(u'板上:外层 %d 段 / 内层 %d 段 / 过孔 %d' % (len(outer), len(inner), len(V)))
kill = []
for pid, net, lay, x1, y1, x2, y2, w in outer:
if net not in NETS:
continue
if inw(x1 * MIL, y1 * MIL) and inw(x2 * MIL, y2 * MIL):
kill.append((pid, net, lay, x1 * MIL, y1 * MIL, x2 * MIL, y2 * MIL))
killv = [(v[0], v[1]) for v in V if v[1] in NETS and inw(v[2] * MIL, v[3] * MIL)]
print(u'要拆:%d 段 / %d 孔' % (len(kill), len(killv)))
by = {}
for k in kill:
by[k[1]] = by.get(k[1], 0) + 1
for n in sorted(by):
print(u' %-18s %d 段' % (n, by[n]))
io.open(os.path.join(HERE, '_base_fan_ripped.json'), 'w', encoding='utf-8').write(
json.dumps({'segs': [[k[2], k[3], k[4], k[5], k[6], 0, k[1]] for k in kill],
'vias': []}, ensure_ascii=False))
if '--push' not in sys.argv:
print(u'(只列清单;加 --push 才真删)')
return
ids = [k[0] for k in kill] + [v[0] for v in killv]
r = run(OPEN + """
const ids = %s;
let n = 0;
for (const id of ids) { try { await eda.pcb_PrimitiveLine.delete(id); n++; } catch (e) {
try { await eda.pcb_PrimitiveVia.delete(id); n++; } catch (e2) {} } }
await eda.pcb_Document.save();
return n;
""" % json.dumps(ids), timeout=600)
print(u'删除返回:%s' % json.dumps(r.get('result'), ensure_ascii=False))
d2 = read()
o2 = [l for l in d2['L'] if l[2] in (1, 2)]
i2 = [l for l in d2['L'] if l[2] in (15, 16)]
print(u'拆后:外层 %d 段(−%d,点名 %d)/ 内层 %d 段(−%d)/ 过孔 %d(−%d)'
% (len(o2), len(outer) - len(o2), len(kill), len(i2), len(inner) - len(i2),
len(d2['V']), len(V) - len(d2['V'])))
if len(outer) - len(o2) != len(kill) or len(inner) != len(i2):
print(u'⛔ 连带删除!实际掉的和点名的对不上,检查内层 / 过孔')
if __name__ == '__main__':
main()
+71
查看文件
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
u"""L1 `+12V` 主块的**逐 y 水平断面**核算(§6.4 要 ≥ 8.0 mm)。
/c/Python314/python .claude/r2/l1_section.py # 核 pcb_base.PD_L1 现状
/c/Python314/python .claude/r2/l1_section.py --cfg # 核 _base_window.json 里的 pd_l1_extra
主块 x 0.5~27.8(宽 27.30),电流南北流 ⇒ 断面是水平线,线上被走廊占掉的 x 跨度要扣掉。
横带(沿 x 长)很贵、竖带(沿 y 长)几乎免费 —— 旧注释「一根 0.15 的线占约 0.5」说的就是竖线。
槽指头在 x ≥ 27.5,走廊 x 一律 ≤ 27.5 ⇒ 天然不碰任何一槽的供电路径。
"""
import io
import json
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)
import pcb_base as B # noqa: E402
MIN_SECTION = 8.0
MAIN = B.l1_12v_rects()[0]
WIDTH = MAIN[2] - MAIN[0]
def check(corridors, step=0.05):
worst = (1e9, None)
y = MAIN[1]
while y <= MAIN[3]:
iv = []
for _n, r in corridors:
if r[1] - 1e-9 <= y <= r[3] + 1e-9:
iv.append((max(r[0], MAIN[0]), min(r[2], MAIN[2])))
iv.sort()
used, spans = 0.0, []
for a, b in iv:
if b <= a:
continue
if spans and a <= spans[-1][1]:
spans[-1] = (spans[-1][0], max(spans[-1][1], b))
else:
spans.append((a, b))
for a, b in spans:
used += b - a
left = WIDTH - used
if left < worst[0]:
worst = (left, y)
y += step
return worst
def main():
cor = [(n, r) for n, r in B.PD_L1 if n.startswith('FAN')]
if '--cfg' in sys.argv:
cfg = json.load(io.open(os.path.join(HERE, '_base_window.json'), encoding='utf-8'))
cor = [(n, tuple(r)) for n, r in (cfg.get('pd_l1_extra') or [])]
print(u'主块 x %.2f~%.2f(宽 %.2f);走廊 %d 条' % (MAIN[0], MAIN[2], WIDTH, len(cor)))
for n, r in cor:
kind = u'横带' if (r[2] - r[0]) >= (r[3] - r[1]) else u'竖带'
print(u' %-14s (%6.2f, %6.2f, %6.2f, %6.2f) %5.2f×%5.2f %s'
% (n, r[0], r[1], r[2], r[3], r[2] - r[0], r[3] - r[1], kind))
left, y = check(cor)
ok = left >= MIN_SECTION
print(u'%s 逐 y 最窄断面 %.2f mm(y=%.2f),判据 ≥ %.1f' %
(u'[OK]' if ok else u'[X]', left, y, MIN_SECTION))
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
+614 -556
查看文件
文件差异内容过多而无法显示 加载差异
+303
查看文件
@@ -0,0 +1,303 @@
# -*- 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')
+190
查看文件
@@ -0,0 +1,190 @@
# -*- coding: utf-8 -*-
u"""底板**位号丝印**的重排与下发。
/c/Python314/python .claude/r2/silk_base.py # 只算 + 体检(改前 / 改后对照)
/c/Python314/python .claude/r2/silk_base.py --push # 下发(pcb_PrimitiveAttribute.modify)+ 读回核对
## 判据
位号文字框(字高 `fs`、字宽 ≈ 0.62×字高×字符数,`alignMode=3` 居中)不许:
① 压同面焊盘(顶层丝印 layer 3 对 L1 + 通孔盘;底层 layer 4 对 L4 + 通孔盘)
② 压过孔 ③ 越板框(留 `EDGE` 0.5) ④ 与别的位号重叠
⚠ **大件(本体 ≥ 5 mm)的位号保留原位**:五个插槽的位号故意放在槽体西侧 x=28(离本体 21 mm),
那是设计,不是问题;U20 / 电解 / 座子同理。它们先占位,小件绕开它们。
⚠ 位号必须**紧挨器件**才有意义 ⇒ 离本体外框有硬上限 `MAXD`。够不着就降级:
理想位 → 允许压过孔 → 允许压焊盘 → 保留原位。单纯"找个空地"会把位号甩到十几毫米外。
⚠ `rotation` 字段单位是**度且不归一化**(900 = 180°),读回要 `%% 360`。
"""
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)
from eda import run # noqa: E402
import pcb_base as B # noqa: E402
import place_base as PL # noqa: E402
MIL = 0.0254
SNAP = os.path.join(HERE, '_base_silk.json')
PLAN = os.path.join(HERE, '_base_silk_plan.json')
CHAR_W, EDGE, GAP, MAXD = 0.62, 0.5, 0.15, 2.2
# ⛔ 保留原位的**显式名单**(位号故意远离本体,不是问题):
# 五个插槽的位号放在槽体西侧 x=28(离本体 21 mm)、U20 让开天线净空、
# 顶面件与板边 USB-C 的位号只能朝板内放。
# ⚠ 别用「本体 ≥ 5 mm」当判据:那会把 R9A(7.4×3.5) / D3(6.8×2.2) / U13(2.3×5.8)
# 这些**该重排**的中等件也豁免掉 —— 实测残留 25 个里有 18 个是这么来的。
KEEP = {'J34', 'J35', 'J36', 'J37', 'J38', 'U20',
'J26', 'J27', 'J33', 'C2', 'C3', 'J1', 'J23'}
READ = u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r=>setTimeout(r,2500));
const M = 0.0254, nrm = v => ((v %% 360) + 360) %% 360;
const A = (await eda.pcb_PrimitiveAttribute.getAll()||[]).filter(a=>a.key==='Designator')
.map(a=>({ref:a.value, lay:a.layer, x:a.x*M, y:a.y*M, rot:nrm(a.rotation),
fs:a.fontSize*M, vis:a.valueVisible?1:0, id:a.primitiveId}));
const C = (await eda.pcb_PrimitiveComponent.getAll()||[])
.map(c=>({ref:c.designator, x:c.x*M, y:c.y*M, rot:nrm(c.rotation||0), lay:c.layer}));
const V = (await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[v.x*M, v.y*M, String(v.net), v.diameter*M]);
return {attrs:A, comps:C, vias:V};
""" % B.PCB
def snap():
r = run(READ, timeout=600)
d = r.get('result')
assert d, json.dumps(r, ensure_ascii=False)[:400]
io.open(SNAP, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
return d
def tbox(ref, x, y, rot, fs):
h = fs
w = CHAR_W * h * len(ref)
if rot in (90, 270):
w, h = h, w
return (x - w / 2., y - h / 2., x + w / 2., y + h / 2.)
def ov(p, q):
return not (p[2] <= q[0] or q[2] <= p[0] or p[3] <= q[1] or q[3] <= p[1])
def plan(d, pads, sz):
comp = {c['ref']: c for c in d['comps']}
V = d['vias']
padsL = {3: [p for p in pads if p[6] in (1, 12)], 4: [p for p in pads if p[6] in (2, 12)]}
def hit_pad(bb, lay):
return any(ov(bb, (p[2] - p[4] / 2., p[3] - p[5] / 2., p[2] + p[4] / 2., p[3] + p[5] / 2.))
for p in padsL[lay])
def hit_via(bb):
return any(ov(bb, (v[0] - v[3] / 2., v[1] - v[3] / 2., v[0] + v[3] / 2., v[1] + v[3] / 2.))
for v in V)
def outside(bb):
return bb[0] < EDGE or bb[1] < EDGE or bb[2] > B.BW - EDGE or bb[3] > B.BH - EDGE
placed, out, stat = [], {}, {}
for a in d['attrs']:
if a['vis'] and a['ref'] in KEEP:
placed.append((a['lay'], tbox(a['ref'], a['x'], a['y'], a['rot'], a['fs'])))
stat[u'白名单保留'] = stat.get(u'白名单保留', 0) + 1
small = [a for a in d['attrs'] if a['vis'] and a['ref'] not in KEEP]
small.sort(key=lambda a: -(sz.get(a['ref'], (1, 1))[0] * sz.get(a['ref'], (1, 1))[1]))
for a in small:
ref, c, lay, fs = a['ref'], comp.get(a['ref']), a['lay'], a['fs']
if not c:
continue
w, h = sz.get(ref, (1.0, 1.0))
if int(round(c['rot'])) % 180:
w, h = h, w
best, level = None, None
for lv in (0, 1, 2):
r = 0.0
while r <= MAXD and best is None:
for ang in (90, 270, 0, 180, 45, 135, 225, 315):
for rot in ((0, 90) if ang in (0, 180) else (0,)):
tw, th = CHAR_W * fs * len(ref), fs
if rot == 90:
tw, th = th, tw
ca, sa = math.cos(math.radians(ang)), math.sin(math.radians(ang))
if abs(ca) <= 0.7 and abs(sa) <= 0.7:
off = math.hypot(w / 2. + tw / 2., h / 2. + th / 2.) + GAP + r
elif abs(ca) > 0.7:
off = w / 2. + tw / 2. + GAP + r
else:
off = h / 2. + th / 2. + GAP + r
x, y = c['x'] + off * ca, c['y'] + off * sa
bb = tbox(ref, x, y, rot, fs)
if outside(bb) or any(b[0] == lay and ov(bb, b[1]) for b in placed):
continue
if lv < 2 and hit_pad(bb, lay):
continue
if lv < 1 and hit_via(bb):
continue
best, level = (round(x, 4), round(y, 4), rot, bb), lv
break
if best:
break
r += 0.25
if best:
break
if best:
placed.append((lay, best[3]))
out[ref] = {'x': best[0], 'y': best[1], 'rot': best[2], 'id': a['id'], 'lay': lay}
k = [u'理想位', u'允许压过孔', u'允许压焊盘'][level]
stat[k] = stat.get(k, 0) + 1
else:
stat[u'无解(保留原位)'] = stat.get(u'无解(保留原位)', 0) + 1
return out, stat
def main():
d = snap()
pads = json.load(io.open(os.path.join(HERE, '_base_pads.json'), encoding='utf-8'))
sz = PL.sizes()
p, stat = plan(d, pads, sz)
io.open(PLAN, 'w', encoding='utf-8').write(json.dumps(p, ensure_ascii=False, indent=0))
print(u'位号 %d(可见 %d);重排 %d 个' % (len(d['attrs']), sum(a['vis'] for a in d['attrs']), len(p)))
for k, v in sorted(stat.items(), key=lambda t: -t[1]):
print(u' %-14s %d' % (k, v))
if '--push' not in sys.argv:
print(u'(只算;加 --push 下发)')
return
rows = [[v['id'], v['x'] / MIL, v['y'] / MIL, v['rot']] for v in p.values()]
n = 0
for k in range(0, len(rows), 60):
r = run(u"""
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r=>setTimeout(r,1500));
const R = %s;
let n = 0;
for (const [id, x, y, rot] of R) {
try { await eda.pcb_PrimitiveAttribute.modify(id, {x: x, y: y, rotation: rot}); n++; } catch (e) {}
}
return n;
""" % (B.PCB, json.dumps(rows[k:k + 60])), timeout=600)
n += r.get('result') or 0
run(u"await eda.pcb_Document.save(); return 1;", timeout=300)
print(u' modify 成功 %d / %d' % (n, len(rows)))
# 读回核对
d2 = snap()
now = {a['ref']: a for a in d2['attrs']}
bad = [k for k, v in p.items()
if k not in now or abs(now[k]['x'] - v['x']) > 0.02 or abs(now[k]['y'] - v['y']) > 0.02
or int(now[k]['rot']) % 360 != v['rot'] % 360]
print(u'读回核对:%d 个对上,%d 个没对上 %s' % (len(p) - len(bad), len(bad), bad[:10]))
if __name__ == '__main__':
main()