app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.48
7fce739
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
"""silkscreen-plan.py: the silkscreen planner both board lanes share (moved verbatim out of
fusion-project.py, 2026-10-15). It sees the board as plain geometry and answers where each label, contact
frame, leader and the board identity go; reading the board and landing the plan are each lane's own.
els [{name, package, value, x, y, bottom, pads: [box], body: box, rot, net, nameBox, ...}] mm, y up
outline (x0, y0, x1, y1) or None vias [(x, y, r)] texts [{text, layer 21|22|'wire21', box}]
Loaded ahead of the lane's own script (`adom-aiflow fusion ...`, `adom-aiflow altium ...`); RUN is the
run directory the lane defines.
"""
import json, math, os, re, sys
# How big a label's text is, the one place the planner measures it: a box TEXT_W x size per character
# plus TEXT_PADK x size plus TEXT_PAD wide, TEXT_H x size tall. The defaults are Fusion's vector text;
# a lane whose tool measures text differently sets its own (Altium's stroke font: see altium-project.py).
TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H = 0.95, 0.0, 0.1, 1.1
def tw_(t, size): return TEXT_W * size * len(t or '') + TEXT_PADK * size + TEXT_PAD
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 text_box(x, y, s, size, align='bottom-left'):
"""EAGLE vector text (ratio 15): about 0.85 x size per character, size tall."""
w, h = 0.85 * size * max(1, len(s)), size
x0 = x - w / 2 if 'center' in align and not align.endswith('left') and not align.endswith('right') or align == 'center' else (x - w if align.endswith('right') else x)
if align.startswith('top'): y0 = y - h
elif align.startswith('center'): y0 = y - h / 2
else: y0 = y
if align == 'center': x0 = x - w / 2
return (x0, y0, x0 + w, y0 + h)
def is_contact(e):
"""edge machine contacts and corner machine pins: both get a framed tag (name, and net when wired)"""
pk = (e['package'] or '').upper()
return ('MACHINECONTACT' in pk or 'MACHINEPIN' in pk) and not e['bottom']
def _ring_gaps(f, blockers, width=0.15, clear=0.15):
"""The frame f (stroke centre line) as straight pieces with gaps where it would cross a blocker (pad,
mask opening or via, inflated by the clearance), the KiCad lane's 'interrupted for clearance' frame.
Returns (pieces, kept fraction)."""
x0, y0, x1, y1 = f; h = width / 2 + clear
sides = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
pieces, total, kept = [], 0.0, 0.0
for (a, b) in sides:
horiz = abs(a[1] - b[1]) < 1e-9
lo, hi = (min(a[0], b[0]), max(a[0], b[0])) if horiz else (min(a[1], b[1]), max(a[1], b[1]))
c = a[1] if horiz else a[0]
cuts = []
for bb in blockers:
if horiz and bb[1] - h < c < bb[3] + h: cuts.append((bb[0] - h, bb[2] + h))
if not horiz and bb[0] - h < c < bb[2] + h: cuts.append((bb[1] - h, bb[3] + h))
cuts.sort(); cur = lo; total += hi - lo
for (u, v) in cuts:
if v <= cur or u >= hi: continue
if u > cur + 0.15: pieces.append(((cur, c), (u, c)) if horiz else ((c, cur), (c, u))); kept += u - cur
cur = max(cur, v)
if hi > cur + 0.15: pieces.append(((cur, c), (hi, c)) if horiz else ((c, cur), (c, hi))); kept += hi - cur
return pieces, (kept / total if total else 0)
def plan_frames(els, outline, vias, edge=0.12, width=0.15, gap=0.2, corner_orients=None):
"""Machine-contact groups, the way the KiCad lane draws them (aiflow-silkscreen, contactBorders): one
printed frame per contact enclosing the contact, its reference and its net, as large as the space
allows. A column of contacts shares rails (from just inside the board edge to past the widest label)
and a row height of the pitch less 0.2 mm, so the column reads as a set of tags. A frame is rejected
when any other part's copper, any via or the board edge comes inside it; that contact then gets its own
frame (its own width, shifted up or down), and only if nothing fits does it fall back to a frame round
the pad with the labels outside. Text steps down from 0.8 to 0.5 mm before a frame is given up.
Returns (groups, unresolved): a group is {'ref', 'frame': (x0, y0, x1, y1) stroke centre line,
'texts': [label dicts like plan_labels makes]}."""
if not outline: return [], []
cs = [e for e in els if is_contact(e) and e['pads']]
others = [b for e in els if not is_contact(e) for b in e['pads']] + [b for e in els if is_contact(e) for b in e['pads']]
viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
cx_b = (outline[0] + outline[2]) / 2
def pad_box(e): return (min(p[0] for p in e['pads']), min(p[1] for p in e['pads']), max(p[2] for p in e['pads']), max(p[3] for p in e['pads']))
def tw(t, size): return tw_(t, size)
def fits(e, fr):
"""the frame fr (outer bounds incl. stroke) holds nothing of anyone else and stays on the board"""
if fr[0] < outline[0] + edge or fr[2] > outline[2] - edge or fr[1] < outline[1] + edge or fr[3] > outline[3] - edge: return False
own = pad_box(e)
for b in others:
if b == own or (own[0] <= b[0] and b[2] <= own[2] and own[1] <= b[1] and b[3] <= own[3]): continue
if _ov(fr, b, 0.15): return False
for v in viab:
inside_own = own[0] <= (v[0] + v[2]) / 2 <= own[2] and own[1] <= (v[1] + v[3]) / 2 <= own[3]
if not inside_own and _ov(fr, v, 0.15): return False
for o in els:
if o is e or is_contact(o) or o['bottom'] or o['name'] in skip_bodies: continue
if _ov(fr, o['body'], 0.05): return False
if any(_ov(fr, ob, 0.2) for (ref_, ob) in placed_outers if ref_ != e['name']): return False
return True
skip_bodies = set(); placed_outers = []
def fit_or_gap(e, f, outer, texts):
"""(ok, pieces): the frame fits whole (pieces None), or breaks around small pads and vias that sit on its
line (none shut inside, at least 70% of it drawn, no label on them), the aiflow-silkscreen rule"""
if fits(e, outer): return True, None
small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]
small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]
small += [v for v in viab if _ov(outer, v, 0.3)]
if not small: return False, None
if any(f[0] + 0.1 < (b[0] + b[2]) / 2 < f[2] - 0.1 and f[1] + 0.1 < (b[1] + b[3]) / 2 < f[3] - 0.1 for b in small): return False, None
saved = list(others); saved_v = list(viab)
others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]
skip_bodies.update(o['name'] for o in small_owners)
ok_ = fits(e, outer)
others[:] = saved; viab[:] = saved_v; skip_bodies.clear()
pieces, frac = _ring_gaps(f, small)
if not ok_ or frac < 0.7 or any(_ov(t['box'], b, 0.1) for t in texts for b in small): return False, None
return True, pieces
def layout(e, size, rail=None, dy=0.0, pitch=None, mode='side'):
"""(frame, texts) for one contact. 'side': name over net inward of the pad. 'stack': name above the
pad and net below it, both centred on the pad, for a contact with a big part close beside it."""
pb = pad_box(e); left_col = (pb[0] + pb[2]) / 2 < cx_b; cy = (pb[1] + pb[3]) / 2 + dy
if mode == 'stack':
pcx = (pb[0] + pb[2]) / 2; th = size * TEXT_H
nw, vw = tw(e['name'], size), tw(e['net'] or '', size)
ny0 = pb[3] + 0.12; vy0 = pb[1] - 0.12 - th
texts = [{'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(pcx - nw / 2, 3), round(ny0, 3), round(pcx + nw / 2, 3), round(ny0 + th, 3)], 'origin': [round(pcx - nw / 2, 3), round(ny0, 3)]}]
if e['net']:
texts.append({'ref': e['name'], 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R0', 'box': [round(pcx - vw / 2, 3), round(vy0, 3), round(pcx + vw / 2, 3), round(vy0 + th, 3)], 'origin': [round(pcx - vw / 2, 3), round(vy0, 3)]})
span = max(nw, vw, pb[2] - pb[0]) / 2
if left_col: x0 = outline[0] + edge + width / 2; x1 = rail if rail else pcx + span + 0.15
else: x1 = outline[2] - edge - width / 2; x0 = rail if rail else pcx - span - 0.15
if left_col and x1 < pcx + span + 0.15 - 0.01: return None
if not left_col and x0 > pcx - span - 0.15 + 0.01: return None
if pitch:
y0, y1 = cy - (pitch - 0.2) / 2, cy + (pitch - 0.2) / 2
else:
y0, y1 = vy0 - 0.15, ny0 + th + 0.15
if y0 > vy0 - 0.08 or y1 < ny0 + th + 0.08: return None
fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)
return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts
nw, vw, th = tw(e['name'], size), tw(e['net'] or '', size), size * TEXT_H
need = max(nw, vw)
if left_col:
tx0 = pb[2] + gap; x0 = outline[0] + edge + width / 2; x1 = rail if rail else tx0 + need + gap
if x1 < tx0 + need + gap - 0.01: return None
else:
tx1 = pb[0] - gap; x1 = outline[2] - edge - width / 2; x0 = rail if rail else tx1 - need - gap
if x0 > tx1 - need - gap + 0.01: return None
half = (pitch - 0.2) / 2 if pitch else max(th + 0.1, (pb[3] - pb[1]) / 2 + 0.15)
y0, y1 = cy - half, cy + half
if y1 - y0 < 2 * th + 0.3 or y0 > pb[1] - 0.1 or y1 < pb[3] + 0.1: return None
texts = []
for (t, kind, ty) in ((e['name'], 'name', cy + 0.08), (e['net'], 'net', cy - 0.08 - th)):
if not t: continue
w = tw(t, size)
bx0 = tx0 if left_col else tx1 - w
texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})
fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)
return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts
groups, bad = [], []
# corner machine pins: one frame shape for all four, mirrored into each corner, so the corners read as a
# matched set. The frame runs from the board edges past the pin to hold its name over its net beside it
# (on the side toward the board's middle); the largest text that fits every corner wins.
corners = [e for e in cs if 'MACHINEPIN' in (e['package'] or '').upper()]
if corners:
cy_b = (outline[1] + outline[3]) / 2
placed_corners = False
for orient in (corner_orients or ('h', 'v', 'v1')):
if placed_corners: break
for size in _sz(0.8, 0.6, 0.5):
th = size * TEXT_H
one = orient == 'v1' # one line: the net (the reference when the pin is mechanical only)
lw = max((tw(e['net'] or e['name'], size) if one else max(tw(e['name'], size), tw(e['net'] or '', size))) for e in corners)
pw = max(pad_box(e)[2] - pad_box(e)[0] for e in corners); ph = max(pad_box(e)[3] - pad_box(e)[1] for e in corners)
# distance from the edge to the pin's far side, the same for all (the largest)
dx = max(min(pad_box(e)[2] - outline[0], outline[2] - pad_box(e)[0]) for e in corners)
dy = max(min(pad_box(e)[3] - outline[1], outline[3] - pad_box(e)[1]) for e in corners)
if orient == 'h': fw = dx + gap + lw + 0.2; fh = max(dy + 0.2, 2 * th + 0.5)
else: fw = max(dx + 0.25 - (edge + width / 2), lw + 0.4); fh = dy - (edge + width / 2) + gap + (1 if one else 2) * th + (0.25 if one else 0.3)
trial = []
for e in corners:
pb = pad_box(e); left = (pb[0] + pb[2]) / 2 < cx_b; low = (pb[1] + pb[3]) / 2 < cy_b
x0 = outline[0] + edge + width / 2 if left else outline[2] - edge - width / 2 - fw
y0 = outline[1] + edge + width / 2 if low else outline[3] - edge - width / 2 - fh
f = (round(x0, 3), round(y0, 3), round(x0 + fw, 3), round(y0 + fh, 3))
if orient == 'h':
tx = (pb[2] + gap) if left else (pb[0] - gap - lw)
tcy = (f[1] + f[3]) / 2
else:
tx = (pb[0] + pb[2]) / 2 - lw / 2
tcy = ((pb[3] + gap + th + 0.05) if low else (pb[1] - gap - th - 0.05)) if not one else ((pb[3] + gap + th / 2) if low else (pb[1] - gap - th / 2))
texts = []
rows_ = ([(e['net'], 'net', tcy - th / 2)] if e['net'] else [(e['name'], 'name', tcy - th / 2)]) if one else ((e['name'], 'name', tcy + 0.08), (e['net'], 'net', tcy - 0.08 - th))
for (t, kind, ty) in rows_:
if not t: continue
w = tw(t, size); bx0 = tx + (((lw - w) if not left else 0) if orient == 'h' else (lw - w) / 2)
texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})
outer = (f[0] - width / 2, f[1] - width / 2, f[2] + width / 2, f[3] + width / 2)
trial.append((e, f, outer, texts))
ok_all, gapped = True, {}
_dbg = os.environ.get('SILK_DEBUG')
for (e, f, outer, texts) in trial:
if fits(e, outer): continue
# a frame may break where it would cross a small pad or a via, if the break is short, the
# blocker is not shut inside, and nothing else is in the way (aiflow-silkscreen rule)
own = pad_box(e)
small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]
small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]
small += [v for v in viab if _ov(outer, v, 0.3)]
inside = [b for b in small if f[0] < (b[0] + b[2]) / 2 < f[2] and f[1] < (b[1] + b[3]) / 2 < f[3] and not (b[0] < f[0] or b[2] > f[2] or b[1] < f[1] or b[3] > f[3])]
if _dbg: print('corner', orient, size, e['name'], 'small', [round(v,2) for b in small for v in b][:8], 'inside', bool(inside), file=sys.stderr)
if inside: ok_all = False; break
# without the small blockers, would it fit?
saved = list(others); saved_v = list(viab)
others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]
skip_bodies.update(o['name'] for o in small_owners)
clear_rest = fits(e, outer)
others[:] = saved; viab[:] = saved_v; skip_bodies.clear()
pieces, frac = _ring_gaps(f, small)
if _dbg: print(' clear_rest', clear_rest, 'frac', round(frac, 2), 'textclash', any(_ov(tb['box'], b, 0.1) for tb in texts for b in small), file=sys.stderr)
if not clear_rest or frac < 0.7 or any(_ov(tb['box'], b, 0.1) for tb in texts for b in small): ok_all = False; break
gapped[e['name']] = pieces
if ok_all:
for (e, f, outer, texts) in trial:
groups.append({'ref': e['name'], 'frame': f, 'outer': outer, 'texts': texts, 'pieces': gapped.get(e['name'])})
placed_outers.append((e['name'], outer))
placed_corners = True; break
if not placed_corners:
bad += ['%s frame' % e['name'] for e in corners]
cs = [e for e in cs if e not in corners]
# columns: contacts on one vertical line share rails and a row height
cols = []
for e in cs:
pb = pad_box(e); x = (pb[0] + pb[2]) / 2
c = next((c for c in cols if abs(c['x'] - x) < 0.1), None)
if c: c['m'].append(e)
else: cols.append({'x': x, 'm': [e]})
for c in cols:
m = sorted(c['m'], key=lambda e: pad_box(e)[1])
ys = [(pad_box(e)[1] + pad_box(e)[3]) / 2 for e in m]
pitch = min([b - a for a, b in zip(ys, ys[1:]) if b - a > 0.1] or [None]) if len(m) > 1 else None
if pitch: pitch = min(pitch, 4.0)
left_col = c['x'] < cx_b
done = {}
# rows shrink together (never one alone) until the column clears its neighbours and the corner frames
for size in _sz(0.8, 0.6, 0.5):
for mode in ('side', 'stack'):
for pt in ([pitch - 0.3 * k for k in range(0, 6)] if pitch else [None]):
if pt is not None and pt < 2 * size * TEXT_H + 0.6: break
# the shared rail: past the widest label of the column at this size
lays = [layout(e, size, pitch=pt, mode=mode) for e in m]
if any(l is None for l in lays): continue
rail = max(l[0][2] for l in lays) if left_col else min(l[0][0] for l in lays)
shared = [layout(e, size, rail=rail, pitch=pt, mode=mode) for e in m]
if os.environ.get('SILK_DEBUG'): print('col', [e['name'] for e in m], size, mode, pt, [(e['name'], bool(l), bool(l) and fits(e, l[1]), l and [round(v, 2) for v in l[1]]) for e, l in zip(m, shared)], file=sys.stderr)
res = [fit_or_gap(e, l[0], l[1], l[2]) if l else (False, None) for e, l in zip(m, shared)]
if all(r_[0] for r_ in res):
for e, l, r_ in zip(m, shared, res): done[e['name']] = l + (r_[1],)
break
if len(done) == len(m): break
if len(done) == len(m): break
for e in m:
if e['name'] in done: continue
# its own frame: its own width, then shifted off whatever is in the way, then smaller rows
got = None
for size in _sz(0.8, 0.6, 0.5):
for mode in ('side', 'stack'):
for pt in ([pitch] if pitch else []) + [None]:
for dy in (0.0, -0.3, 0.3, -0.6, 0.6, -0.9, 0.9):
l = layout(e, size, dy=dy, pitch=pt, mode=mode)
if l:
ok_, pc = fit_or_gap(e, l[0], l[1], l[2])
if ok_: got = l + (pc,); break
if got: break
if got: break
if got: break
if got: done[e['name']] = got
else: bad.append('%s frame' % e['name'])
for e in m:
if e['name'] in done:
f, fr, texts, pc = done[e['name']]
groups.append({'ref': e['name'], 'frame': f, 'outer': fr, 'texts': texts, 'pieces': pc}); placed_outers.append((e['name'], fr))
# every edge contact's frame the same width (the eye scans the row of tags as one set): the widest that
# fits all of them, else the narrowest that still holds every label; each keeps its board-edge side
edge_g = [g for g in groups if 'MACHINEPIN' not in (next(e for e in els if e['name'] == g['ref'])['package'] or '').upper()]
if len(edge_g) > 1:
widths = sorted(set(round(g['frame'][2] - g['frame'][0], 3) for g in edge_g), reverse=True)
def refit(g, nf):
# the tag's texts, re-sized (0.8, 0.6, 0.5) and re-centred to sit inside the frame nf
out = []
for t in g['texts']:
for size in (t['size'],) + _sz(0.6, 0.5):
if size > t['size']: continue
w = tw_(t['text'], size); th = size * TEXT_H
if w <= (nf[2] - nf[0]) - 0.3:
cx_ = (t['box'][0] + t['box'][2]) / 2; cy_ = (t['box'][1] + t['box'][3]) / 2
cx_ = min(max(cx_, nf[0] + 0.15 + w / 2), nf[2] - 0.15 - w / 2)
out.append(dict(t, size=size, box=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3), round(cx_ + w / 2, 3), round(cy_ + th / 2, 3)], origin=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3)]))
break
else:
return None
return out
for W in widths:
trial = []
for g in edge_g:
f = g['frame']; e = next(x for x in els if x['name'] == g['ref'])
left = (f[0] + f[2]) / 2 < cx_b
nf = (f[0], f[1], round(f[0] + W, 3), f[3]) if left else (round(f[2] - W, 3), f[1], f[2], f[3])
tx = refit(g, nf)
if tx is None: trial = None; break
no = (nf[0] - width / 2, nf[1] - width / 2, nf[2] + width / 2, nf[3] + width / 2)
saved = list(placed_outers); placed_outers[:] = [po for po in placed_outers if po[0] != g['ref']]
ok_, pc = fit_or_gap(e, nf, no, tx)
ok_ = ok_ and not any(_ov(t['box'], b, 0.1) for t in tx for b in others if not (e['pads'] and b in e['pads']))
placed_outers[:] = saved
if not ok_: trial = None; break
trial.append((g, nf, no, tx, pc))
if trial:
for g, nf, no, tx, pc in trial:
g['frame'] = nf; g['outer'] = no; g['texts'] = tx; g['pieces'] = pc
break
return groups, bad
def min_text():
"""the fab profile's smallest silkscreen text (mm): 3rdPartyFab 0.5, JLCPCB 0.8 (Adom-tested), else 0.5;
AIFLOW_MIN_TEXT overrides"""
if os.environ.get('AIFLOW_MIN_TEXT'): return float(os.environ['AIFLOW_MIN_TEXT'])
try:
d = json.loads((RUN / 'run.json').read_text()); slug = str(((d.get('data') or d).get('fabRules') or {}).get('slug') or '')
except Exception:
slug = ''
return 0.8 if 'jlc' in slug.lower() else 0.5
def _sz(*sizes):
"""a text size ladder cut at the fab profile's minimum (never empty: the minimum itself at worst)"""
m = min_text(); out = tuple(x for x in sizes if x >= m - 1e-9)
return out or (m,)
def silk_value(v, ref):
"""the value as silkscreen prints it: 10uF 25V -> 10u, 100nF -> 100n, 15uH -> 15u, 68pF C0G -> 68p,
5.6k -> 5k6, 1M -> 1M; an IC or LED gets none (its reference and the BOM say it)"""
if not v or ref.startswith(('U', 'IC', 'Q', 'D', 'LED')): return None
m = re.match(r'^\s*([\d.]+)\s*([pnumkKMG\u00b5]?)\s*(?:F|H|R|ohm|\u03a9)?\b', v)
if not m: return v.split()[0][:6]
num, unit = m.group(1), m.group(2).replace('\u00b5', 'u')
if ref.startswith('R'):
unit = unit.replace('K', 'k') or 'R'
return num.replace('.', unit) if '.' in num else num + ('' if unit == 'R' else unit)
return num + unit
def _seg_boxes(p, q, w=0.15, step=0.1):
"""a straight silk line from p to q as small boxes along it (for collision checks)"""
n = max(1, int(math.hypot(q[0] - p[0], q[1] - p[1]) / step))
h = w / 2
return [(p[0] + (q[0] - p[0]) * i / n - h, p[1] + (q[1] - p[1]) * i / n - h, p[0] + (q[0] - p[0]) * i / n + h, p[1] + (q[1] - p[1]) * i / n + h) for i in range(n + 1)]
def _edge_point(b, toward):
"""the point on box b's edge nearest the point toward"""
return (min(max(toward[0], b[0]), b[2]), min(max(toward[1], b[1]), b[3]))
def plan_leaders(items, els, outline, vias, taken, edge=0.3, gap=0.15, reach=10.0, sizes=None):
"""Labels with no room beside their part, the way the KiCad lane's leaders do it (silkscreen-leaders):
the label goes where there is room, within reach of the part, and a thin silk line runs from the label
to the part's edge. items: [{'ref', 'texts': [(text, kind)], 'target': box, 'frame': bool}], a framed
item being a contact tag (name over net, framed). The label area, its frame and the line keep clear of
every pad (but the line may end on the target's own outline), via, label and frame; the nearest spot
whose line is clear wins, at 1.0 mm text where it fits, stepping down to the fab profile's smallest. Returns (placed, unresolved):
placed = [{'ref', 'texts': [label dicts], 'frame': box or None, 'leader': [(x, y), (x, y)]}]."""
pads = [(e, b) for e in els for b in e['pads']]
viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge)
bodies = [(e, e['body']) for e in els if not e['bottom']]
out, bad = [], []
for it in items:
tgt = it['target']; tcx, tcy = (tgt[0] + tgt[2]) / 2, (tgt[1] + tgt[3]) / 2
own = next((e for e in els if e['name'] == it['ref']), None)
best = None
sizes = sizes or _sz(1.0, 0.8, 0.6, 0.5)
# a tag that will not fit framed is tried again without its frame (still name over net, still led)
for framed in ([True, False] if it.get('frame') else [False]):
if best: break
for size in sizes:
th = size * TEXT_H
ws = [tw_(t, size) for t, _ in it['texts']]
w = max(ws); h = th * len(it['texts']) + 0.1 * (len(it['texts']) - 1)
pad_ = 0.25 if framed else 0.0
W, H = w + 2 * pad_, h + 2 * pad_
cands = []
r = 1.0
while r <= reach:
for k in range(24):
ang = 2 * math.pi * k / 24
cx, cy = tcx + r * math.cos(ang), tcy + r * math.sin(ang)
cands.append((r, cx - W / 2, cy - H / 2))
r += 0.25
for (r, x0, y0) in cands:
area = (x0, y0, x0 + W, y0 + H)
if not (area[0] >= inner[0] and area[1] >= inner[1] and area[2] <= inner[2] and area[3] <= inner[3]): continue
if any(_ov(area, b, 0.12) for (_, b) in pads) or any(_ov(area, v, 0.12) for v in viab): continue
if any(_ov(area, t, gap) for t in taken): continue
if any(_ov(area, b, 0.05) for (e, b) in bodies): continue
a_ = _edge_point(area, (tcx, tcy)); b_ = _edge_point(tgt, (a_[0], a_[1]))
if math.hypot(b_[0] - a_[0], b_[1] - a_[1]) < 0.3: continue
segs = _seg_boxes(a_, b_)[2:-2]
if any(_ov(sb, b, 0.08) for sb in segs for (e, b) in pads): continue
if any(_ov(sb, v, 0.08) for sb in segs for v in viab): continue
if any(_ov(sb, t, 0.05) for sb in segs for t in taken): continue
best = (size, area, pad_, a_, b_, ws, th, framed); break
if best: break
if not best: bad.append('%s leader' % it['ref']); continue
size, area, pad_, a_, b_, ws, th, framed = best
texts, y = [], area[3] - pad_ - th
for (t, kind), w in zip(it['texts'], ws):
x0 = area[0] + pad_ + (area[2] - area[0] - 2 * pad_ - w) / 2
texts.append({'ref': it['ref'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(x0, 3), round(y, 3), round(x0 + w, 3), round(y + th, 3)], 'origin': [round(x0, 3), round(y, 3)], 'leader': True})
y -= th + 0.1
taken.append(area); taken.extend(_seg_boxes(a_, b_))
out.append({'ref': it['ref'], 'texts': texts, 'frame': tuple(round(v, 3) for v in area) if framed else None,
'leader': [(round(a_[0], 3), round(a_[1], 3)), (round(b_[0], 3), round(b_[1], 3))]})
return out, bad
# Values on the silkscreen are the designer's choice (issue #134), never a requirement:
# "none" no values, "where-room" (default) a value goes beside its reference where it fits and is
# skipped (and listed) where it does not, "required" a value that does not fit is left unresolved.
VALUES_MODE = 'where-room'
SKIPPED_VALUES = []
def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), skip=()):
"""Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and
the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;
a smaller size before giving up; whatever cannot fit is named, never dropped silently."""
pads = [b for e in els for b in e['pads']]
bodies = [e['body'] for e in els]
viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None
taken, out, unresolved = list(fixed), [], []
relax = [False]
def free(bx, own=None):
if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]): return False
if any(_ov(bx, p, 0.1) for p in pads): return False
if not relax[0] and any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False
if any(_ov(bx, v, 0.05) for v in viab): return False
if any(_ov(bx, t, gap) for t in taken): return False
return True
order = sorted([e for e in els if not e['bottom']], key=lambda e: (not is_contact(e), -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1])))) # the contacts and their nets first: they are what a probe or a person looks for
for e in order:
if e['name'] in skip: continue # labelled inside its contact frame
b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2
texts = [(e['name'], 'name')]
if is_contact(e) and e.get('net'): texts.append((e['net'], 'net'))
# every component's value, small, right next to its reference (aiflow-silkscreen: the reference is
# larger, the value mandatory and visibly smaller); test pads and machine parts carry none
pk_ = (e['package'] or '').upper()
sv = silk_value(e['value'], e['name']) if VALUES_MODE != 'none' and not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None
if sv: texts.append((sv, 'value'))
# the reference and its value as one unit first (aiflow-silkscreen: the pair is atomic): the value
# inline after the reference, or stacked under it, the unit placed where a name would go
vt = next((t for t, k in texts if k == 'value'), None)
if vt and not is_contact(e):
vs = min_text(); pair = None
for size in _sz(0.8, 0.6):
nw, nh = tw_(e['name'], size), size * TEXT_H
vw, vh = tw_(vt, vs), vs * TEXT_H
for layout_ in ('inline', 'stack'):
w = nw + 0.12 + vw if layout_ == 'inline' else max(nw, vw)
h = nh if layout_ == 'inline' else nh + 0.08 + vh
cands = []
for d in (gap, gap + 0.4, gap + 0.9):
for f in (0.0, -0.5, 0.5):
cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]
for (x0, y0) in cands:
if layout_ == 'inline':
nb = (x0, y0, x0 + nw, y0 + nh); vb = (x0 + nw + 0.12, y0, x0 + nw + 0.12 + vw, y0 + vh)
else:
nb = (x0, y0 + vh + 0.08, x0 + nw, y0 + h); vb = (x0, y0, x0 + vw, y0 + vh)
if free(nb, own=b) and free(vb, own=b):
pair = (size, nb, vb); break
if pair: break
if pair: break
if pair:
size, nb, vb = pair
taken += [nb, vb]
out.append({'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in nb], 'origin': [round(nb[0], 3), round(nb[1], 3)]})
out.append({'ref': e['name'], 'kind': 'value', 'text': vt, 'size': vs, 'rot': 'R0', 'box': [round(v, 3) for v in vb], 'origin': [round(vb[0], 3), round(vb[1], 3)]})
continue
for txt, kind in texts:
done = False
if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):
continue # printed by an earlier pass: it stays, and it is already an obstacle
# part names keep the library's 0.8 mm (a size change by name does not land in Fusion); the new
# net labels may step down. EAGLE's vector font measured at about 0.95 x size per character.
# near first, then small: every size is tried close to the part before any farther ring, since a
# label beside its part at 0.6 mm reads better than one at 0.8 mm on the far side of a neighbour
if kind == 'value':
# beside its reference: right after it, under it, over it, before it, at the fab minimum
nb = next((L['box'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), None)
vs = min_text(); vh = vs * TEXT_H; vw = tw_(txt, vs)
if nb:
nrot = next((L['rot'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), 'R0')
if nrot == 'R0':
cands_v = [(nb[2] + 0.1, nb[1], 'R0'), (nb[0], nb[1] - 0.08 - vh, 'R0'), (nb[0], nb[3] + 0.08, 'R0'), (nb[0] - 0.1 - vw, nb[1], 'R0')]
else:
cands_v = [(nb[2] + 0.08, nb[1], 'R90'), (nb[0] - 0.08 - vh, nb[1], 'R90'), (nb[0], nb[3] + 0.1, 'R90'), (nb[0], nb[1] - 0.1 - vw, 'R90')]
for (x0, y0, rr) in cands_v:
w, h = (vw, vh) if rr == 'R0' else (vh, vw)
bx = (x0, y0, x0 + w, y0 + h)
if free(bx, own=b):
taken.append(bx)
out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})
done = True; break
if not done:
# anywhere close round the part, at the same small size
for d in (gap, gap + 0.4, gap + 0.9):
for rr in ('R0', 'R90'):
w, h = (vw, vh) if rr == 'R0' else (vh, vw)
for (x0, y0) in ((cx - w / 2, b[3] + d), (cx - w / 2, b[1] - d - h), (b[2] + d, cy - h / 2), (b[0] - d - w, cy - h / 2)):
bx = (x0, y0, x0 + w, y0 + h)
if free(bx, own=b):
taken.append(bx)
out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})
done = True; break
if done: break
if done: break
if not done:
if VALUES_MODE == 'required': unresolved.append('%s value' % e['name'])
else: SKIPPED_VALUES.append(e['name'])
continue
for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))):
if done: break # a contact's net stays close: far away it reads as a neighbour's
for size in (_sz(0.8, 0.6) if kind == 'name' else _sz(0.8, 0.6, 0.5)):
for rot in ('R0', 'R90'):
# R0 first (reads left to right); R90 (reads bottom to top) where a part has no room beside it
tw = tw_(txt, size); th = size * TEXT_H
w, h = (tw, th) if rot == 'R0' else (th, tw)
cands = []
for d in ring_:
for f in (0.0, -0.5, 0.5, -1.0, 1.0):
sx, sy = f * w, f * h
cands += [(cx - w / 2 + sx, b[3] + d), (cx - w / 2 + sx, b[1] - d - h), (b[2] + d, cy - h / 2 + sy), (b[0] - d - w, cy - h / 2 + sy)]
cands += [(b[2] + d, b[3] + d), (b[0] - d - w, b[3] + d), (b[2] + d, b[1] - d - h), (b[0] - d - w, b[1] - d - h)]
if kind == 'net' and outline:
# the edge side first: the side of the contact nearest the outline
dists = {'L': cx - outline[0], 'R': outline[2] - cx, 'B': cy - outline[1], 'T': outline[3] - cy}
side = min(dists, key=dists.get)
# nearest the contact first, with the edge side favoured: a label far from its contact
# (on the far side of another part) reads as someone else's
def k_(p):
mx, my = p[0] + w / 2, p[1] + h / 2
on_edge = {'L': mx < cx, 'R': mx > cx, 'B': my < cy, 'T': my > cy}[side]
return ((mx - cx) ** 2 + (my - cy) ** 2) ** 0.5 - (1.0 if on_edge else 0.0)
cands.sort(key=k_)
for (x0, y0) in cands:
bx = (x0, y0, x0 + w, y0 + h)
if free(bx, own=b):
taken.append(bx)
out.append({'ref': e['name'], 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})
done = True; break
if done: break
if done: break
if not done: unresolved.append('%s %s' % (e['name'], kind))
if not relax[0] and unresolved:
# last resort for what is left: a label may sit over another part's outline (not its copper)
relax[0] = True
left = [u for u in unresolved]; unresolved.clear()
for u in left:
ref, kind = u.rsplit(' ', 1)
e = next(x for x in order if x['name'] == ref); b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2
txt = e['name'] if kind == 'name' else (e.get('net') if kind == 'net' else silk_value(e['value'], e['name'])); done = False
if not txt: continue
for size in ((min_text(),) if kind == 'value' else _sz(0.8, 0.6)):
for rot in ('R0', 'R90'):
tw = tw_(txt, size); th = size * TEXT_H
w, h = (tw, th) if rot == 'R0' else (th, tw)
cands = []
for d in (gap, gap + 0.4, gap + 0.9, gap + 1.5):
for f in (0.0, -0.5, 0.5, -1.0, 1.0):
cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]
cands.sort(key=lambda p: ((p[0] + w / 2 - cx) ** 2 + (p[1] + h / 2 - cy) ** 2))
for (x0, y0) in cands:
bx = (x0, y0, x0 + w, y0 + h)
if free(bx, own=b):
taken.append(bx); out.append({'ref': ref, 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]}); done = True; break
if done: break
if done: break
if not done: unresolved.append(u)
return out, unresolved, taken
def identity_spot(els, outline, vias, lines):
"""A clear place on the bottom face for the board's identity (bottom pads, through-holes and vias are
the obstacles there): (x, y, size), the largest size that fits nearest the centre."""
if not outline: return None
obs = [b for e in els for b in e['pads'] if e['bottom']] + [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
obs += [b for e in els for b in e['pads'] if (b[2] - b[0]) > 0.9 and abs((b[2] - b[0]) - (b[3] - b[1])) < 0.05] # round through-hole lands
cx0, cy0 = (outline[0] + outline[2]) / 2, (outline[1] + outline[3]) / 2
for size in (1.0, 0.8, 0.7, 0.6):
w = max(tw_(l, size) - TEXT_PAD for l in lines); h = len(lines) * size * 1.5
best = None
for i in range(-30, 31):
for j in range(-30, 31):
x, y = cx0 + i * 0.4, cy0 + j * 0.4
bx = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)
if bx[0] < outline[0] + 0.5 or bx[2] > outline[2] - 0.5 or bx[1] < outline[1] + 0.5 or bx[3] > outline[3] - 0.5: continue
if any(_ov(bx, o, 0.1) for o in obs): continue
d = abs(i) + abs(j)
if best is None or d < best[0]: best = (d, x, y)
if best: return (best[1], best[2], size)
return None
def plan_board(els, outline, vias, texts):
"""The whole plan from the board's geometry, as `fusion silkscreen` makes it (a copy of its planning
half; the lanes differ only in how they read the board and land the plan): contact groups with their
frames, the labels, the leaders and what stayed unresolved."""
tries = [plan_frames(els, outline, vias, corner_orients=(o,)) for o in ('h', 'v', 'v1')]
groups, frame_bad = min(tries, key=lambda t: len(t[1]))
grouped = {g['ref'] for g in groups}
frames = [(g['ref'], g['frame']) for g in groups]
fixed = [t['box'] for t in texts if t['layer'] == 21] + [g['outer'] for g in groups]
labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=grouped)
labels = [t for g in groups for t in g['texts']] + labels
unresolved = frame_bad + unresolved
# what has no room beside its part goes further out with a leader line (contacts as framed tags)
taken_now = list(fixed) + [L['box'] for L in labels]
items = []
for u in list(unresolved):
ref, kind = u.rsplit(' ', 1)
e = next((x for x in els if x['name'] == ref), None)
if not e: continue
if kind == 'frame':
items.append({'ref': ref, 'texts': [(e['name'], 'name'), (e['net'], 'net')] if e.get('net') else [(e['name'], 'name')], 'target': e['body'], 'frame': True})
elif kind == 'name':
items.append({'ref': ref, 'texts': [(e['name'], 'name')], 'target': e['body'], 'frame': False})
framed_refs = {it['ref'] for it in items if it['frame']}
taken_now = list(fixed) + [L['box'] for L in labels]
leaders, leader_bad = plan_leaders(items, els, outline, vias, taken_now)
led = {l['ref'] for l in leaders}
labels = [L for L in labels if not (L['ref'] in framed_refs and L['ref'] in led)] # a placed tag replaces its outside labels
done_refs = {(l['ref']) for l in leaders}
unresolved = [u for u in unresolved if u.rsplit(' ', 1)[0] not in done_refs] + leader_bad
labels = labels + [t for l in leaders for t in l['texts']]
# a contact that got neither a frame nor a led tag: its name straight above its pad and its net straight
# below (or the other way round), each centred on the pad, so the pair reads as that contact's and no
# other's. Only when both fit; otherwise its earlier spots stay.
padsall = [(o, b) for o in els for b in o['pads']]
viab_ = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
for u in [u for u in unresolved if u.endswith(' frame')]:
ref = u.rsplit(' ', 1)[0]; e = next((x for x in els if x['name'] == ref), None)
if not e or ref in {l['ref'] for l in leaders}: continue
pb = (min(q[0] for q in e['pads']), min(q[1] for q in e['pads']), max(q[2] for q in e['pads']), max(q[3] for q in e['pads']))
pcx = (pb[0] + pb[2]) / 2
others_l = [L['box'] for L in labels if L['ref'] != ref] + [g['outer'] for g in groups]
got = None
for size in _sz(0.8, 0.6, 0.5):
th = size * 1.1
for flip in (False, True):
rows = [(e['name'], 'name'), (e.get('net'), 'net')]
if flip: rows = rows[::-1]
cand = []
for (t, kind), y0 in zip(rows, (pb[3] + 0.12, pb[1] - 0.12 - th)):
if not t: continue
w = 0.95 * size * len(t) + 0.1
cand.append({'ref': ref, 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(pcx - w / 2, 3), round(y0, 3), round(pcx + w / 2, 3), round(y0 + th, 3)], 'origin': [round(pcx - w / 2, 3), round(y0, 3)]})
def clear(b):
if outline and not (b[0] >= outline[0] + 0.3 and b[1] >= outline[1] + 0.3 and b[2] <= outline[2] - 0.3 and b[3] <= outline[3] - 0.3): return False
if any(_ov(b, q, 0.1) for (o, q) in padsall): return False
if any(_ov(b, v, 0.1) for v in viab_): return False
if any(_ov(b, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): return False
return not any(_ov(b, x, 0.12) for x in others_l)
if cand and all(clear(c['box']) for c in cand): got = cand; break
if got: break
if got:
labels = [L for L in labels if L['ref'] != ref] + got
continue
# the name straight above the pad (a name below an unframed contact sits next to its neighbour's net)
nm = next((L for L in labels if L['ref'] == ref and L['kind'] == 'name'), None)
if nm and nm['box'][1] < pb[3]:
others_n = [L['box'] for L in labels if L is not nm] + [g['outer'] for g in groups]
for size in _sz(0.8, 0.6, 0.5):
th = size * 1.1; w = 0.95 * size * len(ref) + 0.1
c = (pcx - w / 2, pb[3] + 0.12, pcx + w / 2, pb[3] + 0.12 + th)
if outline and c[3] > outline[3] - 0.3: break
if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue
if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue
if any(_ov(c, x, 0.12) for x in others_n): continue
labels = [L for L in labels if L is not nm] + [{'ref': ref, 'kind': 'name', 'text': ref, 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in c], 'origin': [round(c[0], 3), round(c[1], 3)]}]
break
# the net alone, turned 90 degrees in the strip between the pad and the nearest board edge, when its
# current spot is far from the pad (more than 1 mm off it reads as a neighbour's)
nl = next((L for L in labels if L['ref'] == ref and L['kind'] == 'net'), None)
if not nl or not outline: continue
bx = nl['box']; far = max(pb[0] - bx[2], bx[0] - pb[2], pb[1] - bx[3], bx[1] - pb[3]) > 1.0
if not far: continue
for size in _sz(0.8, 0.6, 0.5):
th = size * 1.1; w = 0.95 * size * len(e.get('net') or '') + 0.1; pcy = (pb[1] + pb[3]) / 2
cands = []
if outline[2] - pb[2] < pb[0] - outline[0]: cands.append((pb[2] + 0.12, pcy - w / 2, pb[2] + 0.12 + th, pcy + w / 2))
else: cands.append((pb[0] - 0.12 - th, pcy - w / 2, pb[0] - 0.12, pcy + w / 2))
others_l = [L['box'] for L in labels if L is not nl] + [g['outer'] for g in groups]
ok_ = None
for c in cands:
if c[0] < outline[0] + 0.3 or c[2] > outline[2] - 0.3 or c[1] < outline[1] + 0.3 or c[3] > outline[3] - 0.3: continue
if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue
if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue
# a contact's net outranks a test pad's or a small part's name: those give way (off the print)
yield_ = [L for L in labels if L is not nl and _ov(c, L['box'], 0.12)]
if any(L['kind'] != 'name' or is_contact(next((x for x in els if x['name'] == L['ref']), e)) for L in yield_): continue
if any(_ov(c, g['outer'], 0.12) for g in groups): continue
ok_ = c; break
if ok_:
for L in yield_: unresolved.append('%s name' % L['ref'])
labels = [L for L in labels if L not in yield_]
labels = [L for L in labels if L is not nl] + [{'ref': ref, 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R90', 'box': [round(v, 3) for v in ok_], 'origin': [round(ok_[0], 3), round(ok_[1], 3)]}]
break
frames = frames + [(l['ref'], l['frame']) for l in leaders if l['frame']]
return groups, frames, labels, leaders, unresolved