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