文件
phonerail/.claude/r2/schcheck.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

320 行
15 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""R2 原理图页脚本的公共部分:几何自检 + 下发。
`b2_ctrl.py` 里那一套(本地并查集反推 + 旗端口端点判据 + 文字越界)抽出来复用,
`b3_phone.py` / `b3b_usb.py` / `b4_edge.py` 都吃这一份。
判据出处见 `easyeda-sch-geometry` skill:
§3/§8a-2b 坐标必须是 5 的倍数、§8a-2/§8a-3 旗端口必须落在导线端点且不能是母线中段、
§10b 推送前先本地反推网表、§12 文字宽度经验值、§9c 分批下发、§6b/§6c 放完读回断言。
"""
import io
import json
import os
import sys
from collections import defaultdict
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ `.claude` 下有和 r2 同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place)。
# 只插父目录的话,**后面任何一个 `import pcb_rules` 都会拿到 R1 那一份**,
# 而且不报错,只在调到 R2 专有的函数时才炸(2026-09-08 踩过)。
# 规矩:两行都要写,HERE 后插(在 sys.path 最前)。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
PARTS = json.load(io.open(os.path.join(os.path.dirname(HERE), 'eda-parts.json'),
encoding='utf-8'))['parts']
def call(js, timeout=180):
r = run(js, timeout=timeout)
if not r.get('success'):
raise RuntimeError(json.dumps(r, ensure_ascii=False)[:900])
return r['result']
class Sheet(object):
u"""攒一页的导线 / 旗 / 端口 / 文字,带下发前的全部自检。"""
def __init__(self, page, name):
self.page, self.name = page, name
self.W, self.F, self.PT, self.TX = [], [], [], []
# ── 声明 ──────────────────────────────────────────────────────────
def w(self, net, *pts):
for a, b in zip(pts, pts[1:]):
if a != b:
self.W.append((a, b, net))
def gnd(self, x, y, rot):
self.F.append(['Ground', 'GND', x, y, rot])
def pwr(self, net, x, y, rot):
self.F.append(['Power', net, x, y, rot])
def port(self, d, net, x, y, rot):
self.PT.append([d, net, x, y, rot])
def txt(self, x, y, s, size=10):
self.TX.append((x, y, s, size))
# ── 自检 ──────────────────────────────────────────────────────────
def assert_grid(self):
for a, b, net in self.W:
assert (a[0] == b[0]) != (a[1] == b[1]), \
u'%s 段不是横平竖直: %s->%s' % (net, a, b)
for v in (a[0], a[1], b[0], b[1]):
assert v % 5 == 0, u'%s 坐标 %d 不是 5 的倍数' % (net, v)
for it in self.F + self.PT:
assert it[2] % 5 == 0 and it[3] % 5 == 0, \
u'旗/端口 %s 坐标不是 5 的倍数' % (it[1],)
assert 20 <= it[2] <= 1145 and 10 <= it[3] <= 812, \
u'旗/端口 %s 出框' % (it[1],)
# 文字越界(skill §12 宽度经验值 + §8c 标题栏 x>455 且 y<192)
for x, y, s, size in self.TX:
cjk = sum(1 for ch in s if ord(ch) > 0x2E7F)
right = x + size * (cjk + 0.5 * (len(s) - cjk))
limit = 455 if y < 192 else 1145
assert right <= limit, \
u'文字右边界 %.0f 越过 %d(y=%d): %s' % (right, limit, y, s[:24])
def netcheck(self, pins, nc, allow_split):
u"""并查集反推 + 与声明的网络名比对。返回错误列表。"""
errs = []
W = self.W
n = len(W)
par = list(range(n))
def find(i):
while par[i] != i:
par[i] = par[par[i]]
i = par[i]
return i
for i in range(n):
for j in range(i + 1, n):
a1, a2, _ = W[i]
b1, b2, _ = W[j]
if (_on(a1, b1, b2) or _on(a2, b1, b2)
or _on(b1, a1, a2) or _on(b2, a1, a2)):
ri, rj = find(i), find(j)
if ri != rj:
par[ri] = rj
grp = defaultdict(list)
for i in range(n):
grp[find(i)].append(i)
pieces = defaultdict(int)
for _root, idx in grp.items():
names = sorted(set(W[i][2] for i in idx))
if len(names) > 1:
errs.append(u'短路: ' + ' + '.join(names) + u' :: ' + ' | '.join(
'%s%s-%s' % (W[i][2], W[i][0], W[i][1]) for i in idx[:6]))
pieces[names[0]] += 1
for name, cnt in sorted(pieces.items()):
if cnt > 1 and name not in allow_split:
errs.append(u'网络 %s 被拆成 %d 块(漏连)' % (name, cnt))
for d, pd in pins.items():
for num, xy in pd.items():
if num in nc.get(d, []):
continue
if not any(_on(tuple(xy), s[0], s[1]) for s in W):
errs.append(u'悬空引脚: %s.%s @ %s' % (d, num, tuple(xy)))
for kind, lst in ((u'旗', self.F), (u'端口', self.PT)):
for it in lst:
p = (it[2], it[3])
through = any(
(s[0][0] == s[1][0] and p[0] == s[0][0]
and min(s[0][1], s[1][1]) < p[1] < max(s[0][1], s[1][1]))
or (s[0][1] == s[1][1] and p[1] == s[0][1]
and min(s[0][0], s[1][0]) < p[0] < max(s[0][0], s[1][0]))
for s in W)
ends = [s for s in W if s[0] == p or s[1] == p]
if through:
errs.append(u'%s %s @ %s 压在导线中段' % (kind, it[1], p))
elif not ends:
errs.append(u'%s %s @ %s 不在任何导线端点上' % (kind, it[1], p))
else:
ax = set(('V' if s[0][0] == s[1][0] else 'H') for s in ends)
if len(ends) > 1 and len(ax) == 1:
errs.append(u'%s %s @ %s 两段共线,合并后会变成中段'
% (kind, it[1], p))
return errs
def crossings(self):
u"""不相连的交叉点。不是错误,但要人眼确认可读性。"""
out = []
W = self.W
for i in range(len(W)):
for j in range(i + 1, len(W)):
a1, a2, na = W[i]
b1, b2, nb = W[j]
if na == nb:
continue
ha, hb = a1[1] == a2[1], b1[1] == b2[1]
if ha == hb:
continue
h, v = ((a1, a2), (b1, b2)) if ha else ((b1, b2), (a1, a2))
y, x = h[0][1], v[0][0]
if (min(h[0][0], h[1][0]) < x < max(h[0][0], h[1][0])
and min(v[0][1], v[1][1]) < y < max(v[0][1], v[1][1])):
out.append((na, nb, x, y))
return out
# ── 下发 ──────────────────────────────────────────────────────────
def clear(self):
n = call("""
await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r=>setTimeout(r,1200));
const nm=(await eda.dmt_Schematic.getCurrentSchematicPageInfo()).name;
if (nm!=='%s') throw new Error('page='+nm);
let k=0;
for (const c of await eda.sch_PrimitiveComponent.getAll()){
if (c.getState_ComponentType()==='sheet') continue;
await eda.sch_PrimitiveComponent.delete(c.getState_PrimitiveId()); k++;
}
for (const w of await eda.sch_PrimitiveWire.getAll()) await eda.sch_PrimitiveWire.delete(w.getState_PrimitiveId());
for (const t of await eda.sch_PrimitiveText.getAll()) await eda.sch_PrimitiveText.delete(t.getState_PrimitiveId());
return k;
""" % (self.page, self.name))
print(u'%s 已清空(删掉 %d 个图元)' % (self.name, n))
def place(self, place, exp):
u"""放件 -> 认领位号 -> 逐脚回读比对,对不上就翻 180 重试(skill §6b/§6c)。"""
put = []
for d, lcsc, x, y, rot in place:
assert lcsc in PARTS, u'eda-parts.json 里没有 %s' % lcsc
put.append([d, PARTS[lcsc]['uuid'], x, y, rot, PARTS[lcsc]['lib']])
for it in put:
assert len(it) == 6 and it[5], u'placement 少了 libraryUuid: %s' % (it,)
for i in range(0, len(put), 4):
call("""
for (const [d,uuid,x,y,rot,lib] of %s){
await eda.sch_PrimitiveComponent.create({libraryUuid:lib, uuid}, x, y, undefined, rot, false, true, true);
for (const c of await eda.sch_PrimitiveComponent.getAll()){
if (c.getState_ComponentType()!=='part') continue;
if (Math.round(c.getState_X())!==x || Math.round(c.getState_Y())!==y) continue;
await c.toAsync().setState_Designator(d).done();
}
}
return 1;
""" % json.dumps(put[i:i + 4]))
# ⚠ skill §6e:`create(..., rot)` 在 rot=90/270 时,**紧接着读回的引脚坐标是错的**
# —— 实测(A2-AUX 空页,0402 电容 rot 90):
# create 之后第一次读 pin1 (0,+20) / pin2 (0,−20)
# 再写一次 setState_Rotation(90) 之后 pin1 (0,−20) / pin2 (0,+20)
# 两者差 180°,非极性件看不出来,**极性件(电解、TVS、二极管)就是接反**。
# 下面这一遍「用同一个角度再写一次」把内部状态刷正,读回才是真的。
# 不做这一步的话,下面那个翻转重试循环会绕两圈才碰对,
# 而且真有一件放错时,循环可能替它「碰」出一个假的通过。
call("""
for (const [d,rot] of %s){
for (const c of await eda.sch_PrimitiveComponent.getAll()){
if (c.getState_ComponentType()!=='part') continue;
if (c.getState_Designator()!==d) continue;
await c.toAsync().setState_Rotation(rot).done();
}
}
return 1;
""" % json.dumps([[d, rot] for d, _l, _x, _y, rot in place]))
print(u'已放置 %d 个元件' % len(put))
READ = """
const out={};
for (const c of await eda.sch_PrimitiveComponent.getAll()){
if (c.getState_ComponentType()!=='part') continue;
out[c.getState_Designator()] = (await c.getAllPins()).map(
p=>[p.getState_PinNumber(), Math.round(p.getState_X()), Math.round(p.getState_Y())]);
}
return out;
"""
rots = dict((d, rot) for d, _l, _x, _y, rot in place)
for rnd in range(4):
got = call(READ)
bad = []
for d, want in exp.items():
if d not in got:
raise SystemExit(u'读不到元件 %s' % d)
have = dict((p, (x, y)) for p, x, y in got[d])
if '?' in want:
if set(have.values()) != want['?']:
bad.append((d, sorted(want['?']), sorted(have.values())))
else:
for p, xy in sorted(want.items()):
if have.get(p) != xy:
bad.append((d, p, xy, have.get(p)))
break
if not bad:
print(u'引脚坐标逐个核对通过(%d 个元件)' % len(exp))
return
if rnd == 3:
raise SystemExit(u'引脚坐标对不上:\n '
+ '\n '.join(repr(b) for b in bad))
names = sorted(set(b[0] for b in bad))
print(u'第 %d 轮:翻转 %d 件重试 %s' % (rnd + 1, len(names), names))
call("""
for (const [d,rot] of %s){
for (const c of await eda.sch_PrimitiveComponent.getAll()){
if (c.getState_ComponentType()!=='part') continue;
if (c.getState_Designator()!==d) continue;
await c.toAsync().setState_Rotation(rot).done();
}
}
return 1;
""" % json.dumps([[d, (rots[d] + 180 * (rnd + 1)) % 360] for d in names]))
def push_geometry(self):
def batch(items, size, js):
for i in range(0, len(items), size):
call(js % json.dumps(items[i:i + size], ensure_ascii=False))
batch([[a[0], a[1], b[0], b[1]] for a, b, _ in self.W], 40,
"for (const s of %s) await eda.sch_PrimitiveWire.create(s); return 1;")
batch(self.PT, 25, "for (const p of %s) await eda.sch_PrimitiveComponent.createNetPort(p[0],p[1],p[2],p[3],p[4],false); return 1;")
batch(self.F, 25, "for (const f of %s) await eda.sch_PrimitiveComponent.createNetFlag(f[0],f[1],f[2],f[3],f[4],false); return 1;")
batch([list(t) for t in self.TX], 12,
"for (const t of %s) await eda.sch_PrimitiveText.create(t[0],t[1],t[2],0,null,null,t[3],false,false,false,0); return 1;")
print(u'推送完成')
def report(self, nparts, errs):
print(u'元件 %d 导线 %d 旗 %d 端口 %d 文字 %d'
% (nparts, len(self.W), len(self.F), len(self.PT), len(self.TX)))
xs = self.crossings()
print(u'交叉点(不相连,供人工确认): %d' % len(xs))
for c in xs:
print(u' %s x %s @ (%d,%d)' % c)
if errs:
print(u'本地自检 %d 条问题:' % len(errs))
for e in errs:
print(u' ! ' + e)
raise SystemExit(1)
print(u'本地自检通过(短路 0 / 漏连 0 / 悬空 0 / 旗端口全在端点)')
def _on(p, a, b):
if a[0] == b[0]:
return p[0] == a[0] and min(a[1], b[1]) <= p[1] <= max(a[1], b[1])
return p[1] == a[1] and min(a[0], b[0]) <= p[0] <= max(a[0], b[0])
def two_pin(x, y, half=20):
u"""两脚件的期望:只比坐标集合(非极性件翻转只换脚号,无害)。"""
return {'?': set([(x, y + half), (x, y - half)])}
def by_rel(x, y, rel):
return dict((k, (x + dx, y + dy)) for k, (dx, dy) in rel.items())
def pins_of(exp):
u"""把 EXPECT 摊平成 netcheck 要的 {位号: {脚号: (x,y)}}。"""
out = {}
for d, want in exp.items():
if '?' in want:
a, b = sorted(want['?'])
out[d] = {'1': a, '2': b}
else:
out[d] = dict(want)
return out