文件
phonerail/.claude/r2/silk_blade.py
T

331 行
15 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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()