文件
zhou yang和Claude Opus 5 efcba30a0c R2 底板改 4 层(D15):规则 / 几何 / 背面摆位 / 4 层布线与铺铜,已下发
- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base)
- 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base)
- 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、
  东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base)
- 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面
- 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、
  J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder
  开关(route_base、remote_route.ps1);剩 37 个网待手工补
- 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base);
  已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查)
- 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
2026-09-10 21:02:10 +08:00

314 行
14 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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
# ⚠️ 必须走 pcb1_pour 的 gi/gj —— 板框左下角是 (−30, −5),不是 (0, 0)。
# 2026-09-08 踩过:pcb1_pour 把 BW/BH 从假板框 160×125 改成真板框 220×135
# 并加了 BX0/BY0 之后,这里还在 `int(x / G)`,坐标整体偏了 30 mm ——
# 表现是「靠平面连通的网络(+12V/GND/+3V3/VOn_P)忽然全部碎成几十片」,
# 而 pcb1_pour --verify 回读的实测铺铜完全健康。**两边对不上时信实测那一边。**
# 更贵的是 pcb1_orphan_cu 走的就是这套栅格,据此误删过 12 个好过孔。
i0, i1 = PO.gi(x - hw), PO.gi(x + hw) + 1
j0, j1 = PO.gj(y - hh), PO.gj(y + hh) + 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())