# -*- 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()