R2:底板 / 刀片布局布线脚本、快照、截图与规划文档更新(含外壳、打印件、布线归档)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,330 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
u"""刀片**位号丝印**的重排与下发(照 `silk_base.py` 移植,四处刀片专属改动)。
|
||||
|
||||
/c/Python314/python .claude/r2/silk_blade.py # 只算 + 改前/改后对照
|
||||
/c/Python314/python .claude/r2/silk_blade.py --push # 下发 + 读回核对
|
||||
|
||||
## 判据(同底板)
|
||||
|
||||
位号文字框(字高 `fs`、字宽 ≈ 0.62×字高×字符数、`alignMode=3` 居中)不许:
|
||||
① 压同面焊盘 ② 压过孔 ③ 越板框 ④ 与别的位号重叠
|
||||
|
||||
## ⛔ 刀片与底板不同的四点
|
||||
|
||||
1. **板框是凸字形,不是矩形** —— 不能像底板那样用 `BW×BH` 判越界,
|
||||
必须对 `pcb_blade.outline_polygon()` 做「四角在多边形内 + 到轮廓 ≥ EDGE」。
|
||||
2. **金手指区 y < STEP_H(12) 整体禁止放丝印** —— `fp_edge` 写明指区不许有丝印,
|
||||
嘉立创的金手指工艺也要求指区无丝印。底板没有这条。
|
||||
3. **两条铣穿槽(BAT / TAIL)要避开** —— 丝印落在铣掉的开口上等于没有。
|
||||
4. `KEEP` 白名单只放 `J1`:它的位号是**手工**摆的,不能让算法再挪回去。
|
||||
坐标沿革:28.50 →(舌头收窄到 34.5,原位离板边只剩 0.75)→ 38.30 →
|
||||
(`NOTCH_D` 加深到 8.3 后框 y 7.81~8.95 与缺口重叠 0.49、x 又蹭到口部)→ **(37.84, 8.378)**。
|
||||
⚠ 现值对缺口左壁 38.70 只余 **0.151**(判据要 `GAP` 0.15),**再往 +x 挪就压缺口**。
|
||||
|
||||
## ⚠ `rotation` 归一化
|
||||
|
||||
快照实测取值含 `-2340.0000000000005 / -720 / -360 / -180 / -90` —— **有负值、有浮点误差**。
|
||||
必须 `((v mod 360) + 360) mod 360` 再 `round`。
|
||||
(`silk_base` 的注释只提到「900 = 180」这种正的大数,负值和浮点误差是刀片这边新遇到的。)
|
||||
|
||||
## ⛔⛔ 焊盘尺寸只能读缓存 `_blade_pads.json`,**别用实时 `getAll()` 取 width/height**
|
||||
|
||||
实测:`eda.pcb_PrimitivePad.getAll()` 回来的对象上 **`o.width` / `o.height` 一律是
|
||||
`undefined`**(362 个焊盘无一例外)。照这个算半宽会得到 0 ⇒ 焊盘退化成**点** ⇒
|
||||
「压焊盘」几乎永远判 0。本会话就是这么先得出一个假的「压焊盘 0」,
|
||||
直到改用缓存才看到真值 **68**。
|
||||
|
||||
⇒ 本脚本的 `audit()` / `plan()` 一律从 `_blade_pads.json` 取焊盘
|
||||
(9 元组 `[位号, 脚, x, y, w, h, 层, 网, 形状]`,与 `_base_pads.json` 同构)。
|
||||
⇒ 判据教训:**过滤条件只会让命中变少**。若「加了层过滤反而变多」,
|
||||
那一定是没过滤的那一版算错了,不是过滤写反了。
|
||||
|
||||
## ⚠ 本脚本**看不见封装内部的丝印**(判据边界,不是 bug)
|
||||
|
||||
`eda.pcb_PrimitiveString.getAll()` 在 **PCB 文档级**枚举**不到封装内部**画的字符串:
|
||||
刀片上那行 `NOT PCIe — PhoneRail blade` 明明在板上,文档级却返回 **0 条自由丝印**
|
||||
(六种图元全查过,只有 J1 的 `key=Footprint` / `key=Device` 两条属性命中关键字,
|
||||
位置都在 (0,0),不是它)。它是 `fp_edge.py` 用 `create(layer, x, y, text, …)`
|
||||
画在 **J1 封装里**的,`fp_edge` 自己的自检也是在**封装文档内**读的。
|
||||
|
||||
⇒ 所以 `audit()` 的「越框 / 重叠」只覆盖**文档级** Designator,
|
||||
封装内丝印(金手指区那行警告、缺口线)不在统计内。
|
||||
|
||||
⛔ **2026-09-12 深夜更正:「y≈8 是有意放行的」这句话曾经害了一次。**
|
||||
原来的理由是「`fp_edge` 的自检只禁止 y 0~5.6(指区本身)」——但那条断言判的是**指区**,
|
||||
而真正该判的是**缺口**:缺口是铣穿的,字压上去会被切掉。当时丝印在 7.20、缺口顶 7.45,
|
||||
**已经重叠 0.75,两边的自检却都不报**(指区断言看 5.6,缺口没人看)。
|
||||
`NOTCH_D` 加深到 8.3 后恶化到 1.00,是**截图**才看出来的。
|
||||
⇒ 已在 `fp_edge.selfcheck()` 补断言 `SILK_Y - SILK_SIZE/2 >= NOTCH_D`,
|
||||
并把那行字抬到 `SILK_Y = NOTCH_D + 0.6 = 8.90`(字带 8.40~9.40)。
|
||||
教训:**「某条断言没报」不等于「这件事被检查过」** —— 先确认判据判的是不是同一个东西。
|
||||
|
||||
⛔⛔ **由此引出第三个盲区:位号 ↔ 封装内丝印的互相重叠,没有任何判据覆盖。**
|
||||
本脚本的 `audit()` 只看**文档级** Designator(看不见封装内那行字),
|
||||
而 `fp_edge.selfcheck()` 只在**封装文档内**读(看不见文档级位号),
|
||||
DRC 也不管(丝印非铜)⇒ **两者撞在一起时三方全部沉默**。
|
||||
2026-09-12 深夜就这么撞了一次:把封装丝印从 7.20 抬到 8.90 避开缺口之后,
|
||||
字带变成 8.40~9.40,而 `J1` 位号框是 7.81~8.95 —— **重叠 x 1.417 / y 0.550**,
|
||||
而改动前两者间隙 0.11、本来不压。同样是**截图**才看出来的。
|
||||
⇒ 已把 `J1` 位号抬到 **(37.84, 10.13)**(框 9.56~10.70):
|
||||
离丝印带顶 9.40 留 **0.159**、离外层铜下沿 `pcb_pour.TOP_POUR_Y` 11.50 还有 0.798。
|
||||
⚠ 第一次取 10.12 时间隙是 **0.149**,差 0.001 没过 `GAP` —— 候选 y 按 0.1 步进取整
|
||||
低于真正需要的 `9.40 + GAP + fs/2 = 10.1215`。工程上无意义,但让判据干净地过,
|
||||
省得以后每次跑都看见一个 [X]。
|
||||
⚠ **动这两处中的任何一处,都要手算另一处**:`fp_edge.SILK_Y` 与 `KEEP` 里 J1 的坐标
|
||||
是一对**互相牵制但没有共同判据**的数。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.dirname(HERE))
|
||||
sys.path.insert(0, HERE)
|
||||
from eda import run # noqa: E402
|
||||
import pcb_blade as B # noqa: E402
|
||||
import pcb_place as PL # noqa: E402
|
||||
|
||||
MIL = 0.0254
|
||||
SNAP = os.path.join(HERE, '_blade_silk.json')
|
||||
PLAN = os.path.join(HERE, '_blade_silk_plan.json')
|
||||
PADS = os.path.join(HERE, '_blade_pads.json')
|
||||
CHAR_W, EDGE, GAP, MAXD = 0.62, 0.5, 0.15, 2.2
|
||||
KEEP = {'J1'}
|
||||
|
||||
OUTLINE = [(p[0], p[1]) for p in B.outline_polygon()]
|
||||
OSEGS = list(zip(OUTLINE, OUTLINE[1:]))
|
||||
SLOTS = [tuple(w) for w in B.flex_slots().values()] # (x0, y0, x1, y1)
|
||||
|
||||
READ = u"""
|
||||
await eda.dmt_EditorControl.openDocument('%s');
|
||||
await new Promise(r=>setTimeout(r,2500));
|
||||
const M = 0.0254, nrm = v => ((v %% 360) + 360) %% 360;
|
||||
const A = (await eda.pcb_PrimitiveAttribute.getAll()||[]).filter(a=>a.key==='Designator')
|
||||
.map(a=>({ref:a.value, lay:a.layer, x:a.x*M, y:a.y*M, rot:nrm(a.rotation),
|
||||
fs:a.fontSize*M, vis:a.valueVisible?1:0, id:a.primitiveId}));
|
||||
const C = (await eda.pcb_PrimitiveComponent.getAll()||[])
|
||||
.map(c=>({ref:c.designator, x:c.x*M, y:c.y*M, rot:nrm(c.rotation||0), lay:c.layer}));
|
||||
const V = (await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[v.x*M, v.y*M, String(v.net), v.diameter*M]);
|
||||
return {attrs:A, comps:C, vias:V};
|
||||
""" % B.PCB
|
||||
|
||||
|
||||
def snap():
|
||||
r = run(READ, timeout=600)
|
||||
d = r.get('result')
|
||||
assert d, json.dumps(r, ensure_ascii=False)[:400]
|
||||
io.open(SNAP, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
|
||||
return d
|
||||
|
||||
|
||||
def tbox(ref, x, y, rot, fs):
|
||||
h = fs
|
||||
w = CHAR_W * h * len(ref)
|
||||
if int(round(rot)) % 360 in (90, 270):
|
||||
w, h = h, w
|
||||
return (x - w / 2., y - h / 2., x + w / 2., y + h / 2.)
|
||||
|
||||
|
||||
def ov(p, q):
|
||||
return not (p[2] <= q[0] or q[2] <= p[0] or p[3] <= q[1] or q[3] <= p[1])
|
||||
|
||||
|
||||
def inside_poly(px, py):
|
||||
c = False
|
||||
for (ax, ay), (bx, by) in OSEGS:
|
||||
if (ay > py) != (by > py) and px < (bx - ax) * (py - ay) / (by - ay + 1e-18) + ax:
|
||||
c = not c
|
||||
return c
|
||||
|
||||
|
||||
def _dps(px, py, a, b):
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
dx, dy = bx - ax, by - ay
|
||||
L2 = dx * dx + dy * dy
|
||||
t = 0.0 if L2 == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L2))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
def d_box_outline(bb):
|
||||
u"""文字框到板框轮廓的最小距离(取框四角与轮廓各段的最近值,够用)。"""
|
||||
best = 1e9
|
||||
for px, py in ((bb[0], bb[1]), (bb[2], bb[1]), (bb[2], bb[3]), (bb[0], bb[3])):
|
||||
for a, b in OSEGS:
|
||||
d = _dps(px, py, a, b)
|
||||
if d < best:
|
||||
best = d
|
||||
return best
|
||||
|
||||
|
||||
def outside(bb):
|
||||
u"""刀片版:凸字形板框 + 金手指区 + 铣穿槽,任一不满足即判「不可用」。"""
|
||||
if bb[1] < B.STEP_H: # ② 金手指区整体禁止
|
||||
return True
|
||||
for px, py in ((bb[0], bb[1]), (bb[2], bb[1]), (bb[2], bb[3]), (bb[0], bb[3])):
|
||||
if not inside_poly(px, py): # ① 四角必须在板内
|
||||
return True
|
||||
if d_box_outline(bb) < EDGE: # ① 到轮廓留 EDGE
|
||||
return True
|
||||
for s in SLOTS: # ③ 避开铣穿槽
|
||||
if ov(bb, (s[0], s[1], s[2], s[3])):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _pads_by_layer(pads):
|
||||
return {3: [p for p in pads if p[6] in (1, 12)],
|
||||
4: [p for p in pads if p[6] in (2, 12)]}
|
||||
|
||||
|
||||
def audit(items, pads, vias):
|
||||
u"""items: [(ref, lay, bb)] ⇒ 四项体检计数。"""
|
||||
padsL = _pads_by_layer(pads)
|
||||
n_pad = n_via = n_out = 0
|
||||
for ref, lay, bb in items:
|
||||
if any(ov(bb, (p[2] - p[4] / 2., p[3] - p[5] / 2., p[2] + p[4] / 2., p[3] + p[5] / 2.))
|
||||
for p in padsL.get(lay, [])):
|
||||
n_pad += 1
|
||||
if any(ov(bb, (v[0] - v[3] / 2., v[1] - v[3] / 2., v[0] + v[3] / 2., v[1] + v[3] / 2.))
|
||||
for v in vias):
|
||||
n_via += 1
|
||||
if outside(bb):
|
||||
n_out += 1
|
||||
pairs = 0
|
||||
for i in range(len(items)):
|
||||
for j in range(i + 1, len(items)):
|
||||
if items[i][1] == items[j][1] and ov(items[i][2], items[j][2]):
|
||||
pairs += 1
|
||||
return {u'压焊盘': n_pad, u'压过孔': n_via, u'越框/入禁区': n_out, u'互相重叠(对)': pairs}
|
||||
|
||||
|
||||
def plan(d, pads, sz):
|
||||
comp = {c['ref']: c for c in d['comps']}
|
||||
V = d['vias']
|
||||
padsL = _pads_by_layer(pads)
|
||||
|
||||
def hit_pad(bb, lay):
|
||||
return any(ov(bb, (p[2] - p[4] / 2., p[3] - p[5] / 2., p[2] + p[4] / 2., p[3] + p[5] / 2.))
|
||||
for p in padsL.get(lay, []))
|
||||
|
||||
def hit_via(bb):
|
||||
return any(ov(bb, (v[0] - v[3] / 2., v[1] - v[3] / 2., v[0] + v[3] / 2., v[1] + v[3] / 2.))
|
||||
for v in V)
|
||||
|
||||
placed, out, stat = [], {}, {}
|
||||
for a in d['attrs']:
|
||||
if a['vis'] and a['ref'] in KEEP:
|
||||
placed.append((a['lay'], tbox(a['ref'], a['x'], a['y'], a['rot'], a['fs'])))
|
||||
stat[u'白名单保留'] = stat.get(u'白名单保留', 0) + 1
|
||||
small = [a for a in d['attrs'] if a['vis'] and a['ref'] not in KEEP]
|
||||
small.sort(key=lambda a: -(sz.get(a['ref'], (1, 1))[0] * sz.get(a['ref'], (1, 1))[1]))
|
||||
for a in small:
|
||||
ref, c, lay, fs = a['ref'], comp.get(a['ref']), a['lay'], a['fs']
|
||||
if not c:
|
||||
continue
|
||||
w, h = sz.get(ref, (1.0, 1.0))
|
||||
if int(round(c['rot'])) % 180:
|
||||
w, h = h, w
|
||||
best, level = None, None
|
||||
for lv in (0, 1, 2):
|
||||
r = 0.0
|
||||
while r <= MAXD and best is None:
|
||||
for ang in (90, 270, 0, 180, 45, 135, 225, 315):
|
||||
for rot in ((0, 90) if ang in (0, 180) else (0,)):
|
||||
tw, th = CHAR_W * fs * len(ref), fs
|
||||
if rot == 90:
|
||||
tw, th = th, tw
|
||||
ca, sa = math.cos(math.radians(ang)), math.sin(math.radians(ang))
|
||||
if abs(ca) <= 0.7 and abs(sa) <= 0.7:
|
||||
off = math.hypot(w / 2. + tw / 2., h / 2. + th / 2.) + GAP + r
|
||||
elif abs(ca) > 0.7:
|
||||
off = w / 2. + tw / 2. + GAP + r
|
||||
else:
|
||||
off = h / 2. + th / 2. + GAP + r
|
||||
x, y = c['x'] + off * ca, c['y'] + off * sa
|
||||
bb = tbox(ref, x, y, rot, fs)
|
||||
if outside(bb) or any(b[0] == lay and ov(bb, b[1]) for b in placed):
|
||||
continue
|
||||
if lv < 2 and hit_pad(bb, lay):
|
||||
continue
|
||||
if lv < 1 and hit_via(bb):
|
||||
continue
|
||||
best, level = (round(x, 4), round(y, 4), rot, bb), lv
|
||||
break
|
||||
if best:
|
||||
break
|
||||
r += 0.25
|
||||
if best:
|
||||
break
|
||||
if best:
|
||||
placed.append((lay, best[3]))
|
||||
out[ref] = {'x': best[0], 'y': best[1], 'rot': best[2], 'id': a['id'], 'lay': lay}
|
||||
key = [u'理想位', u'允许压过孔', u'允许压焊盘'][level]
|
||||
stat[key] = stat.get(key, 0) + 1
|
||||
else:
|
||||
stat[u'无解(保留原位)'] = stat.get(u'无解(保留原位)', 0) + 1
|
||||
return out, stat
|
||||
|
||||
|
||||
PUSH = u"""
|
||||
await eda.dmt_EditorControl.openDocument('%s');
|
||||
await new Promise(r=>setTimeout(r,1500));
|
||||
const R = %s;
|
||||
let n = 0;
|
||||
for (const [id, x, y, rot] of R) {
|
||||
try { await eda.pcb_PrimitiveAttribute.modify(id, {x: x, y: y, rotation: rot}); n++; } catch (e) {}
|
||||
}
|
||||
return n;
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
d = snap()
|
||||
pads = json.load(io.open(PADS, encoding='utf-8'))
|
||||
sz = PL.sizes()
|
||||
vis = [a for a in d['attrs'] if a['vis']]
|
||||
before = [(a['ref'], a['lay'], tbox(a['ref'], a['x'], a['y'], a['rot'], a['fs'])) for a in vis]
|
||||
print(u'板框 %d 点;金手指禁区 y<%.1f;铣穿槽 %d 条' % (len(OUTLINE), B.STEP_H, len(SLOTS)))
|
||||
print(u'位号 %d(可见 %d)\n' % (len(d['attrs']), len(vis)))
|
||||
print(u'改前体检: %s' % json.dumps(audit(before, pads, d['vias']), ensure_ascii=False))
|
||||
|
||||
p, stat = plan(d, pads, sz)
|
||||
io.open(PLAN, 'w', encoding='utf-8').write(json.dumps(p, ensure_ascii=False, indent=0))
|
||||
print(u'\n重排 %d 个:' % len(p))
|
||||
for k, v in sorted(stat.items(), key=lambda t: -t[1]):
|
||||
print(u' %-16s %d' % (k, v))
|
||||
|
||||
after = []
|
||||
for a in vis:
|
||||
q = p.get(a['ref'])
|
||||
if q:
|
||||
after.append((a['ref'], a['lay'], tbox(a['ref'], q['x'], q['y'], q['rot'], a['fs'])))
|
||||
else:
|
||||
after.append((a['ref'], a['lay'], tbox(a['ref'], a['x'], a['y'], a['rot'], a['fs'])))
|
||||
print(u'\n改后体检: %s' % json.dumps(audit(after, pads, d['vias']), ensure_ascii=False))
|
||||
|
||||
if '--push' not in sys.argv:
|
||||
print(u'\n(只算;加 --push 下发)')
|
||||
return
|
||||
|
||||
rows = [[v['id'], v['x'] / MIL, v['y'] / MIL, v['rot']] for v in p.values()]
|
||||
n = 0
|
||||
for k in range(0, len(rows), 60):
|
||||
r = run(PUSH % (B.PCB, json.dumps(rows[k:k + 60])), timeout=600)
|
||||
n += r.get('result') or 0
|
||||
run(u"await eda.pcb_Document.save(); return 1;", timeout=300)
|
||||
print(u' modify 成功 %d / %d' % (n, len(rows)))
|
||||
d2 = snap()
|
||||
now = {a['ref']: a for a in d2['attrs']}
|
||||
bad = [k for k, v in p.items()
|
||||
if k not in now or abs(now[k]['x'] - v['x']) > 0.02 or abs(now[k]['y'] - v['y']) > 0.02
|
||||
or int(round(now[k]['rot'])) % 360 != v['rot'] % 360]
|
||||
print(u'读回核对:%d 个对上,%d 个没对上 %s' % (len(p) - len(bad), len(bad), bad[:10]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
在新工单中引用
屏蔽一个用户