- 规则:铜层 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
320 行
15 KiB
Python
320 行
15 KiB
Python
# -*- 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
|