2026-09-25 为焊接底板与旧版刀片(BLADE-PCB 3b9c4e39c8d164db): - silk_value.py:隐藏位号、在原位建自由丝印写器件值(Value,没有就用型号), --restore 按 _silk_value_restore.json 删字并恢复位号。⚠ 再出 Gerber / 下单前必须先 --restore。 - silk_value_plan.py:器件值摆放优化(不压焊盘 / 不重叠 / 不出框 / 避开开槽与金手指区; 软代价:压过孔、压他件本体、离本件远、缩字号)。EDA 默认字体实测字宽 ≈0.78×字高。 - print_value.py:两块板背面镜像 1:1 画在同一张 A4,红框标 1 脚; print/print_1to1.ps1 按 210×297 mm 直送打印机(不经 PDF 阅读器缩放)。 - 数据:_silk_value_geom/_extra(板上读回几何)、_dump/_plan(排位结果)。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
306 行
13 KiB
Python
306 行
13 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""器件值丝印(silk_value.py 建的那批自由文字)的摆放优化。
|
||
|
||
/c/Python314/python .claude/r2/silk_value_plan.py # 读板 + 算 + 出预览 PNG
|
||
/c/Python314/python .claude/r2/silk_value_plan.py --push # 再下发(pcb_PrimitiveString.modify)并存盘
|
||
|
||
硬约束:不压同面焊盘 / 通孔焊盘 / 安装孔,文字互不重叠,不出板框(留 EDGE),不进开槽、金手指区。
|
||
软代价:压过孔、压别的器件本体、离自己器件远、缩字号、离别的器件比离自己还近。
|
||
器件本体 = 该器件同面焊盘外包框(没有丝印框数据,焊盘框是能拿到的最好近似)。
|
||
"""
|
||
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))
|
||
from eda import run # noqa: E402
|
||
|
||
M = 0.0254 # mil → mm
|
||
RESTORE = os.path.join(HERE, '_silk_value_restore.json')
|
||
PLAN = os.path.join(HERE, '_silk_value_plan.json')
|
||
DUMP = os.path.join(HERE, '_silk_value_dump.json')
|
||
CHAR_W, EDGE, GAP = 0.78, 0.35, 0.15
|
||
FS = [(1.0, 0.0), (0.85, 1.5), (0.7, 4.0)] # (字高 mm, 代价)
|
||
FINGER_Y = {'BLADE': 10.6} # 刀片金手指区 y < 10.6 不放字
|
||
|
||
READ = u"""
|
||
await eda.dmt_EditorControl.openDocument('%s');
|
||
await new Promise(r=>setTimeout(r,3000));
|
||
const C = await eda.pcb_PrimitiveComponent.getAll()||[];
|
||
const comps=[];
|
||
for (const c of C) {
|
||
const pins = await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(c.primitiveId)||[];
|
||
comps.push({ref:c.designator, x:c.x, y:c.y, layer:c.layer, pads: pins.map(p=>[p.x,p.y,p.rotation,p.layer,p.pad])});
|
||
}
|
||
const P = (await eda.pcb_PrimitivePad.getAll()||[]).map(p=>[p.x,p.y,p.rotation,p.layer,p.pad]);
|
||
const V = (await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[v.x,v.y,v.diameter]);
|
||
const PL = (await eda.pcb_PrimitivePolyline.getAll()||[]).filter(l=>l.layer===11).map(l=>l.polygon.polygon);
|
||
const F = (await eda.pcb_PrimitiveFill.getAll()||[]).filter(f=>f.layer===12).map(f=>(f.complexPolygon||f.polygon).polygon);
|
||
const S = (await eda.pcb_PrimitiveString.getAll()||[]).map(s=>({id:s.primitiveId, layer:s.layer, text:s.text, x:s.x, y:s.y, rot:s.rotation, fs:s.fontSize, lw:s.lineWidth, align:s.alignMode}));
|
||
return {comps, pads:P, vias:V, outline:PL, cuts:F, strings:S};
|
||
"""
|
||
|
||
|
||
def nums(poly):
|
||
v = [t for t in poly if not isinstance(t, str)]
|
||
return list(zip(v[0::2], v[1::2]))
|
||
|
||
|
||
def pad_box(p):
|
||
x, y, rot, lay, shp = p
|
||
w, h = (shp[1], shp[2]) if shp and shp[0] in ('RECT', 'OVAL', 'ELLIPSE') else (None, None)
|
||
if w is None: # POLYGON 等:取点集外包
|
||
pts = nums(shp[1]) if shp and len(shp) > 1 and isinstance(shp[1], list) else []
|
||
if pts:
|
||
xs, ys = [q[0] for q in pts], [q[1] for q in pts]
|
||
w, h = max(xs) - min(xs), max(ys) - min(ys)
|
||
else:
|
||
w = h = 40
|
||
r = int(round((rot or 0) / 90.0)) % 2
|
||
if abs((rot or 0) / 90.0 - round((rot or 0) / 90.0)) > 0.05:
|
||
w = h = max(w, h)
|
||
if r:
|
||
w, h = h, w
|
||
return (x * M - w * M / 2, y * M - h * M / 2, x * M + w * M / 2, y * M + h * M / 2, lay)
|
||
|
||
|
||
def ov(a, b, g=0.0):
|
||
return not (a[2] + g <= b[0] or b[2] + g <= a[0] or a[3] + g <= b[1] or b[3] + g <= a[1])
|
||
|
||
|
||
def oarea(a, b):
|
||
return max(0, min(a[2], b[2]) - max(a[0], b[0])) * max(0, min(a[3], b[3]) - max(a[1], b[1]))
|
||
|
||
|
||
def inpoly(x, y, pts):
|
||
c = False
|
||
for i in range(len(pts)):
|
||
x1, y1 = pts[i]
|
||
x2, y2 = pts[i - 1]
|
||
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
||
c = not c
|
||
return c
|
||
|
||
|
||
def seg_dist(px, py, a, b):
|
||
(x1, y1), (x2, y2) = a, b
|
||
dx, dy = x2 - x1, y2 - y1
|
||
L = dx * dx + dy * dy
|
||
t = 0 if L == 0 else max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / L))
|
||
return math.hypot(px - x1 - t * dx, py - y1 - t * dy)
|
||
|
||
|
||
def tsize(text, fs, rot):
|
||
w, h = CHAR_W * fs * len(text) + 0.15, fs + 0.15
|
||
return (h, w) if rot % 180 else (w, h)
|
||
|
||
|
||
def box_dist(a, b):
|
||
dx = max(b[0] - a[2], a[0] - b[2], 0)
|
||
dy = max(b[1] - a[3], a[1] - b[3], 0)
|
||
return math.hypot(dx, dy)
|
||
|
||
|
||
def plan_board(name, d, mine):
|
||
outline = [(x * M, y * M) for x, y in nums(d['outline'][0])]
|
||
cuts = []
|
||
for f in d['cuts']:
|
||
q = nums(f)
|
||
if q:
|
||
cuts.append((min(a for a, _ in q) * M, min(b for _, b in q) * M, max(a for a, _ in q) * M, max(b for _, b in q) * M))
|
||
fy = FINGER_Y.get(name)
|
||
silk_side = {3: (1, 12), 4: (2, 12)}
|
||
padsB = {3: [], 4: []}
|
||
bodies = {}
|
||
for c in d['comps']:
|
||
bs = [pad_box(p) for p in c['pads']]
|
||
for b in bs:
|
||
for sl, ok in silk_side.items():
|
||
if b[4] in ok:
|
||
padsB[sl].append(b[:4])
|
||
side = [b for b in bs if b[4] in (c['layer'], 12)] or bs
|
||
if side:
|
||
bodies[c['ref']] = ((min(b[0] for b in side) - 0.15, min(b[1] for b in side) - 0.15,
|
||
max(b[2] for b in side) + 0.15, max(b[3] for b in side) + 0.15), 3 if c['layer'] == 1 else 4)
|
||
for p in d['pads']:
|
||
b = pad_box(p)
|
||
for sl in (3, 4):
|
||
padsB[sl].append(b[:4])
|
||
vias = [(x * M - dd * M / 2, y * M - dd * M / 2, x * M + dd * M / 2, y * M + dd * M / 2) for x, y, dd in d['vias']]
|
||
fixed = {3: [], 4: []}
|
||
for s in d['strings']:
|
||
if s['id'] in mine:
|
||
continue
|
||
w, h = tsize(s['text'], (s['fs'] or 40) * M, int(round(s['rot'] or 0)))
|
||
fixed[s['layer']].append((s['x'] * M - w / 2, s['y'] * M - h / 2, s['x'] * M + w / 2, s['y'] * M + h / 2))
|
||
|
||
def legal_static(bb, sl):
|
||
for (x, y) in ((bb[0], bb[1]), (bb[2], bb[1]), (bb[0], bb[3]), (bb[2], bb[3])):
|
||
if not inpoly(x, y, outline):
|
||
return False
|
||
cx, cy = (bb[0] + bb[2]) / 2, (bb[1] + bb[3]) / 2
|
||
r = min(seg_dist(x, y, outline[i], outline[i - 1]) for (x, y) in
|
||
((bb[0], bb[1]), (bb[2], bb[1]), (bb[0], bb[3]), (bb[2], bb[3]), (cx, bb[1]), (cx, bb[3]), (bb[0], cy), (bb[2], cy))
|
||
for i in range(len(outline)))
|
||
if r < EDGE:
|
||
return False
|
||
if fy and bb[1] < fy:
|
||
return False
|
||
if any(ov(bb, c, 0.3) for c in cuts):
|
||
return False
|
||
if any(ov(bb, p, GAP) for p in padsB[sl]):
|
||
return False
|
||
if any(ov(bb, f, GAP) for f in fixed[sl]):
|
||
return False
|
||
return True
|
||
|
||
texts = [s for s in d['strings'] if s['id'] in mine]
|
||
cands = {}
|
||
for s in texts:
|
||
ref = mine[s['id']]
|
||
body, sl = bodies.get(ref, (None, s['layer']))
|
||
if body is None:
|
||
continue
|
||
sl = s['layer']
|
||
bw, bh = body[2] - body[0], body[3] - body[1]
|
||
bcx, bcy = (body[0] + body[2]) / 2, (body[1] + body[3]) / 2
|
||
L = []
|
||
for fs, fc in FS:
|
||
for rot in (0, 90):
|
||
w, h = tsize(s['text'], fs, rot)
|
||
pos = []
|
||
for r in (0.0, 0.3, 0.7, 1.2, 1.8, 2.6, 3.5, 4.6, 5.8):
|
||
g = GAP + 0.05 + r
|
||
for k in (-1, -0.5, 0, 0.5, 1):
|
||
sx = bcx + k * max(0, (bw - w) / 2 + w * 0.4)
|
||
sy = bcy + k * max(0, (bh - h) / 2 + h * 0.4)
|
||
pos += [(sx, body[3] + g + h / 2, r), (sx, body[1] - g - h / 2, r),
|
||
(body[2] + g + w / 2, sy, r), (body[0] - g - w / 2, sy, r)]
|
||
for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)):
|
||
pos.append((bcx + dx * (bw / 2 + g + w / 2), bcy + dy * (bh / 2 + g + h / 2), r + 0.4))
|
||
inside = bw * bh > 2.5 * w * h and w < bw - 0.2 and h < bh - 0.2
|
||
if inside:
|
||
for ky in (0, 0.25, -0.25):
|
||
pos.append((bcx, bcy + ky * (bh - h), -1))
|
||
for x, y, r in pos:
|
||
bb = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)
|
||
if not legal_static(bb, sl):
|
||
continue
|
||
cost = fc + (0.3 if rot == 90 else 0) + (r * 1.2 if r >= 0 else 1.0)
|
||
cost += 3.0 * sum(1 for v in vias if ov(bb, v))
|
||
near_own = box_dist(bb, body)
|
||
for oref, (ob, osl) in bodies.items():
|
||
if oref == ref or osl != sl:
|
||
continue
|
||
a = oarea(bb, ob)
|
||
if a > 0:
|
||
cost += 4.0 + 6.0 * a / (w * h)
|
||
elif box_dist(bb, ob) + 0.05 < near_own:
|
||
cost += 2.5
|
||
L.append((cost, bb, x, y, rot, fs))
|
||
L.sort(key=lambda t: t[0])
|
||
cands[s['id']] = L[:400]
|
||
|
||
# 贪心:候选少的先排;然后逐个拿掉重排若干轮
|
||
order = sorted(cands, key=lambda i: len(cands[i]))
|
||
chosen = {}
|
||
|
||
def pick(i):
|
||
for c in cands[i]:
|
||
bb = c[1]
|
||
if any(j != i and ov(bb, chosen[j][1], GAP) for j in chosen):
|
||
continue
|
||
return c
|
||
return None
|
||
|
||
for i in order:
|
||
c = pick(i)
|
||
if c:
|
||
chosen[i] = c
|
||
for _ in range(4):
|
||
changed = 0
|
||
for i in order:
|
||
old = chosen.pop(i, None)
|
||
c = pick(i)
|
||
if c:
|
||
chosen[i] = c
|
||
if old is None or c[2:] != old[2:]:
|
||
changed += 1
|
||
elif old:
|
||
chosen[i] = old
|
||
if not changed:
|
||
break
|
||
fail = [mine[i] for i in cands if i not in chosen]
|
||
return chosen, fail, {'outline': outline, 'cuts': cuts, 'pads': padsB, 'bodies': bodies, 'vias': vias, 'fixed': fixed}
|
||
|
||
|
||
def preview(name, chosen, texts, geo, path):
|
||
import matplotlib
|
||
matplotlib.use('Agg')
|
||
import matplotlib.pyplot as plt
|
||
from matplotlib.patches import Rectangle, Polygon
|
||
fig, axs = plt.subplots(1, 2, figsize=(22, 11))
|
||
for ax, sl in zip(axs, (4, 3)):
|
||
ax.add_patch(Polygon(geo['outline'], fill=False, ec='k'))
|
||
for c in geo['cuts']:
|
||
ax.add_patch(Rectangle(c[:2], c[2] - c[0], c[3] - c[1], fc='0.7'))
|
||
for p in geo['pads'][sl]:
|
||
ax.add_patch(Rectangle(p[:2], p[2] - p[0], p[3] - p[1], fc='#c9a227', ec='none'))
|
||
for ref, (b, s) in geo['bodies'].items():
|
||
if s == sl:
|
||
ax.add_patch(Rectangle(b[:2], b[2] - b[0], b[3] - b[1], fill=False, ec='#1a3fa0', lw=0.4))
|
||
for i, c in chosen.items():
|
||
if texts[i]['layer'] != sl:
|
||
continue
|
||
bb = c[1]
|
||
ax.add_patch(Rectangle(bb[:2], bb[2] - bb[0], bb[3] - bb[1], fill=False, ec='r', lw=0.4))
|
||
ax.text(c[2], c[3], texts[i]['text'], fontsize=c[5] * 4.2, ha='center', va='center',
|
||
rotation=c[4], color='r')
|
||
ax.set_aspect('equal')
|
||
ax.autoscale_view()
|
||
ax.set_title('%s %s' % (name, 'BOTTOM' if sl == 4 else 'TOP'))
|
||
fig.savefig(path, dpi=130, bbox_inches='tight')
|
||
|
||
|
||
def main():
|
||
rec = json.load(io.open(RESTORE, encoding='utf-8'))
|
||
dump = {}
|
||
plans = {}
|
||
for name, R in rec.items():
|
||
r = run(READ % R['pcb'], timeout=900)
|
||
d = r.get('result')
|
||
assert d, json.dumps(r, ensure_ascii=False)[:500]
|
||
dump[name] = d
|
||
mine = {sid: ref for ref, sid in R['made']}
|
||
chosen, fail, geo = plan_board(name, d, mine)
|
||
texts = {s['id']: s for s in d['strings']}
|
||
fsz = {}
|
||
for c in chosen.values():
|
||
fsz[c[5]] = fsz.get(c[5], 0) + 1
|
||
print(u'%s:%d 条,排上 %d,无解 %d %s;字号分布 %s' % (name, len(mine), len(chosen), len(fail), fail, fsz))
|
||
plans[name] = {'pcb': R['pcb'], 'rows': [[i, c[2] / M, c[3] / M, c[4], c[5] / M] for i, c in chosen.items()]}
|
||
preview(name, chosen, texts, geo, os.path.join(HERE, '_silk_value_%s.png' % name.lower()))
|
||
io.open(DUMP, 'w', encoding='utf-8').write(json.dumps(dump, ensure_ascii=False))
|
||
io.open(PLAN, 'w', encoding='utf-8').write(json.dumps(plans, ensure_ascii=False))
|
||
if '--push' not in sys.argv:
|
||
print(u'(只算;预览 _silk_value_base.png / _silk_value_blade.png;加 --push 下发)')
|
||
return
|
||
for name, P in plans.items():
|
||
js = u"""
|
||
await eda.dmt_EditorControl.openDocument('%s');
|
||
await new Promise(r=>setTimeout(r,3000));
|
||
const R = %s; let n=0; const err=[];
|
||
for (const [id,x,y,rot,fs] of R) { try { const s = await eda.pcb_PrimitiveString.modify(id, {x:x, y:y, rotation:rot, fontSize:fs, alignMode:3}); if (s) n++; else err.push(id); } catch(e) { err.push(String(e)); } }
|
||
await eda.pcb_Document.save();
|
||
return {n, err};
|
||
""" % (P['pcb'], json.dumps(P['rows']))
|
||
print(name, run(js, timeout=900).get('result'))
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|