文件
phonerail/.claude/r2/fp_edge.py
T
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

374 行
19 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""自建「PCI-E x4 金手指」封装 —— R2 刀片 `J1`(`PCIE-X4-EDGE-64P`)的板边本身。
/c/Python314/python .claude/r2/fp_edge.py # 只打印几何 + 自检
/c/Python314/python .claude/r2/fp_edge.py --push # 建/重画封装 + 挂到器件上 + 读回校验
## 几何出处(**不是凭记忆写的**,M2d 的教训)
主源:KiCad 官方封装库 `Connector_PCBEdge.pretty/BUS_PCIexpress_x4.kicad_mod`
(其 descr 注明摘自 PCI-E CEM 的 add-in card edge-finger 图)。逐条交叉核对过:
| 项 | 本封装 | 交叉核对 |
|---|---:|---|
| 间距 | **1.00 mm** | CEM / 立创资料 / KiCad 一致 |
| 手指宽 | **0.70 mm**(净距 0.30) | 同上 |
| 手指长 | **4.30 mm** | 中文资料常写 4.0,KiCad 4.3,同量级 |
| 指尖到板边 | **1.30 mm** | multi-cb 给 PCIe 推荐 1.4;另一处给 0.6~1.5 窗口 ⇒ 1.3 在窗口内 |
| 缺口(key) | 11 与 12 号针之间,宽 **1.9**,深 **7.45**,口部两个 0.5×45° 导入角 | KiCad x1 / x4 两个封装完全一致 |
| A1 / B31 短指 | 长 **3.2**,指尖再退 **1.10** | CEM 的 PRSNT# 语义:**最后接触**,插到底才认在位 |
| A 排 / B 排 | A = **底层**,B = **顶层** | 同 KiCad |
⚠ **A1 / B31 必须是短指**:本方案的在位检测就是靠它俩(底板 A1=GND、B31=PRSNT{n}#,
刀片上短接)。做成等长指,刀片插一半就报「已插入」,而这一类错误功能测试测不出来。
## ⛔ T3 的「20° 斜边」在嘉立创下不了单
嘉立创《金手指斜边设计注意事项》写明**只有 30° 和 45°,并建议优先 30°**。
20° 是 PCI/PCIe 的行业标准角(德系板厂 multi-cb 给的就是 20°),但**这一版要在嘉立创打**,
⇒ 方案文档 §5.3 / §21.3 的「20°/1.0 mm」改成 **30°**,深度由板厂按指长配。
指尖留了 1.30 mm 裸边,30° 斜边吃不到镀层(这正是那 1.3 mm 的用途)。
嘉立创另外两条硬要求,本封装已满足 / 已记:
· **金手指处必须开通窗**(阻焊开窗)—— 焊盘自带开窗,且本封装不在指区放任何丝印。
· 斜边板单片长宽 ≥ 50 mm —— 刀片 98×76,过。
## 板框:缺口画在封装里,刀片外框在 M4 的 PCB 里画
封装在 **BOARD_OUTLINE(11)** 层只出**缺口那一段**(两条竖线 + 顶部半圆 + 口部两个导入角),
刀片 98×76 的外框在 PCB 文档里画,**底边要在缺口口部断开**,两段拼成一个闭合外框。
⇒ 缺口忘不掉(跟着封装走),而外框尺寸仍归 PCB。断点坐标从本模块的 `notch_mouth()` 取,
不要手抄。
## 坐标系
原点 = **金手指阵列 X 中心 × 卡的底边**。手指在 **+Y 方向**(板边 y=0,往板内长)。
⚠ 实测:EasyEDA PCB / 封装编辑器里 **y 向上为正**,所以 +Y 就是「板内」。截图为证(§M4a)。
`PIN_X[n]`(mm)是相对原点的 X;板边是 y=0。
"""
import json
import math
import os
import sys
sys.stdout.reconfigure(encoding='utf-8')
HERE = os.path.dirname(os.path.abspath(__file__))
# ⚠ `.claude` 下有和 r2 同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place)。
# 只插父目录的话,**后面任何一个 `import pcb_rules` 都会拿到 R1 那一份**,
# 而且不报错,只在调到 R2 专有的函数时才炸(2026-09-08 踩过)。
# 规矩:两行都要写,HERE 后插(在 sys.path 最前)。
sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, HERE)
from eda import run # noqa: E402
LIB_ME = '543725e8f0454017a5691e7e570aeef6' # 个人库
FP_NAME = 'EDGE-64P-P1.00_PCIE-X4-GF'
DEV_NAME = 'PCIE-X4-EDGE-64P'
MIL = 39.3700787401575
# ── 几何常量(mm,出处见表头)────────────────────────────────────────
PITCH = 1.0
FW = 0.7 # 手指宽
LONG_L, SHORT_L = 4.3, 3.2
EDGE_CLR = 1.3 # 指尖到卡底边
SHORT_BACK = LONG_L - SHORT_L # 短指指尖再退 1.1
NOTCH_W, NOTCH_D = 1.9, 7.45 # 缺口宽 / 深(自板边算)
LEAD_IN = 0.5 # 缺口口部 45° 导入角
N_PER_SIDE = 32
SHORT_PINS = ('A1', 'B31') # PRSNT#:最后接触
GAP_AFTER = 11 # 11 号针之后空两格(缺口)
ARC_SEG = 12 # 缺口顶部半圆用几段折线近似(见 outline_segments 的说明)
def _x_raw(n):
u"""第 n 号针在「以 1 号针为 0」的原始坐标(mm)。"""
return (n - 1) + (2.0 if n > GAP_AFTER else 0.0)
X_CENTER = (_x_raw(1) + _x_raw(N_PER_SIDE)) / 2.0 # 16.5
PIN_X = dict()
for _n in range(1, N_PER_SIDE + 1):
PIN_X['A%d' % _n] = PIN_X['B%d' % _n] = round(_x_raw(_n) - X_CENTER, 4)
NOTCH_CX = round((_x_raw(GAP_AFTER) + _x_raw(GAP_AFTER + 1)) / 2.0 - X_CENTER, 4) # -5.0
LAYER = {'A': 2, 'B': 1} # A 排 = 底层,B 排 = 顶层
def pad_geom(pin):
u"""(x, y_center, w, h) mm。**y 为正 = 往板内**(实测:PCB 编辑器里 y 向上为正,
见 §M4a 的截图判据;板边在 y=0,手指从板边往板内长)。"""
h = SHORT_L if pin in SHORT_PINS else LONG_L
tip = EDGE_CLR + (SHORT_BACK if pin in SHORT_PINS else 0.0)
return PIN_X[pin], (tip + h / 2.0), FW, h
def notch_mouth():
u"""缺口在板边(y=0)上的两个断点 x(mm)—— 刀片外框底边要在这两点断开。"""
half = NOTCH_W / 2.0 + LEAD_IN
return round(NOTCH_CX - half, 4), round(NOTCH_CX + half, 4)
def outline_segments():
u"""缺口的板框线段 [(x1,y1,x2,y2), ...] + 半圆 (cx, cy, r)。"""
hw = NOTCH_W / 2.0
ml, mr = notch_mouth()
yl = LEAD_IN # 导入角结束的高度
top = NOTCH_D - hw # 顶部圆弧的圆心 y
segs = [(ml, 0.0, NOTCH_CX - hw, yl), # 左导入角
(NOTCH_CX - hw, yl, NOTCH_CX - hw, top),
(mr, 0.0, NOTCH_CX + hw, yl), # 右导入角
(NOTCH_CX + hw, yl, NOTCH_CX + hw, top)]
# 顶部半圆用 ARC_SEG 段折线近似 —— **不用 pcb_PrimitiveArc**:
# 实测它 create() 返回成功、画面上也画出来了,但 getAll()/getAllPrimitiveId() 一律
# 返回空 ⇒ 既读不回来校验、也删不掉(重画时会越堆越多)。折线弦高误差
# r*(1-cos(90/ARC_SEG deg)) < 0.01 mm,机械上无所谓,换来「可读回、可幂等重画」。
pts = []
for k in range(ARC_SEG + 1):
a = math.pi * k / ARC_SEG # 0..pi,从右到左
pts.append((NOTCH_CX + hw * math.cos(a), top + hw * math.sin(a)))
for k in range(ARC_SEG):
segs.append((pts[k][0], pts[k][1], pts[k + 1][0], pts[k + 1][1]))
return segs
def selfcheck():
assert len(PIN_X) == 64, len(PIN_X)
assert PIN_X['A1'] == -16.5 and PIN_X['A32'] == 16.5, (PIN_X['A1'], PIN_X['A32'])
assert PIN_X['A11'] == -6.5 and PIN_X['A12'] == -3.5, u'11/12 之间必须空两格'
assert NOTCH_CX == -5.0, NOTCH_CX
ml, mr = notch_mouth()
assert round(mr - ml, 4) == 2.9, (ml, mr)
# 缺口的直段(不含口部导入角)到相邻两指的铜边距。口部的 45° 导入角在 y=0~+0.5,
# 而指尖在 y=+1.3 才开始,两者在 Y 上错开 0.8 ⇒ 只需校核直段的 X 间距。
hw = NOTCH_W / 2.0
cl = round(NOTCH_CX - hw - (PIN_X['A11'] + FW / 2.0), 4)
cr = round((PIN_X['A12'] - FW / 2.0) - (NOTCH_CX + hw), 4)
assert cl == cr == 0.2, u'缺口到 11/12 号指的铜边距变了: %s / %s' % (cl, cr)
assert EDGE_CLR - LEAD_IN >= 0.8, u'口部导入角会切进指尖'
for p in SHORT_PINS:
x, y, w, h = pad_geom(p)
# 远端(板内那头)必须和长指齐平,指尖(板边那头)必须比长指退 SHORT_BACK
assert abs((y + h / 2.0) - (EDGE_CLR + LONG_L)) < 1e-9, u'%s 的远端没对齐' % p
assert abs((y - h / 2.0) - (EDGE_CLR + SHORT_BACK)) < 1e-9, p
# 长指本身
x, y, w, h = pad_geom('B1')
assert abs((y - h / 2.0) - EDGE_CLR) < 1e-9, u'长指指尖不在 1.3'
# 缺口比手指深,否则插槽的防呆筋顶到板子
assert NOTCH_D > EDGE_CLR + LONG_L, u'缺口比手指还浅'
return True
def mil(v):
return round(v * MIL, 4)
def call(js, timeout=180):
r = run(js, timeout=timeout)
if not r.get('success'):
raise RuntimeError(json.dumps(r, ensure_ascii=False)[:800])
return r['result']
def _find_once(name=FP_NAME):
r = call("const r = await eda.lib_Footprint.search('%s', '%s', undefined, 20, 1);"
"return (r||[]).map(f => [f.uuid, f.name]);" % (name, LIB_ME))
for u, n in r:
if n == name:
return u
return None
def find_fp(retries=6):
import time
for _i in range(retries):
u = _find_once()
if u:
return u
time.sleep(2)
return None
def body_js():
u"""画封装的 JS 语句串。"""
js = []
# 64 个手指。助焊(锡膏)扩展给一个大负数,想让钢网在金手指区不开口。
# ⚠ 实测客户端把它**夹到 -1.37795 mil(-0.035 mm)**,关不死 ⇒ 光靠封装做不到,
# 「金手指区钢网不开口」必须在下单时另外说明(同 fp_keypad.py 那颗接触盘)。
paste = "{topPasteMask:%s,bottomPasteMask:%s}" % (mil(-FW / 2.0), mil(-FW / 2.0))
for row in ('A', 'B'):
for n in range(1, N_PER_SIDE + 1):
pin = '%s%d' % (row, n)
x, y, w, h = pad_geom(pin)
js.append("await eda.pcb_PrimitivePad.create(%d,'%s',%s,%s,0,['RECT',%s,%s,0],"
"undefined,null,undefined,undefined,undefined,undefined,undefined,"
"undefined,%s);"
% (LAYER[row], pin, mil(x), mil(y), mil(w), mil(h), paste))
# 板框层:缺口(含顶部折线半圆)
segs = outline_segments()
for x1, y1, x2, y2 in segs:
js.append("await eda.pcb_PrimitiveLine.create('',11,%s,%s,%s,%s,%s);"
% (mil(x1), mil(y1), mil(x2), mil(y2), mil(0.15)))
# 文档层:卡底边基准线(板框由 PCB 画,这里只给一条看得见的线)
ml, mr = notch_mouth()
js.append("await eda.pcb_PrimitiveLine.create('',13,%s,%s,%s,%s,%s);"
% (mil(-X_CENTER - 2), 0, mil(ml), 0, mil(0.1)))
js.append("await eda.pcb_PrimitiveLine.create('',13,%s,%s,%s,%s,%s);"
% (mil(mr), 0, mil(X_CENTER + 2), 0, mil(0.1)))
# 顶/底装配层:手指区外框,摆位时看得见
yb = EDGE_CLR + LONG_L
for lay in (9, 10):
for x1, y1, x2, y2 in [(-X_CENTER - FW / 2, yb, X_CENTER + FW / 2, yb),
(-X_CENTER - FW / 2, yb, -X_CENTER - FW / 2, 0),
(X_CENTER + FW / 2, yb, X_CENTER + FW / 2, 0)]:
js.append("await eda.pcb_PrimitiveLine.create('',%d,%s,%s,%s,%s,%s);"
% (lay, mil(x1), mil(y1), mil(x2), mil(y2), mil(0.1)))
# 丝印:只准放在手指区**上方**(指区里一点丝印都不能有)
# create(layer, x, y, text, fontFamily, fontSize, lineWidth, ...)
js.append("await eda.pcb_PrimitiveString.create(3,%s,%s,'NOT PCIe - PhoneRail blade',"
"undefined,%s,%s);"
% (mil(-X_CENTER), mil(yb + 1.6), mil(1.0), mil(0.15)))
return ''.join(js)
def push():
fp = find_fp(retries=2)
if not fp:
desc = (u'PCI-E x4 金手指(刀片板边,非采购件)。64 指 / 1.0mm / 0.7 宽 / 长指 4.3 / '
u'指尖离板边 1.3 / A1,B31 短指 3.2。缺口在板框层。'
u'斜边 30 度(嘉立创只有 30/45)、电镀硬金、板厚 1.6。')
call("await eda.lib_Footprint.create('%s','%s',undefined,%s);return 1;"
% (LIB_ME, FP_NAME, json.dumps(desc, ensure_ascii=False)))
fp = find_fp()
assert fp, u'封装建了但搜不到'
print(u'封装已建: %s %s' % (FP_NAME, fp))
else:
print(u'封装已存在: %s %s,重画' % (FP_NAME, fp))
r = call("await eda.lib_Footprint.openInEditor('%s','%s');"
"await new Promise(r=>setTimeout(r,2500));"
"for (const k of ['pcb_PrimitivePad','pcb_PrimitiveLine','pcb_PrimitiveArc',"
" 'pcb_PrimitiveString','pcb_PrimitiveRegion']) {"
" const o = await eda[k].getAllPrimitiveId(); if (o && o.length) await eda[k].delete(o); }"
"%s"
"await eda.pcb_Document.save();"
"return {pads:(await eda.pcb_PrimitivePad.getAllPrimitiveId()).length,"
" lines:(await eda.pcb_PrimitiveLine.getAllPrimitiveId()).length,"
" arcs:(await eda.pcb_PrimitiveArc.getAllPrimitiveId()||[]).length};"
% (fp, LIB_ME, body_js()), timeout=300)
print(u'已画: %s' % r)
return fp
def verify(fp=None):
fp = fp or find_fp()
assert fp, u'找不到封装'
v = call("await eda.lib_Footprint.openInEditor('%s','%s');"
"await new Promise(r=>setTimeout(r,2000));"
"return {pads:(await eda.pcb_PrimitivePad.getAll()||[]).map(p=>({n:p.padNumber,"
" x:p.x,y:p.y,layer:p.layer,pad:p.pad,exp:p.solderMaskAndPasteMaskExpansion})),"
" lines:(await eda.pcb_PrimitiveLine.getAll()||[]).map(l=>({layer:l.layer,"
" x:l.startX,y:l.startY,x2:l.endX,y2:l.endY})),"
" arcs:(await eda.pcb_PrimitiveArc.getAll()||[]).map(a=>({layer:a.layer,"
" x:a.startX,y:a.startY,x2:a.endX,y2:a.endY,ang:a.arcAngle})),"
" strs:(await eda.pcb_PrimitiveString.getAll()||[]).map(s=>({layer:s.layer,"
" x:s.x,y:s.y,t:s.text}))};"
% (fp, LIB_ME), timeout=180)
bad = []
got = {}
for p in v['pads']:
got[p['n']] = (round(p['x'] / MIL, 3), round(p['y'] / MIL, 3), p['layer'], p['pad'])
if len(got) != 64:
bad.append(u'焊盘数 %d != 64' % len(got))
for row in ('A', 'B'):
for n in range(1, N_PER_SIDE + 1):
pin = '%s%d' % (row, n)
if pin not in got:
bad.append(u'缺 %s' % pin)
continue
gx, gy, glay, gpad = got[pin]
ex, ey, ew, eh = pad_geom(pin)
if abs(gx - ex) > 0.01 or abs(gy - ey) > 0.01:
bad.append(u'%s 坐标 (%.3f,%.3f) != (%.3f,%.3f)' % (pin, gx, gy, ex, ey))
if glay != LAYER[row]:
bad.append(u'%s 在 layer %s,应为 %d' % (pin, glay, LAYER[row]))
gw, gh = round(gpad[1] / MIL, 3), round(gpad[2] / MIL, 3)
if abs(gw - ew) > 0.01 or abs(gh - eh) > 0.01:
bad.append(u'%s 尺寸 %.2fx%.2f != %.2fx%.2f' % (pin, gw, gh, ew, eh))
nout = len([l for l in v['lines'] if l['layer'] == 11])
want = 4 + ARC_SEG
if nout != want:
bad.append(u'板框层线段 %d != %d' % (nout, want))
# 板框缺口必须是一条首尾相接的开口折线:只有口部两个端点落在 y=0
ends = {}
for l in v['lines']:
if l['layer'] != 11:
continue
for kx, ky in ((l['x'], l['y']), (l['x2'], l['y2'])):
k = (round(kx / MIL, 3), round(ky / MIL, 3))
ends[k] = ends.get(k, 0) + 1
loose = sorted([k for k, c in ends.items() if c == 1])
ml, mr = notch_mouth()
if len(loose) != 2 or abs(loose[0][0] - ml) > 0.01 or abs(loose[1][0] - mr) > 0.01 or abs(loose[0][1]) > 0.01 or abs(loose[1][1]) > 0.01:
bad.append(u'缺口折线不闭合/端点不在板边: %s' % (loose,))
# 丝印一个字都不许落进指区(0 <= y <= EDGE_CLR+LONG_L 就是指区)。
# 封装名那一条是编辑器自带的属性串,不算丝印。
for st in v.get('strs', []):
sy = st['y'] / MIL
if st.get('t') in (FP_NAME, None):
continue
if st['layer'] in (3, 4) and -0.01 <= sy <= EDGE_CLR + LONG_L + 0.01:
bad.append(u'丝印 "%s" 落进金手指区 (y=%.2f)' % (st.get('t'), sy))
ys = [got[p][1] for p in got]
print(u'读回: %d 焊盘 / %d 线 / %d 弧 / %d 丝印串;y 范围 %.2f ~ %.2f'
% (len(got), len(v['lines']), len(v['arcs']), len(v.get('strs', [])),
min(ys), max(ys)))
for st in v.get('strs', []):
print(u' 串: layer=%s (%.2f,%.2f) "%s"'
% (st['layer'], st['x'] / MIL, st['y'] / MIL, st.get('t')))
if bad:
print(u'[X] 校验不过:')
for b in bad[:20]:
print(u' ' + b)
else:
print(u'[OK] 六十四指逐脚对上(坐标 / 层 / 尺寸),板框缺口 %d 段折线、端点落在板边' % want)
return bad
def bind():
u"""把封装挂到器件 PCIE-X4-EDGE-64P 上。"""
fp = find_fp()
dev = call("const r = await eda.lib_Device.search('%s','%s',undefined,undefined,20,1);"
"return (r||[]).map(d=>[d.uuid,d.name]);" % (DEV_NAME, LIB_ME))
du = None
for u, n in dev:
if n == DEV_NAME:
du = u
assert du, u'找不到器件 %s' % DEV_NAME
# ⚠ modify 的位置参数是 (uuid, lib, name, classification, association, description, property)
# —— 早先 dev_edge.py 把 association 写到了第 6 位(description),所以「改不动」。
call("await eda.lib_Device.modify('%s','%s',undefined,undefined,"
"{footprint:{uuid:'%s',libraryUuid:'%s'}});return 1;"
% (du, LIB_ME, fp, LIB_ME), timeout=120)
# 顺手把「进 BOM」关掉:property.addIntoBom 是 **boolean**,dev_edge.py 传的是字符串 'no'
call("await eda.lib_Device.modify('%s','%s',undefined,undefined,undefined,undefined,"
"{addIntoBom:false,addIntoPcb:true});return 1;" % (du, LIB_ME), timeout=120)
g = call("const d = await eda.lib_Device.get('%s','%s');"
"return {fp:(d.association&&d.association.footprint)||null,"
" sym:(d.association&&d.association.symbol)||null,"
" bom:(d.property&&d.property.addIntoBom)};" % (du, LIB_ME))
print(u'器件关联: %s' % json.dumps(g, ensure_ascii=False))
return g
if __name__ == '__main__':
selfcheck()
print(u'自检过。X 中心 %.2f,缺口中心 %.2f,口部断点 %s' % (X_CENTER, NOTCH_CX, notch_mouth()))
for p in ('A1', 'B1', 'A11', 'A12', 'B31', 'A32'):
print(u' %-4s x=%7.3f y=%7.3f %.2fx%.2f' % ((p,) + pad_geom(p)))
if '--push' in sys.argv:
fp = push()
verify(fp)
bind()
elif '--verify' in sys.argv:
verify()