原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。 这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。 纳入: - 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本 - .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、 pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源) - 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾) - firmware/ 的 A1 骨架与 site/ 文档站源码 .gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、 site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。 .gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存, 不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
154 行
8.0 KiB
Python
154 行
8.0 KiB
Python
# -*- 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 f126ffb29c92f9b2 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()
|