# -*- coding: utf-8 -*- u"""R2 刀片 PCB 的设计规则(M4c-1)。 /c/Python314/python .claude/r2/pcb_rules.py # 只反查 + 打印,不下发 /c/Python314/python .claude/r2/pcb_rules.py --push # 下发规则 + 差分对 + 网络指派 /c/Python314/python .claude/r2/pcb_rules.py --audit # 读回审计 做法照抄 R1 的 `.claude/pcb1_rules.py`,**那一份的文件头是唯一的说明书**, 四个坑一个都没变: · `overwriteCurrentRuleConfiguration(x)` 要 config **本体**,不要 `{name,config}` 包装 · `getCurrentRuleConfiguration()` 是**嵌套形状**,`getRuleConfiguration(能力集)` 是**平形状** · 下发之后**别再去 GUI 换「配置」下拉框** —— 换一次整套重置,网络指派全部悬空 · `overwriteNetRules` / `overwriteNetByNetRules` 都是**整表覆盖**,必须先读回再合并 ## 和 R1 下板不同的四处 1. **刀片只有一路**,所以线宽表比 R1 短:没有 `FAN12V`,`PWR12V` 也从 2.0 降到 0.8 —— 刀片 12V 满载只有 **1.45 A**(§20.7 那颗 2 A 保险就是按它选的), R1 的 2.0 mm 是给整机 8~10 A 的干路用的,照抄过来只会把功率列挤死。 2. **两对差分**,不是一对:`USB_DP/DM`(金手指 → U7 ESD)和 `USB_DPJ/DMJ`(U7 → J4 尾插) 是被 U7 隔开的两段,各自成对。只建一对的话另一段退回默认 0.2 mm 净距规则, 在换层过孔处会误判 —— 和 R1 §10.1b 同一个理由。 3. **层角色**(§7.1):L1 功率+信号 / **Inner1(L2) = 完整地平面(不许割断)** / Inner2(L3) = 12V + VO 灌铜 / L4(Bottom) = 控制信号。D± 走 L1、以 L2 为参考。 4. **自动命名网只有两个**(R1 有十几个):SW 节点和 eFuse 输出段。 仍然**按「位号.引脚」当场反查、两端交叉校核**,不写死 `$1N137` 这种名字 —— 再导入一次变更它就可能改名,而写死的后果是**静默失效**(DRC 干干净净,规则悬空)。 """ import io import json import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) # ⚠ HERE 必须后插(在 sys.path 最前):`.claude` 下有同名的 R1 脚本。 sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 U = json.load(io.open(os.path.join(HERE, 'r2-uuids.json'), encoding='utf-8')) PCB = U['boards']['BLADE']['pcb']['uuid'] NETS_FILE = os.path.join(HERE, '_blade_pcb_nets.json') # ── 线宽规则(mm,外层 1 oz)──────────────────────────────────────────── # 载流靠灌铜保证、不靠 DRC;min 一律压到 0.15~0.3,否则 0402 焊盘(0.57 宽)扇不出去。 TRACK_RULES = { 'PWR12V': {'strokeWidthDefault': 0.8, 'strokeWidthMin': 0.25, 'strokeWidthMax': 4.0}, 'SWNODE': {'strokeWidthDefault': 2.0, 'strokeWidthMin': 0.30, 'strokeWidthMax': 4.0}, 'PWR4A': {'strokeWidthDefault': 1.5, 'strokeWidthMin': 0.30, 'strokeWidthMax': 6.0}, 'PWR5V': {'strokeWidthDefault': 0.6, 'strokeWidthMin': 0.20, 'strokeWidthMax': 3.0}, 'PWR1A': {'strokeWidthDefault': 0.4, 'strokeWidthMin': 0.15, 'strokeWidthMax': 2.0}, } # ── 有名字的网络 → 线宽规则 ──────────────────────────────────────────── NET_TRACK = { '+12V': 'PWR12V', # 金手指 B1/B2/B3 + A2/A3 → F1,1.45 A '+12V_F': 'PWR12V', # F1 → Buck 输入,同一路电流 'VO': 'PWR4A', # Buck 输出 → eFuse 输入 'VO_P': 'PWR4A', # 检流之后 → J5 电池排线,4 A '+5V': 'PWR5V', # 金手指 A19/A32 → U5 TPS22918 'VBUS': 'PWR5V', # U5 → J4 尾插,手机 VBUS '+3V3': 'PWR1A', # 刀片逻辑(INA226 + PCA9555)≈ 3 mA,0.4 纯余量 } # ── 自动命名网 → (规则, 探针, 校核端):两端查出来必须是同一个网 ───────── AUTO_TRACK = { # SW 节点:TPS54622(VQFN-14) 的 SW = pin 11/12 → L1 'SW': ('SWNODE', ('L1', '1'), ('U1', '11')), # eFuse 输出段:TPS25940A(WQFN-20) 的 OUT = pin 4~8 → Rs1 上游脚 'VO_F': ('PWR4A', ('Rs1', '2'), ('U2', '5')), } # ── 铺铜焊盘全连接(理由同 R1:不是怕十字花烧,是**铺完之后断面要量得准**)── PLANE_RULE = 'PLANE-DIRECT' PLANE_CONNECT_DIRECT = 1 PLANE_NETS = ['VO_P', 'VO', '+12V', '+12V_F'] PLANE_AUTO = ['VO_F'] # ── 差分对(mm)── 与 R1 同一组数:W 0.28 / S 0.18,以 L2 为参考 ──────── # ⚠ 2026-09-11 下限 0.20 → 0.15:USB_DPJ/DMJ 进 J4(BM24 0.35 节距、焊盘 0.18 宽)只能 0.18, # 0.20 的下限会让整对 USBPHONE 在 DRC 里刷「差分对线宽」(router.DP_WJ)。 DIFF = {'strokeWidthDefault': 0.28, 'strokeWidthMin': 0.15, 'strokeWidthMax': 0.60, 'differentailPairSpacingDefault': 0.18, 'differentailPairSpacingMin': 0.15, 'differentailPairLenTolerMax': 5.0} PAIR_RULE = 'USBPAIR' PAIR_CLEARANCE = 0.15 PAIRS = [('USBEDGE', 'USB_DP', 'USB_DM'), # 金手指 → U7 USBLC6 ('USBPHONE', 'USB_DPJ', 'USB_DMJ')] # U7 → J4 BM24 尾插 # ⚠ **0.15,不是 R1 的 0.2**。抬到 0.2 会刷出 23 条「贴片焊盘到贴片焊盘」—— # **全部在 `J4` BM24-50DS 内部**:0.4 mm 间距的尾插座,相邻焊盘实测净距 0.168~0.171, # 这是买来的连接器封装本身的几何,不是摆位问题,改摆位也修不掉。 # 0.15 仍远高于嘉立创四层板 0.09 的能力线,而板上其它最紧的一处是 # `K1~K4` 侧键盘的 0.276(Ø1.5 盘、实测间距 1.78),也过。 MIN_CLEARANCE = 0.15 OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2000));" % PCB) def call(body, timeout=300): r = run(OPEN + body, timeout=timeout) if not r.get('success'): print(u'失败: ' + json.dumps(r, ensure_ascii=False)[:600]) sys.exit(1) return r['result'] def dump_nets(): u"""重抓「位号.引脚 → 网名」。**每次导入变更之后必跑**。""" r = call("const out = {};" "for (const c of await eda.pcb_PrimitiveComponent.getAll()) {" " const ps = await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(" " c.getState_PrimitiveId());" " out[c.designator] = ps.map(p=>[p.padNumber, p.net]); }" "return out;") io.open(NETS_FILE, 'w', encoding='utf-8').write(json.dumps(r, ensure_ascii=False)) print(u'已写 _blade_pcb_nets.json:%d 件' % len(r)) return r def resolve_auto(nets=None): u"""按 (位号, 引脚) 反查自动网名并逐条断言。对不上就**不下发**。""" if nets is None: if not os.path.exists(NETS_FILE): nets = dump_nets() else: nets = json.load(io.open(NETS_FILE, encoding='utf-8')) pin = {} for ref, pads in nets.items(): for pn, net in pads: pin[(ref, str(pn))] = net out, bad, seen = {}, [], {} for key, (_rule, probe, cross) in sorted(AUTO_TRACK.items()): if probe not in pin: bad.append(u'%s: 板上没有 %s.%s' % (key, probe[0], probe[1])) continue if cross not in pin: bad.append(u'%s: 板上没有校核端 %s.%s' % (key, cross[0], cross[1])) continue net, other = pin[probe], pin[cross] if not net or not str(net).startswith('$'): bad.append(u'%s: %s.%s 上是 "%s",不是自动命名网 —— 探针写错了' % (key, probe[0], probe[1], net)) continue if net != other: bad.append(u'%s: %s.%s = %s,但校核端 %s.%s = %s —— 两端不是同一个网' % (key, probe[0], probe[1], net, cross[0], cross[1], other)) continue if net in seen: bad.append(u'%s 和 %s 查到同一个网 %s' % (key, seen[net], net)) continue seen[net] = key out[key] = net if bad: print(u' [!!] 自动命名网反查失败,**没有下发任何东西**:') for b in bad: print(u' · ' + b) print(u' 多半是 _blade_pcb_nets.json 过期了 —— 导入变更之后要重跑 --dump。') sys.exit(1) return out def net_track_full(auto): t = dict(NET_TRACK) for key, net in auto.items(): t[net] = AUTO_TRACK[key][0] return t def plane_nets_full(auto): return list(PLANE_NETS) + [auto[k] for k in PLANE_AUTO] CAP_JS = u""" const D = eda.pcb_Drc; const cur = await D.getCurrentRuleConfiguration(); const c = JSON.parse(JSON.stringify(cur.config)); const multi = (await D.getRuleConfiguration( 'JLCPCB Capability(Multiple Layers Board)')).config; const TRACKS = %s, DW = %s, MINC = %s, PAIRRULE = %s, PAIRC = %s; const PLANERULE = %s, PLANEMODE = %s; const setTrack = (e, mn, df, mx) => { for (const k of Object.keys(e.form.data)) Object.assign(e.form.data[k], {minValue: mn, defaultValue: df, maxValue: mx}); }; const T = c['Physics']['Track'], SS = c['Spacing']['Safe Spacing']; setTrack(T['copperThickness1oz'], 0.102, 0.254, 2.54); if (!T['copperThickness0.5oz']) { const e = JSON.parse(JSON.stringify(T['copperThickness1oz'])); e.editName = 'copperThickness0.5oz'; e.isSetDefault = false; setTrack(e, 0.089, 0.254, 2.54); T['copperThickness0.5oz'] = e; } Object.assign(c['Physics']['Via Size']['viaSize'].form, {viaOuterdiameterMin: 0.3, viaInnerdiameterMin: 0.2}); if (!SS['copperThicknessInner0.5oz']) { const e = JSON.parse(JSON.stringify(SS['copperThickness1oz'])); e.editName = 'copperThicknessInner0.5oz'; e.isSetDefault = false; const src = multi['Spacing']['Safe Spacing']['copperThicknessInner0.5oz'].table; for (const k of Object.keys(e.tables)) e.tables[k].content = JSON.parse(JSON.stringify(src)); SS['copperThicknessInner0.5oz'] = e; } for (const name of Object.keys(TRACKS)) { const f = TRACKS[name]; const e = JSON.parse(JSON.stringify(T['copperThickness1oz'])); e.editName = name; e.isSetDefault = false; e.unit = 'mm'; setTrack(e, f.strokeWidthMin, f.strokeWidthDefault, f.strokeWidthMax); T[name] = e; } const dp = c['Physics']['Differential Pair']['differentialPair'].form; for (const k of Object.keys(dp.strokeWidthTables.data)) Object.assign(dp.strokeWidthTables.data[k], {minValue: DW.strokeWidthMin, defaultValue: DW.strokeWidthDefault, maxValue: DW.strokeWidthMax}); for (const k of Object.keys(dp.diffPairSpacingTables.data)) Object.assign(dp.diffPairSpacingTables.data[k], {minValue: DW.differentailPairSpacingMin, defaultValue: DW.differentailPairSpacingDefault}); dp.differentailPairLenTolerMax = DW.differentailPairLenTolerMax; if (!SS[PAIRRULE]) { const e = JSON.parse(JSON.stringify(SS['copperThickness1oz'])); e.editName = PAIRRULE; e.isSetDefault = false; for (const tk of Object.keys(e.tables || {})) for (const row of (e.tables[tk].content || [])) for (let i = 0; i < row.length; i++) if (typeof row[i] === 'number') row[i] = PAIRC; SS[PAIRRULE] = e; } // 安全间距全表**归一到 MINC**:≤0.2 的格子(板厂能力值 0.09~0.127,以及上一版抬到 // 0.2 的那些)一律设成 MINC;0.254 / 0.3 那几格是孔/板边相关的,**不动**。 // ⚠ R1 那份只「抬高不降低」,所以把 MINC 从 0.2 改小是改不动的 —— 这里必须是赋值。 let bumped = 0; for (const key of Object.keys(SS)) if (key !== PAIRRULE) for (const tk of Object.keys(SS[key].tables || {})) for (const row of (SS[key].tables[tk].content || [])) for (let i = 0; i < row.length; i++) if (typeof row[i] === 'number' && row[i] <= 0.2001 && Math.abs(row[i] - MINC) > 1e-6) { row[i] = MINC; bumped++; } const CZ = c['Plane']['Copper Zone']; if (!CZ[PLANERULE]) { const e = JSON.parse(JSON.stringify(CZ['copperRegion'])); e.editName = PLANERULE; e.isSetDefault = false; CZ[PLANERULE] = e; } for (const m of ['singleLayerPadModel', 'multiLayerPadModel']) for (const k of Object.keys(CZ[PLANERULE].form[m].data)) CZ[PLANERULE].form[m].data[k].connectMode = PLANEMODE; const ok = await D.overwriteCurrentRuleConfiguration(c); return {ok: ok === true, bumped: bumped, name: cur.name, track: Object.keys(T), spacing: Object.keys(SS), plane: Object.keys(CZ)}; """ def push(): auto = resolve_auto() track = net_track_full(auto) planes = plane_nets_full(auto) print(u' 自动网反查: ' + ' '.join(u'%s=%s' % (k, v) for k, v in sorted(auto.items()))) r = call(CAP_JS % (json.dumps(TRACK_RULES), json.dumps(DIFF), MIN_CLEARANCE, json.dumps(PAIR_RULE), PAIR_CLEARANCE, json.dumps(PLANE_RULE), PLANE_CONNECT_DIRECT)) if not r.get('ok'): print(u' [!!] overwriteCurrentRuleConfiguration 返回非 true,没生效') sys.exit(1) print(u' 规则配置已下发:线宽条目 %s' % ' '.join(r['track'])) print(u' 安全间距抬高 %d 格;配置名 "%s"' % (r['bumped'], r['name'])) n = call(""" const P = %s; let k = 0; for (const d of await eda.pcb_Drc.getAllDifferentialPairs()) await eda.pcb_Drc.deleteDifferentialPair(d.name); for (const [name, p, m] of P) { if (await eda.pcb_Drc.createDifferentialPair(name, p, m)) k++; } return k; """ % json.dumps(PAIRS)) print(u' 差分对 %d / %d 对' % (n, len(PAIRS))) if n != len(PAIRS): print(u' [!!] 有对没建上 —— 网络名在这块板上不存在') n = call(""" const T = %s, PN = %s, PR = %s; const pset = new Set(PN); const rules = await eda.pcb_Drc.getNetRules(); let k = 0, p = 0; const seen = []; for (const r of rules) { if (r.type !== 'net') continue; if (T[r.name]) { r['Track'] = T[r.name]; k++; seen.push(r.name); } if (pset.has(r.name)) { r['Copper Zone'] = PR; p++; } } await eda.pcb_Drc.overwriteNetRules(rules); return {track: k, plane: p, seen: seen}; """ % (json.dumps(track, ensure_ascii=False), json.dumps(planes, ensure_ascii=False), json.dumps(PLANE_RULE))) print(u' 线宽指派 %d / %d 个网络' % (n['track'], len(track))) if n['track'] != len(track): print(u' [!!] 板上找不到: %s' % ' '.join(sorted(set(track) - set(n['seen'])))) print(u' 铺铜全连接指派 %d / %d(%s)' % (n['plane'], len(planes), ' '.join(planes))) n = call(""" const PAIRS = %s, RULE = %s; const cur = await eda.pcb_Drc.getNetByNetRules(); const old = (cur && cur['Safe Spacing']) ? cur['Safe Spacing'] : []; const key = e => [e.net1.name, e.net2.name].sort().join('|'); const want = new Set(PAIRS.map(([n, dp, dm]) => [dp, dm].sort().join('|'))); const list = []; let dropped = 0; for (const e of old) { if (e.rule === RULE && !want.has(key(e))) { dropped++; continue; } list.push(e); } const have = new Set(list.map(key)); let k = 0; for (const [name, dp, dm] of PAIRS) { const e = {net1: {type: 'net', name: dp}, net2: {type: 'net', name: dm}, rule: RULE, copperRuleName: null, planeRuleName: null}; if (have.has(key(e))) continue; list.push(e); k++; } const ok = await eda.pcb_Drc.overwriteNetByNetRules( {'Safe Spacing': list, 'Creepage Distance': (cur && cur['Creepage Distance']) || []}); const back = await eda.pcb_Drc.getNetByNetRules(); return {added: k, dropped: dropped, ok: ok === true, now: (back['Safe Spacing'] || []).length}; """ % (json.dumps(PAIRS), json.dumps(PAIR_RULE))) print(u' 对内间距:新增 %d / 清掉过期 %d,现共 %d 条(%s = %.2f mm)' % (n['added'], n['dropped'], n['now'], PAIR_RULE, PAIR_CLEARANCE)) call("await eda.pcb_Document.save(); return 1;") print(u' 已保存') print(u'') print(u'⚠ 别去 GUI 的 设计 → 设计规则… 里换「配置」下拉框:换一次整套重置,') print(u' %d 个网络的线宽指派立刻悬空,而 DRC 不会报错。' % len(track)) print(u'⚠ 再导入一次变更之后顺序是:--dump → --push,少跑 --dump 就是拿旧网名指派。') AUDIT_JS = u""" const c = await eda.pcb_Drc.getCurrentRuleConfiguration(); const rules = await eda.pcb_Drc.getNetRules(); const nbn = await eda.pcb_Drc.getNetByNetRules(); return { cfgName: c && c.name, layers: await eda.pcb_Layer.getTheNumberOfCopperLayers(), track: Object.keys(c.config.Physics['Track']), half: !!c.config.Physics['Track']['copperThickness0.5oz'], inner: !!c.config.Spacing['Safe Spacing']['copperThicknessInner0.5oz'], pairs: (await eda.pcb_Drc.getAllDifferentialPairs()).map(d => d.name).sort(), assigned: rules.filter(r => r['Track'] && r['Track'] !== 'default') .map(r => [r.name, r['Track']]).sort(), planeAssigned: rules.filter(r => r['Copper Zone'] && r['Copper Zone'] !== 'default') .map(r => [r.name, r['Copper Zone']]).sort(), nbn: ((nbn && nbn['Safe Spacing']) || []).length, }; """ def audit(): auto = resolve_auto() track = net_track_full(auto) planes = plane_nets_full(auto) r = call(AUDIT_JS) bad = [] def chk(cond, good, msg): print((u' [OK] ' if cond else u' [!!] ') + (good if cond else msg)) if not cond: bad.append(msg) print(u'──── 审计(配置 "%s")────' % r['cfgName']) chk(r['layers'] == 4, u'铜层数 4', u'铜层数是 %s,应为 4' % r['layers']) chk(r['half'] and r['inner'], u'内层 0.5 oz 的线宽/间距条目都在', u'内层 0.5 oz 条目缺失') miss = [k for k in TRACK_RULES if k not in r['track']] chk(not miss, u'五条线宽条目都在', u'缺线宽条目 %s' % miss) got = dict(r['assigned']) wrong = [(n, got.get(n), v) for n, v in sorted(track.items()) if got.get(n) != v] chk(not wrong, u'%d 个网络的线宽指派全对' % len(track), u'指派不对: %s' % wrong[:6]) chk(sorted(r['pairs']) == sorted([p[0] for p in PAIRS]), u'差分对 %s' % r['pairs'], u'差分对是 %s,应为 %s' % (r['pairs'], [p[0] for p in PAIRS])) chk(r['nbn'] == len(PAIRS), u'对内间距 %d 条' % r['nbn'], u'对内间距 %d 条,应为 %d' % (r['nbn'], len(PAIRS))) gotp = dict(r['planeAssigned']) missp = [n for n in planes if gotp.get(n) != PLANE_RULE] chk(not missp, u'铺铜全连接 %d 个网络' % len(planes), u'没指派上: %s' % missp) print(u' ' + (u'[OK] 全部通过' if not bad else u'[X] %d 条不过' % len(bad))) return bad if __name__ == '__main__': print(u'刀片 PCB=%s' % PCB) if '--dump' in sys.argv: dump_nets() if '--push' in sys.argv: push() audit() elif '--audit' in sys.argv: audit() elif '--dump' not in sys.argv: a = resolve_auto() t = net_track_full(a) print(u'自动网: ' + ' '.join(u'%s=%s' % (k, v) for k, v in sorted(a.items()))) for n, v in sorted(t.items(), key=lambda kv: (kv[1], kv[0])): print(u' %-10s -> %s' % (n, v))