# -*- coding: utf-8 -*- u"""把 PCB 上全部焊盘 + 过孔 + 走线抓成 `.claude/pcb-copper.json`(布线用的障碍表)。 /c/Python314/python .claude/dump_pads.py 比 `refresh_cache.py` 轻:那个要开全部 12 页原理图,这个只读 PCB。 布局一动就重跑。 ## 存了什么(单位一律 mm) `pads` 每个焊盘:net / x / y / 形状 / 旋转 / layer / padType / 钻孔(含偏移与旋转) `vias` 过孔:net / x / y / 内外径 `lines` 走线:net / layer / 两端 / 线宽 ## 两个坑 1. **`pad` 数组里的 w/h 是「未旋转」的**,真实朝向要配合同一条记录里的 `rot`。 (`pcb_place.py` 的 `FP_PAD` 只收 `rot==0` 的实例,就是为了躲这一条。) 2. `layer` 1=Top 2=Bottom 15=Inner1-GND 16=Inner2-PWR,**12=多层(真正的通孔盘)**。 ⚠️ **「通孔盘」的判据是 `layer == 12`,不是 `hole` 非空,也不是 `padType`。** `padType` 这块板上全是 0(连 J24/J25 的排针针脚也是 0),而 `hole` 会**骗人**: 3. ⭐ **POLYGON 焊盘一律带一个假的 `hole`**(2026-09-06 第十九轮实测)。 板上 11 个 POLYGON 盘(U1~U5 的散热盘 pin15、U6/U7 的 pin17、J22 的四个壳体盘) 全部满足 **`hole = 0.600 × min(w, h)`** —— 一个派生出来的占位值, EasyEDA 自己渲染出来那几个盘是**实心的,没有孔**。 它们的 `layer` 都是 1(只在顶层),物理上也不可能有孔。 ⇒ 这里在落盘前把**单铜层焊盘(layer 1/2)的 `hole` 一律清成 None**, 并断言被清掉的那些确实符合 0.6 倍关系;不符合的会打印出来要人看一眼。 **不清的后果是静默的**:`pcb_fanout.one_net()` 拿 `not p['hole']` 当「这是贴片盘」用, 于是五路 Buck 的散热盘(同时是 PGND 和唯一的散热路径)**整批被滤掉、一个过孔都没打**, 而连接性 DRC 只会含糊地多出几条,没人会把它和「footprint 里一个用不上的字段」联系起来。 """ import io, json, os, sys HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from eda import run # 默认下板;载板用 `python .claude/dump_pads.py 942ed1560d81c1e7 pcb2-copper.json` PCB = sys.argv[1] if len(sys.argv) > 1 else '7e9415a16a909c51' MIL = 39.3701 OUT = os.path.join(HERE, sys.argv[2] if len(sys.argv) > 2 else 'pcb-copper.json') JS = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1200)); const M = 39.3701, q = v => Math.round(v / M * 1e4) / 1e4; // ⚠️ getAll() 的焊盘坐标是干净的 (4, 79.3),getAllPinsByPrimitiveId 却给 (4.0005, 79.2988) // —— 同一个焊盘差约 1 µm 的浮点噪声。连接键必须放宽,精确匹配只能对上 10 个。 const k2 = v => Math.round(v / M * 100) / 100; const pads = (await eda.pcb_PrimitivePad.getAll()).map(p => ({ net: p.net || '', x: q(p.x), y: q(p.y), rot: p.rotation || 0, layer: p.layer, num: String(p.padNumber), shape: Array.isArray(p.pad) ? [p.pad[0]].concat(p.pad.slice(1).map(v => typeof v === 'number' ? q(v) : v)) : null, hole: Array.isArray(p.hole) ? [p.hole[0]].concat(p.hole.slice(1).map(q)) : null, // ⚠️ 孔有自己的偏移和旋转,和焊盘的不一定一致。 // J22/J23 的 USB-C 壳体脚:铜箔 1.2 x 1.8,槽 0.8 x 1.5 —— 槽的长轴与铜箔垂直, // 槽会**伸出铜箔外** 0.15 mm。只建模铜箔的话,过孔会直接压到槽上(实测踩过)。 hox: q(p.holeOffsetX || 0), hoy: q(p.holeOffsetY || 0), hrot: p.holeRotation || 0, padType: p.padType, })); const vias = (await eda.pcb_PrimitiveVia.getAll()).map(v => ({ net: v.net || '', x: q(v.x), y: q(v.y), hole: q(v.holeDiameter), dia: q(v.diameter)})); const lines = (await eda.pcb_PrimitiveLine.getAll()).map(l => ({ net: l.net || '', layer: l.layer, x1: q(l.startX), y1: q(l.startY), x2: q(l.endX), y2: q(l.endY), w: q(l.lineWidth)})); // 位号 -> 引脚,用来给焊盘认领主人。 // ⚠️ **不要在 JS 里做 round-to-key 的匹配**:pad 的坐标是干净的、pin 的带 1 µm 噪声, // 两边各自 Math.round 到 0.01 时,落在 .xx5 上的值会分别进到不同的桶 —— // 2026-09-06 实测 846 个焊盘里 88 个认不到主人(J75 pin15/16 就在其中, // 害得「J7n 的 GND 脚整批跳过」漏掉了两个)。这里只回传原始坐标,Python 端按最近邻配。 const own = []; for (const c of await eda.pcb_PrimitiveComponent.getAll()) { const d = c.designator || c.name; for (const p of await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(c.primitiveId)) own.push({d, x: q(p.x), y: q(p.y), n: String(p.padNumber)}); } return {pads, vias, lines, own}; """ def main(): r = run(JS % PCB, timeout=600) if not r.get('success'): print(u'失败:', json.dumps(r, ensure_ascii=False)[:500]); sys.exit(1) d = r['result'] # ── POLYGON 焊盘的假 `hole`:清掉,见文件头第 3 条 ────────────────── # 判据是**物理的**:只落在一个铜层上的焊盘(layer 1/2)不可能有孔。 # 0.6 倍那条只用来核对「我理解的这件事没变」,不当判据用 —— # 哪天真出现一个带孔的单层盘,它会被打印出来而不是被悄悄放过。 ghost, odd = 0, [] for q in d['pads']: if q['hole'] and q['layer'] in (1, 2): sh = q['shape'] w = h = None if sh and sh[0] == 'POLYGON': pts = [v for v in sh[1] if isinstance(v, (int, float))] xs, ys = pts[0::2], pts[1::2] w, h = (max(xs) - min(xs)) / MIL, (max(ys) - min(ys)) / MIL elif sh and len(sh) >= 3 and isinstance(sh[1], (int, float)): w, h = sh[1], sh[2] r6 = (q['hole'][1] / min(w, h)) if (w and h and min(w, h)) else None if r6 is None or abs(r6 - 0.6) > 0.01: odd.append((q.get('des'), q['num'], q['hole'], r6)) q['hole'] = None ghost += 1 if ghost: print(u' 单铜层焊盘上的假钻孔已清 %d 个(POLYGON 盘的 0.6×min 占位值)' % ghost) for des, num, ho, r6 in odd: print(u' ⚠ %s.%s 的 hole=%s 不符合 0.6×min(比值 %s)—— 人看一眼是不是真孔' % (des, num, ho, ('%.3f' % r6) if r6 else '?')) # 引脚按「焊盘号 + 粗网格」分桶,再在 3 x 3 个桶里取最近的一个(容差 0.02 mm)。 # 分桶只是为了不做 846 x 3000 的全比较,边界值落哪个桶都无所谓 —— 邻桶也会查。 CELL = 0.5 buck = {} for o in d['own']: buck.setdefault((o['n'], int(o['x'] // CELL), int(o['y'] // CELL)), []).append(o) miss = 0 for p in d['pads']: gx, gy = int(p['x'] // CELL), int(p['y'] // CELL) best, bd = '', 0.02 for i in (-1, 0, 1): for j in (-1, 0, 1): for o in buck.get((p['num'], gx + i, gy + j), ()): r2 = ((o['x'] - p['x']) ** 2 + (o['y'] - p['y']) ** 2) ** 0.5 if r2 < bd: bd, best = r2, o['d'] p['des'] = best if not best: miss += 1 d.pop('own') io.open(OUT, 'w', encoding='utf-8').write( json.dumps(d, ensure_ascii=False, separators=(',', ':'))) nets = {} for p in d['pads']: nets[p['net']] = nets.get(p['net'], 0) + 1 tht = sum(1 for p in d['pads'] if p['hole']) print(u'焊盘 %d(其中通孔 %d / 贴片 %d),过孔 %d,走线 %d' % (len(d['pads']), tht, len(d['pads']) - tht, len(d['vias']), len(d['lines']))) print(u'认领到位号的焊盘 %d' % sum(1 for p in d['pads'] if p['des'])) print(u'网络 %d 个,最大的十个:' % len(nets)) for n, c in sorted(nets.items(), key=lambda kv: -kv[1])[:10]: print(u' %-10s %d 个焊盘' % (n or u'(无网络)', c)) print(u'-> %s' % OUT) if __name__ == '__main__': main()