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