原理图/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
308 行
13 KiB
Python
308 行
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""下板 PCB1:按**实测铜**算全板每个网络的连通性,列出还欠哪些连接。
|
||
|
||
/c/Python314/python .claude/pcb1_netlist.py # 全板汇总
|
||
/c/Python314/python .claude/pcb1_netlist.py --net +5V # 只看这几个网络
|
||
/c/Python314/python .claude/pcb1_netlist.py --todo # 只列还没连通的,按「要拉几条」排序
|
||
/c/Python314/python .claude/pcb1_netlist.py --refresh # 重抓板上快照(改过板就要加)
|
||
/c/Python314/python .claude/pcb1_netlist.py --json out.json # 把待布线任务写成文件
|
||
|
||
## 为什么不看 DRC 的「连接性错误」
|
||
|
||
那一支是按**对象**列的(一个没连上的焊盘可能报好几条),还分页、还要 reload 才准,
|
||
数字(594)说明不了「还剩几条线要拉」。这里把焊盘 / 过孔 / 走线 / **实测铺铜**
|
||
当成图的节点,两两相触连边,数连通域 —— 一个网络 k 个连通域就是**还差 k−1 条线**。
|
||
|
||
## 铺铜怎么进这张图(关键)
|
||
|
||
铺铜不是一个矩形,它是一堆环、还带反焊盘的洞,而且**一个铺铜对象可能铺出好几块**。
|
||
所以照 `pcb1_pour.verify()` 的老办法:把同 (网络, 层) 的环光栅化成 0.25 mm 的布尔格、
|
||
连通域标号,**每一个连通域各算一个节点**。落脚判据和那里一致:
|
||
过孔 / 通孔焊盘落在格子里就算连上;贴片焊盘只在本层(Top/Bottom)算。
|
||
|
||
⚠️ 走线**不**算落进铺铜(异网铺铜会被挖开,同网铺铜和走线重叠才算),
|
||
这里按「走线中点/端点落在本网铺铜格子里」判,偏保守一点没关系 ——
|
||
保守的方向是「多报几条要拉的线」,不是「少报」。
|
||
"""
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
|
||
import pcb1_pour as PO # noqa: E402
|
||
|
||
G = PO.G
|
||
EPS = 1e-6
|
||
LAYNAME = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}
|
||
|
||
|
||
# ══════════════════ 几何:点/段/矩形 ══════════════════
|
||
def seg_pt(x0, y0, x1, y1, px, py):
|
||
dx, dy = x1 - x0, y1 - y0
|
||
d2 = dx * dx + dy * dy
|
||
t = 0.0 if d2 < EPS else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / d2))
|
||
return math.hypot(px - (x0 + t * dx), py - (y0 + t * dy))
|
||
|
||
|
||
def seg_seg(a0, a1, b0, b1):
|
||
def cross(o, a, b):
|
||
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
|
||
d1, d2 = cross(b0, b1, a0), cross(b0, b1, a1)
|
||
d3, d4 = cross(a0, a1, b0), cross(a0, a1, b1)
|
||
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
|
||
return 0.0
|
||
return min(seg_pt(a0[0], a0[1], a1[0], a1[1], b0[0], b0[1]),
|
||
seg_pt(a0[0], a0[1], a1[0], a1[1], b1[0], b1[1]),
|
||
seg_pt(b0[0], b0[1], b1[0], b1[1], a0[0], a0[1]),
|
||
seg_pt(b0[0], b0[1], b1[0], b1[1], a1[0], a1[1]))
|
||
|
||
|
||
def seg_rect(p0, p1, cx, cy, hw, hh):
|
||
u"""线段到轴对齐矩形的距离(重叠返回 0)。"""
|
||
corners = [(cx - hw, cy - hh), (cx + hw, cy - hh), (cx + hw, cy + hh), (cx - hw, cy + hh)]
|
||
for p in (p0, p1):
|
||
if abs(p[0] - cx) <= hw + EPS and abs(p[1] - cy) <= hh + EPS:
|
||
return 0.0
|
||
best = 1e9
|
||
for k in range(4):
|
||
best = min(best, seg_seg(p0, p1, corners[k], corners[(k + 1) % 4]))
|
||
return best
|
||
|
||
|
||
# ══════════════════ 读板 ══════════════════
|
||
def geom(refresh=False):
|
||
u"""返回 (pads, vias, lines),全部 mm。
|
||
|
||
pads: dict(net,x,y,hw,hh,th,ref,num,plane) plane = 元件所在层(1/2)
|
||
vias: dict(net,x,y,r)
|
||
lines: dict(net,lay,x0,y0,x1,y1,w)
|
||
"""
|
||
snap = PO.snapshot(refresh)
|
||
raw, _n, _nb = PO.load_pads(False)
|
||
pads = []
|
||
for net, x, y, hx, hy, th, tag, plane in raw:
|
||
ref, _, num = tag.rpartition('.')
|
||
pads.append(dict(net=net, x=x, y=y, hw=hx, hh=hy, th=th,
|
||
ref=ref, num=num, plane=plane, tag=tag))
|
||
vias = [dict(net=n or '', x=x, y=y, r=(od or 0.61) / 2.0)
|
||
for n, x, y, od in snap.get('via', ())]
|
||
lines = [dict(net=n or '', lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w or 0.2)
|
||
for n, lay, x0, y0, x1, y1, w in snap.get('line', ())]
|
||
return pads, vias, lines
|
||
|
||
|
||
def pour_grids(refresh=False):
|
||
u"""同 (网络, 层) 的实测铺铜 -> 一个 `Grid`(已经标好连通域)。"""
|
||
if refresh or not os.path.exists(PO.FILLS):
|
||
d = PO.call(PO.JS_FILLS, timeout=900)
|
||
io.open(PO.FILLS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
|
||
else:
|
||
d = json.load(io.open(PO.FILLS, encoding='utf-8'))
|
||
rings = {}
|
||
for e in d['pours']:
|
||
rings.setdefault((e['net'], e['layer']), []).extend(e['rings'])
|
||
return {k: PO._grid_from_cells(k[0], k[1], PO._cells_from_rings(v))
|
||
for k, v in rings.items()}
|
||
|
||
|
||
def layers_of_pad(p):
|
||
return (1, 2, 15, 16) if p['th'] else (p['plane'],)
|
||
|
||
|
||
# ══════════════════ 连通性 ══════════════════
|
||
class DSU(object):
|
||
def __init__(self, n):
|
||
self.p = list(range(n))
|
||
|
||
def find(self, i):
|
||
while self.p[i] != i:
|
||
self.p[i] = self.p[self.p[i]]
|
||
i = self.p[i]
|
||
return i
|
||
|
||
def union(self, a, b):
|
||
a, b = self.find(a), self.find(b)
|
||
if a != b:
|
||
self.p[a] = b
|
||
|
||
|
||
def analyze(net, pads, vias, lines, pgrids):
|
||
u"""返回 (items, groups)。items 是 (kind, layers:set, obj, label)。"""
|
||
items = []
|
||
for p in pads:
|
||
if p['net'] == net:
|
||
items.append(('pad', set(layers_of_pad(p)), p, p['tag']))
|
||
for v in vias:
|
||
if v['net'] == net:
|
||
items.append(('via', set((1, 2, 15, 16)), v, u'过孔(%.2f,%.2f)' % (v['x'], v['y'])))
|
||
for l in lines:
|
||
if l['net'] == net:
|
||
items.append(('line', set((l['lay'],)), l, u''))
|
||
# 铺铜:每个连通域一个节点
|
||
pour_nodes = []
|
||
for (pnet, lay), g in pgrids.items():
|
||
if pnet != net:
|
||
continue
|
||
for cid, npx in g.sizes.items():
|
||
pour_nodes.append((lay, g, cid))
|
||
items.append(('pour', set((lay,)), (lay, g, cid),
|
||
u'%s 铺铜 %.0fmm²' % (LAYNAME[lay], npx * G * G)))
|
||
n = len(items)
|
||
dsu = DSU(n)
|
||
|
||
def in_pour(node, x, y, hw, hh, lay):
|
||
u"""焊盘 / 过孔 / 线段的**外框**有没有碰到这一块铺铜。
|
||
|
||
⚠️ 不能只测中心那一格:EasyEDA 回读的环里,**同网通孔焊盘的位置是一个洞**
|
||
(铜直连时那个洞正好等于焊盘轮廓,焊盘自己把它填上)。按中心格判,
|
||
J22.2 / J22.3 / J24.1 这三个明明坐在整块 GND 平面里的通孔脚会被判成孤立
|
||
—— 2026-09-06 实测就是这么被骗过一次。
|
||
判据照 `pcb1_pour.Grid.hit()`:**外框覆盖到的格子里有一格属于这一块**。
|
||
"""
|
||
play, g, cid = node
|
||
if play != lay:
|
||
return False
|
||
i0, i1 = int((x - hw) / G), int((x + hw) / G) + 1
|
||
j0, j1 = int((y - hh) / G), int((y + hh) / G) + 1
|
||
for j in range(max(0, j0), min(g.ny, j1 + 1)):
|
||
for i in range(max(0, i0), min(g.nx, i1 + 1)):
|
||
if g.lab[j][i] == cid:
|
||
return True
|
||
return False
|
||
|
||
def touch(a, b):
|
||
ka, la, oa, _ = a
|
||
kb, lb, ob, _ = b
|
||
common = la & lb
|
||
if not common:
|
||
return False
|
||
if ka == 'pour' or kb == 'pour':
|
||
if ka != 'pour':
|
||
a, b = b, a
|
||
ka, la, oa, _x = a
|
||
kb, lb, ob, _y = b
|
||
lay = oa[0]
|
||
if kb == 'pad':
|
||
# 贴片焊盘只在自己那一层算落脚;通孔焊盘哪一层都算
|
||
if not (ob['th'] or ob['plane'] == lay):
|
||
return False
|
||
return in_pour(oa, ob['x'], ob['y'], ob['hw'], ob['hh'], lay)
|
||
if kb == 'via':
|
||
return in_pour(oa, ob['x'], ob['y'], ob['r'], ob['r'], lay)
|
||
if kb == 'line':
|
||
if ob['lay'] != lay:
|
||
return False
|
||
n_ = max(2, int(math.hypot(ob['x1'] - ob['x0'], ob['y1'] - ob['y0']) / 0.2) + 1)
|
||
h_ = ob['w'] / 2.0
|
||
for s in range(n_ + 1):
|
||
t = s / float(n_)
|
||
if in_pour(oa, ob['x0'] + t * (ob['x1'] - ob['x0']),
|
||
ob['y0'] + t * (ob['y1'] - ob['y0']), h_, h_, lay):
|
||
return True
|
||
return False
|
||
return False # pour-pour:同层不同连通域,本来就不通
|
||
if ka == 'line' and kb == 'line':
|
||
return seg_seg((oa['x0'], oa['y0']), (oa['x1'], oa['y1']),
|
||
(ob['x0'], ob['y0']), (ob['x1'], ob['y1'])) \
|
||
<= (oa['w'] + ob['w']) / 2 + EPS
|
||
if ka == 'line' and kb == 'pad':
|
||
return seg_rect((oa['x0'], oa['y0']), (oa['x1'], oa['y1']),
|
||
ob['x'], ob['y'], ob['hw'], ob['hh']) <= oa['w'] / 2 + EPS
|
||
if ka == 'line' and kb == 'via':
|
||
return seg_pt(oa['x0'], oa['y0'], oa['x1'], oa['y1'],
|
||
ob['x'], ob['y']) <= oa['w'] / 2 + ob['r'] + EPS
|
||
if ka == 'via' and kb == 'via':
|
||
return math.hypot(oa['x'] - ob['x'], oa['y'] - ob['y']) <= oa['r'] + ob['r'] + EPS
|
||
if ka == 'via' and kb == 'pad':
|
||
return abs(oa['x'] - ob['x']) <= ob['hw'] + oa['r'] + EPS \
|
||
and abs(oa['y'] - ob['y']) <= ob['hh'] + oa['r'] + EPS
|
||
if ka == 'pad' and kb == 'pad':
|
||
return (abs(oa['x'] - ob['x']) <= oa['hw'] + ob['hw'] + EPS
|
||
and abs(oa['y'] - ob['y']) <= oa['hh'] + ob['hh'] + EPS)
|
||
return touch(b, a)
|
||
|
||
for i in range(n):
|
||
for j in range(i + 1, n):
|
||
if touch(items[i], items[j]):
|
||
dsu.union(i, j)
|
||
groups = {}
|
||
for i in range(n):
|
||
groups.setdefault(dsu.find(i), []).append(i)
|
||
return items, list(groups.values())
|
||
|
||
|
||
def main():
|
||
argv = sys.argv[1:]
|
||
refresh = '--refresh' in argv
|
||
todo_only = '--todo' in argv
|
||
want = []
|
||
if '--net' in argv:
|
||
want = [a for a in argv[argv.index('--net') + 1:] if not a.startswith('--')]
|
||
outjson = None
|
||
if '--json' in argv:
|
||
outjson = argv[argv.index('--json') + 1]
|
||
|
||
pads, vias, lines = geom(refresh)
|
||
pgrids = pour_grids(refresh)
|
||
print(u'板上:%d 焊盘 / %d 过孔 / %d 走线 / %d 组铺铜'
|
||
% (len(pads), len(vias), len(lines), len(pgrids)))
|
||
|
||
nets = {}
|
||
for p in pads:
|
||
if p['net']:
|
||
nets.setdefault(p['net'], 0)
|
||
nets[p['net']] += 1
|
||
todo, done, single = [], 0, 0
|
||
tasks = []
|
||
for net in sorted(nets):
|
||
if want and net not in want:
|
||
continue
|
||
if nets[net] < 2:
|
||
single += 1
|
||
continue
|
||
items, groups = analyze(net, pads, vias, lines, pgrids)
|
||
# 只含走线的碎片不算「要连的点」(它一定挂在别的东西上,否则是垃圾线)
|
||
real = [g for g in groups
|
||
if any(items[i][0] != 'line' for i in g)]
|
||
if len(real) <= 1:
|
||
done += 1
|
||
continue
|
||
frags = []
|
||
for g in sorted(real, key=lambda g: -len(g)):
|
||
labs = [items[i][3] for i in g if items[i][3]]
|
||
pts = []
|
||
for i in g:
|
||
k, _l, o, _lab = items[i]
|
||
if k == 'pad':
|
||
pts.append((o['x'], o['y'], sorted(layers_of_pad(o)), o['tag']))
|
||
elif k == 'via':
|
||
pts.append((o['x'], o['y'], [1, 2, 15, 16], 'via'))
|
||
frags.append(dict(n=len(g), labels=labs, pts=pts))
|
||
todo.append((len(real) - 1, net, nets[net], frags))
|
||
tasks.append(dict(net=net, npads=nets[net], need=len(real) - 1, frags=frags))
|
||
|
||
todo.sort(key=lambda t: (-t[0], t[1]))
|
||
print(u'\n---- 还没连通的网络:%d 个,共欠 %d 条连接 ----'
|
||
% (len(todo), sum(t[0] for t in todo)))
|
||
for need, net, npads, frags in todo:
|
||
print(u' %-14s %3d 焊盘 %2d 个碎片 -> 还差 %2d 条' % (net, npads, len(frags), need))
|
||
if not todo_only:
|
||
for f in frags[:8]:
|
||
labs = f['labels'][:10]
|
||
print(u' [%2d] %s%s' % (f['n'], u' '.join(labs),
|
||
u' …' if len(f['labels']) > 10 else u''))
|
||
print(u'\n已连通 %d 个网络 / 单焊盘网络 %d 个' % (done, single))
|
||
if outjson:
|
||
io.open(outjson, 'w', encoding='utf-8').write(
|
||
json.dumps(tasks, ensure_ascii=False, indent=1))
|
||
print(u'待布线任务已写到 %s' % outjson)
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|