# -*- 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