main
ae007 Project page: AFS_Adom rev A (Rice Eclipse AFS clean-room AI redesign): highlight reel and narrated process video with… cbe460e 7d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
"""AFS_Adom silkscreen step: label manifest -> candidate search (adom-aiflow silkscreen-layout) -> native apply through
the KiCad bridge (kicad_silk_fields_batch / kicad_silk_text_batch / kicad_silk_graphics_batch) -> whole-board audit input.

Phases (run in order; every phase writes its files under AFS_Adom/aiflow/silk/):
  python3 silk.py parse <board.kicad_pcb>   footprints, pads, vias, holes, existing silk -> board.json
  python3 silk.py manifest                   label manifest (what each label says and why) -> manifest.json
  python3 silk.py measure                    native text bounds through kicad_text_bounds -> bounds.json (estimate fallback flagged)
  python3 silk.py solve                      per-face silkscreen-layout inputs + results -> layout_F.json / layout_B.json
  python3 silk.py batches                    bridge batch payloads -> apply_fields.json, apply_text_F.json, apply_text_B.json, apply_graphics.json
  python3 silk.py audit                      silkscreen-audit input from the applied layout -> audit.json
Board frame: mm, +y down (KiCad). Rotation convention as in pcb.py: abs = (cx + px cos r + py sin r, cy - px sin r + py cos r).
"""
import json, math, os, re, subprocess, sys

SCR = '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad'
PRJ = SCR + '/AFS_Adom'
OUT = PRJ + '/aiflow/silk'
BOARD_WIN = 'C:/Users/angus/Documents/aiflow/AFS_Adom/AFS_Adom.kicad_pcb'
THREAD = 'AFS_Adom-flight-computer'
os.makedirs(OUT, exist_ok=True)

# ----------------------------------------------------------------------------------------------- s-expression parser
def parse_sexp(text):
    tokens = re.findall(r'"(?:[^"\\]|\\.)*"|\(|\)|[^\s()]+', text)
    stack = [[]]
    for t in tokens:
        if t == '(':
            stack.append([])
        elif t == ')':
            node = stack.pop(); stack[-1].append(node)
        else:
            if t.startswith('"'):
                t = t[1:-1].replace('\\"', '"').replace('\\\\', '\\')
            stack[-1].append(t)
    return stack[0]


def kids(node, key):
    return [n for n in node[1:] if isinstance(n, list) and n and n[0] == key]


def kid(node, key, default=None):
    k = kids(node, key)
    return k[0] if k else default


def rot_abs(cx, cy, px, py, r):
    c, s = math.cos(math.radians(r)), math.sin(math.radians(r))
    return (cx + px * c + py * s, cy - px * s + py * c)


def bbox_of(points):
    xs = [p[0] for p in points]; ys = [p[1] for p in points]
    return [min(xs), min(ys), max(xs), max(ys)]


def rect_box(cx, cy, w, h, ang):
    hw, hh = w / 2, h / 2
    pts = [rot_abs(cx, cy, px, py, ang) for px, py in ((-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh))]
    return bbox_of(pts)


# ----------------------------------------------------------------------------------------------- parse
FAB_BODY = {'J1'}
def phase_parse(path):
    tree = parse_sexp(open(path, encoding='utf-8').read())
    pcb = tree[0]
    board = dict(footprints=[], vias=[], texts=[], lines=[], outline=None, source=path)
    edge = []
    for n in pcb[1:]:
        if not isinstance(n, list):
            continue
        k = n[0]
        if k == 'footprint':
            board['footprints'].append(parse_footprint(n))
        elif k == 'via':
            at = kid(n, 'at'); size = float(kid(n, 'size')[1]); drill = float(kid(n, 'drill')[1])
            net = kid(n, 'net'); board['vias'].append(dict(x=float(at[1]), y=float(at[2]), size=size, drill=drill, net=net[1] if net else ''))
        elif k == 'gr_text':
            at = kid(n, 'at'); layer = kid(n, 'layer')[1]; eff = kid(n, 'effects'); font = kid(eff, 'font') if eff else None
            size = float(kid(font, 'size')[1]) if font else 1.0; th = float(kid(font, 'thickness')[1]) if font and kid(font, 'thickness') else 0.15
            just = kid(eff, 'justify') if eff else None
            board['texts'].append(dict(text=n[1], x=float(at[1]), y=float(at[2]), rot=float(at[3]) if len(at) > 3 else 0.0, layer=layer, size=size, stroke=th,
                                       uuid=kid(n, 'uuid')[1], mirror=bool(just and 'mirror' in just)))
        elif k in ('gr_line', 'gr_rect', 'gr_arc', 'gr_poly', 'gr_circle'):
            layer = kid(n, 'layer')[1]
            if layer == 'Edge.Cuts':
                for key in ('start', 'end', 'mid'):
                    p = kid(n, key)
                    if p: edge.append((float(p[1]), float(p[2])))
                pts = kid(n, 'pts')
                if pts:
                    for xy in kids(pts, 'xy'): edge.append((float(xy[1]), float(xy[2])))
            else:
                if k == 'gr_line':
                    s, e = kid(n, 'start'), kid(n, 'end'); w = float(kid(kid(n, 'stroke'), 'width')[1])
                    board['lines'].append(dict(layer=layer, box=bbox_of([(float(s[1]), float(s[2])), (float(e[1]), float(e[2]))]), width=w, uuid=kid(n, 'uuid')[1]))
    board['outline'] = bbox_of(edge) if edge else None
    json.dump(board, open(OUT + '/board.json', 'w'), indent=0)
    print('parsed', len(board['footprints']), 'footprints', len(board['vias']), 'vias', len(board['texts']), 'texts', 'outline', board['outline'])
    return board


def parse_footprint(n):
    name = n[1]; layer = kid(n, 'layer')[1]; at = kid(n, 'at'); cx, cy = float(at[1]), float(at[2]); rot = float(at[3]) if len(at) > 3 else 0.0
    props = {}
    for p in kids(n, 'property'):
        pat = kid(p, 'at'); pl = kid(p, 'layer'); hide = kid(p, 'hide'); eff = kid(p, 'effects')
        font = kid(eff, 'font') if eff else None
        props[p[1]] = dict(text=p[2], uuid=kid(p, 'uuid')[1], x=float(pat[1]) if pat else 0.0, y=float(pat[2]) if pat else 0.0,
                           rot=float(pat[3]) if pat and len(pat) > 3 else 0.0, layer=pl[1] if pl else None,
                           hidden=bool(hide and hide[1] == 'yes'), size=float(kid(font, 'size')[1]) if font and kid(font, 'size') else 1.0)
    crt = {'F': [], 'B': []}; fab = []
    for g in kids(n, 'fp_line') + kids(n, 'fp_rect'):
        lay = kid(g, 'layer')[1]
        if lay.endswith('.CrtYd'):
            s, e = kid(g, 'start'), kid(g, 'end')
            crt[lay[0]] += [rot_abs(cx, cy, float(s[1]), float(s[2]), rot), rot_abs(cx, cy, float(e[1]), float(e[2]), rot)]
        elif lay == 'F.Fab':
            s, e = kid(g, 'start'), kid(g, 'end')
            fab += [rot_abs(cx, cy, float(s[1]), float(s[2]), rot), rot_abs(cx, cy, float(e[1]), float(e[2]), rot)]
    for g in kids(n, 'fp_poly'):
        lay = kid(g, 'layer')[1]
        if lay.endswith('.CrtYd'):
            crt[lay[0]] += [rot_abs(cx, cy, float(xy[1]), float(xy[2]), rot) for xy in kids(kid(g, 'pts'), 'xy')]
    for g in kids(n, 'fp_circle'):
        lay = kid(g, 'layer')[1]
        if lay.endswith('.CrtYd'):
            c, e = kid(g, 'center'), kid(g, 'end'); r = math.dist((float(c[1]), float(c[2])), (float(e[1]), float(e[2])))
            ccx, ccy = rot_abs(cx, cy, float(c[1]), float(c[2]), rot)
            crt[lay[0]] += [(ccx - r, ccy - r), (ccx + r, ccy + r)]
    pads = []
    for p in kids(n, 'pad'):
        pat = kid(p, 'at'); px, py = float(pat[1]), float(pat[2]); pang = float(pat[3]) if len(pat) > 3 else rot
        size = kid(p, 'size'); w, h = float(size[1]), float(size[2])
        layers = kid(p, 'layers')[1:]; drill = kid(p, 'drill')
        ax, ay = rot_abs(cx, cy, px, py, rot)
        faces = []
        if any(l in ('F.Cu', '*.Cu', 'F.Mask', '*.Mask') for l in layers): faces.append('F')
        if any(l in ('B.Cu', '*.Cu', 'B.Mask', '*.Mask') for l in layers): faces.append('B')
        pads.append(dict(number=p[1], type=p[2], shape=p[3], x=ax, y=ay, box=rect_box(ax, ay, w, h, pang), faces=faces,
                         drill=float(drill[1]) if drill and len(drill) > 1 and re.match(r'^[\d.]+$', drill[1]) else 0.0, net=(kid(p, 'net') or ['', ''])[-1]))
    fp = dict(name=name, layer=layer, x=cx, y=cy, rot=rot, ref=props['Reference']['text'], value=props['Value']['text'], props=props, pads=pads,
              courtyard={k: (bbox_of(v) if v else None) for k, v in crt.items()}, fab=(bbox_of(fab) if fab else None))
    if fp['ref'] in FAB_BODY and fp['fab']:
        # fitted body = the F.Fab box (AI-created placeholder STEP box over the fab outline); pads stay separate obstacles
        fp['courtyardFull'] = fp['courtyard']['F']; fp['courtyard']['F'] = [min(fp['fab'][0], min(p['box'][0] for p in pads)), min(fp['fab'][1], min(p['box'][1] for p in pads)),
                                                                              max(fp['fab'][2], max(p['box'][2] for p in pads)), max(fp['fab'][3], max(p['box'][3] for p in pads))]
        fp['courtyard']['F'] = fp['fab']; fp['bodySource'] = 'F.Fab box (placeholder model); pads as separate obstacles'
    if not fp['courtyard']['F'] and pads:
        b = bbox_of([(p['box'][0], p['box'][1]) for p in pads] + [(p['box'][2], p['box'][3]) for p in pads])
        fp['courtyard']['F'] = [b[0] - 0.25, b[1] - 0.25, b[2] + 0.25, b[3] + 0.25]; fp['courtyardSource'] = 'pads+0.25'
    return fp


# ----------------------------------------------------------------------------------------------- manifest
REF_H, VAL_H = 0.8, 0.8   # PCBWay rule pack: silk text >= 0.8 mm high, stroke >= 0.15 mm (min-silk-thickness)
STROKE = {1.2: 0.18, 1.0: 0.15, 0.8: 0.15}
CONTACT_SHORT = {'MC9': 'TX', 'MC10': 'RX', 'MC11': 'SWCLK', 'MC12': 'SWDIO', 'MC14': 'SERVO1'}
CONTACT_FN = {'MC1': 'VBAT_IN', 'MC2': 'GND', 'MC3': 'VBAT', 'MC4': 'GND', 'MC5': '+5V', 'MC6': 'GND', 'MC7': '+3V3', 'MC8': 'GND',
              'MC9': 'UART0_TX', 'MC10': 'UART0_RX', 'MC11': 'SWCLK', 'MC12': 'SWDIO', 'MC13': 'RUN', 'MC14': 'SERVO1_5V'}
HEADER_PINS = {'J12': ['SWCLK', 'SWDIO', '3V3', 'GND'], 'J13': ['TX', 'RX', '3V3', 'GND'], 'J14': ['SCL', 'SDA', '3V3', 'GND'], 'J15': ['SCL', 'SDA', '3V3', 'GND'],
               'J16': ['MISO', 'CS', 'SCK', 'MOSI'], 'J17': ['IO28', 'IO29', 'IO30', 'IO31'], 'J18': ['3V3', 'GND'], 'J19': ['3V3', 'GND'],
               'J20': ['5V', 'GND'], 'J21': ['5V', 'GND'], 'J1': ['+', '-']}
for i in range(1, 9):
    HEADER_PINS['J%d' % (i + 1)] = ['S', 'VBAT', 'GND']
HEADER_NAME = {'J1': 'BATT 2S 6-8.4V', 'J10': 'USB-C 5V', 'J11': 'microSD', 'J12': 'SWD', 'J13': 'UART0', 'J14': 'I2C0', 'J15': 'I2C1', 'J16': 'SPI1',
               'J17': 'GPIO', 'J18': '3V3 OUT', 'J19': '3V3 OUT', 'J20': '5V OUT', 'J21': '5V OUT'}
for i in range(1, 9):
    HEADER_NAME['J%d' % (i + 1)] = 'SERVO%d' % i
VALUE_TEXT = {'XT30 BATT': 'XT30', 'MachinePinMediumShort': 'GND', 'RED VBAT': 'VBAT', 'YEL VBUS': 'VBUS', 'GRN 5V': '5V', 'GRN 3V3': '3V3', 'BLU USER': 'USER'}
IDENTITY_TOP = [('F', 'AFS_Adom rev A', 1.0), ('F', 'Active Fin Stabilization flight computer', 0.8),
                ('B', 'RP2350B flight computer', 0.8), ('B', 'ICM-45686 KX134 MMC5983 MS5611: SPI1', 0.8), ('B', 'TMP117 INA219: I2C0', 0.8),
                ('B', '8 servo outputs, 5V logic', 0.8), ('B', 'S = signal, VBAT = raw 2S, GND', 0.8), ('B', 'in: USB-C 5V or 2S LiPo 6-8.4V', 0.8)]


def legend_lines(ref):
    pins = HEADER_PINS[ref]; items = [f'{i} {pn}' for i, pn in enumerate(pins, 1)]
    if len(items) <= 2: return ['  '.join(items)]
    half = (len(items) + 1) // 2
    return ['  '.join(items[:half]), '  '.join(items[half:])]


def phase_manifest():
    board = json.load(open(OUT + '/board.json'))
    tps = dict(json.load(open(PRJ + '/aiflow/testpoints.json')))
    labels = []

    def add(**kw):
        labels.append(kw)

    for fp in board['footprints']:
        ref, val = fp['ref'], fp['value']
        kind = re.match(r'[A-Za-z]+', ref).group(0)
        if kind in ('MC', 'MP'):
            fn = CONTACT_SHORT.get(ref) or CONTACT_FN.get(ref, 'GND')
            for face in ('F', 'B'):
                add(id=f'{ref}-{face}', kind='contact', ref=ref, face=face, texts=[dict(role='reference', text=ref, height=REF_H), dict(role='function', text=fn, height=VAL_H)], net=CONTACT_FN.get(ref, 'GND'),
                    policy='outside-first', source='design.py MOLECULE_CONTACTS / molecule corner pins (GND)')
        elif kind == 'TP':
            add(id=f'{ref}-F', kind='testpoint', ref=ref, face='F', texts=[dict(role='reference', text=f'{ref} {tps[ref]}', height=VAL_H)], textsAlt=[dict(role='reference', text=ref, height=0.8)], source='aiflow/testpoints.json')
        elif kind == 'H':
            add(id=f'{ref}-F', kind='hole', ref=ref, face='F', texts=[dict(role='reference', text=f'{ref} M3', height=0.8)], source='mounting hole, M3')
        elif kind == 'J':
            name = HEADER_NAME.get(ref, val)
            add(id=f'{ref}-F', kind='header', ref=ref, face='F', texts=[dict(role='reference', text=ref, height=REF_H), dict(role='value', text=name, height=0.8)], source='design.py connectors sheet')
            pins = HEADER_PINS.get(ref)
            if pins:
                for i, pn in enumerate(pins, 1):
                    pad = next(p for p in fp['pads'] if p['number'] == str(i))
                    for face in (('F', 'B') if 'B' in pad['faces'] else ('F',)):
                        add(id=f'{ref}.{i}-{face}', kind='pin', ref=ref, pin=str(i), face=face, texts=[dict(role='pin', text=pn, height=VAL_H)], pad=pad, net=pad['net'],
                            source=f'pad {ref}.{i} net {pad["net"]}')
                for face in (('F', 'B') if 'B' in fp['pads'][0]['faces'] else ('F',)):
                    add(id=f'{ref}-legend-{face}', kind='legend', ref=ref, face=face, texts=[dict(role='legend', text=t, height=VAL_H) for t in legend_lines(ref)],
                        textsAlt=[dict(role='legend', text=f'{i} {pn}', height=VAL_H) for i, pn in enumerate(pins, 1)], source=f'pin key for {ref}: pad numbers and nets', fallback=True)
                if 'B' in fp['pads'][0]['faces']:
                    add(id=f'{ref}-B', kind='header', ref=ref, face='B', texts=[dict(role='reference', text=ref, height=0.8), dict(role='value', text=name, height=VAL_H)], source='through-hole header, repeated on the back face')
        else:
            vtext = VALUE_TEXT.get(val, val)
            add(id=f'{ref}-F', kind='part', ref=ref, face='F', texts=[dict(role='reference', text=ref, height=REF_H), dict(role='value', text=vtext, height=VAL_H)], source=f'{ref} value {val}')
    tpl = sorted(tps.items(), key=lambda kv: int(kv[0][2:]))
    for part in range(0, len(tpl), 3):
        chunk = tpl[part:part + 3]
        add(id=f'TPKEY{part // 3}-B', kind='block', ref=None, face='B', texts=[dict(role='key', text=f'{r} {n}', height=0.8) for r, n in chunk],
            source='aiflow/testpoints.json: reference -> net key on the back face (supplementary to the top-face labels)', section='tp-key')
    for face in ('F', 'B'):
        rows = [dict(role='identity', text=t, height=h) for ff, t, h in IDENTITY_TOP if ff == face]
        for k in range(0, len(rows), 2):
            add(id=f'ID{k // 2}-{face}', kind='block', ref=None, face=face, texts=rows[k:k + 2], source='project brief / schematic root note (left-aligned two-line blocks)', section='identity')
    add(id='BOOTSEL-note-B', kind='note', ref='SW1', face='B', texts=[dict(role='note', text='hold BOOTSEL + press RESET = USB boot', height=0.8)], source='RP2350 datasheet BOOTSEL')
    json.dump(labels, open(OUT + '/manifest.json', 'w'), indent=0)
    from collections import Counter
    print('manifest', len(labels), Counter(l['kind'] for l in labels))
    return labels


# ----------------------------------------------------------------------------------------------- measure
def est_box(text, h):
    """Estimate of KiCad stroke-font extents (newstroke, thickness ~0.15 h): ~0.86 h per glyph, 0.3 h for a space."""
    w = sum(0.3 * h if ch == ' ' else 0.86 * h for ch in text) + 0.15 * h
    return [w, h * 1.2]


def bridge(verb, args):
    r = subprocess.run(['adom-bridge', '--ai-thread', THREAD, verb, json.dumps(args)], capture_output=True, text=True, timeout=200)
    try:
        return json.loads(r.stdout)
    except Exception:
        return dict(success=False, raw=r.stdout[-2000:], err=r.stderr[-2000:])


def phase_measure():
    labels = json.load(open(OUT + '/manifest.json'))
    board = json.load(open(OUT + '/board.json'))
    uniq = {}
    for l in labels:
        for t in l['texts'] + l.get('textsAlt', []):
            for h in sorted(set(font_ladder(t['height']) + [0.8, 1.0])):
                uniq[(t['text'], h)] = None
    for t in board['texts']:
        uniq[(t['text'], t['size'])] = None
    keys = list(uniq)
    bounds = {}
    measured = 0
    for i in range(0, len(keys), 80):
        chunk = keys[i:i + 80]
        res = bridge('kicad_text_bounds', dict(filePath=BOARD_WIN, texts=[dict(id=str(j), text=k[0], x=146.0, y=134.0, height=k[1], width=k[1], stroke=STROKE.get(k[1], 0.15), align='center') for j, k in enumerate(chunk)]))
        items = res.get('texts') or res.get('bounds') or res.get('results') or []
        if not res.get('success') or not items:
            print('measure chunk failed, estimate used:', json.dumps(res)[:300])
            for k in chunk:
                bounds['%s|%s' % k] = dict(w=est_box(*k)[0], h=est_box(*k)[1], native=False)
            continue
        for j, k in enumerate(chunk):
            it = next((x for x in items if str(x.get('id')) == str(j)), items[j] if j < len(items) else None)
            box = it.get('box') or it.get('bounds') or it.get('bbox') if it else None
            if isinstance(box, dict):
                box = [box.get('xmin', box.get('x1')), box.get('ymin', box.get('y1')), box.get('xmax', box.get('x2')), box.get('ymax', box.get('y2'))]
            if box and None not in box:
                bounds['%s|%s' % k] = dict(w=box[2] - box[0], h=box[3] - box[1], native=True, raw=it); measured += 1
            else:
                bounds['%s|%s' % k] = dict(w=est_box(*k)[0], h=est_box(*k)[1], native=False, raw=it)
    json.dump(bounds, open(OUT + '/bounds.json', 'w'), indent=0)
    print('measured', measured, 'of', len(keys), 'native' if measured == len(keys) else 'PARTLY ESTIMATED')
    if keys:
        print('sample', keys[0], bounds['%s|%s' % keys[0]])


def font_ladder(h):
    # PCBWay standard silk: 0.8 mm minimum height, 0.15 mm minimum stroke (board setup + rule pack); no smaller text
    if h >= 1.0: return [1.0, 0.8]
    return [0.8]


# ----------------------------------------------------------------------------------------------- solve
PAD_MARGIN = 0.25      # silk to exposed copper/mask opening
BODY_MARGIN = 0.1
VIA_MARGIN = 0.2
EDGE_MARGIN = 0.6
GAP = 0.05             # between reference and value lines (native boxes already carry the font's padding)


def text_box(bounds, text, h):
    b = bounds['%s|%s' % (text, h)]
    return b['w'], b['h']


def obstacles_for(board, face, bounds):
    obs = []
    for fp in board['footprints']:
        c = fp['courtyard'].get(face)
        if c and fp['layer'][0] == face:
            obs.append(dict(id=f"{fp['ref']}:body", kind='body', layer=f'{face}.SilkS', box=[c[0] - BODY_MARGIN, c[1] - BODY_MARGIN, c[2] + BODY_MARGIN, c[3] + BODY_MARGIN]))
        for p in fp['pads']:
            if face in p['faces']:
                b = p['box']
                obs.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layer=f'{face}.SilkS', box=[b[0] - PAD_MARGIN, b[1] - PAD_MARGIN, b[2] + PAD_MARGIN, b[3] + PAD_MARGIN]))
    for i, v in enumerate(board['vias']):
        r = v['drill'] / 2 + 0.15   # vias are tented on both faces (board tenting): the hole is the keepout, not the annular ring
        obs.append(dict(id=f"via{i}:{v['x']:.2f},{v['y']:.2f}", kind='via', layer=f'{face}.SilkS', box=[v['x'] - r, v['y'] - r, v['x'] + r, v['y'] + r]))
    # the four 2 mm corner arcs: a label inside the rectangle but outside the arc is clipped by the board edge
    ox = board['outline']; R = 2.0
    for cx, cy, n in ((ox[0], ox[1], 'tl'), (ox[2], ox[1], 'tr'), (ox[0], ox[3], 'bl'), (ox[2], ox[3], 'br')):
        obs.append(dict(id=f'corner:{n}', kind='edge', layer=f'{face}.SilkS', box=[min(cx, cx + (R if cx == ox[0] else -R)), min(cy, cy + (R if cy == ox[1] else -R)), max(cx, cx + (R if cx == ox[0] else -R)), max(cy, cy + (R if cy == ox[1] else -R))]))
    for t in board['texts']:
        if t['layer'] == f'{face}.SilkS':
            w, h = text_box(bounds, t['text'], t['size'])
            obs.append(dict(id=f"text:{t['uuid'][:8]}", kind='silk', layer=f'{face}.SilkS', box=[t['x'] - w / 2 - 0.1, t['y'] - h / 2 - 0.1, t['x'] + w / 2 + 0.1, t['y'] + h / 2 + 0.1]))
    for l in board['lines']:
        if l['layer'] == f'{face}.SilkS':
            b = l['box']; m = l['width'] / 2 + 0.1
            obs.append(dict(id=f"line:{l['uuid'][:8]}", kind='silk', layer=f'{face}.SilkS', box=[b[0] - m, b[1] - m, b[2] + m, b[3] + m]))
    return obs


def pair_candidates(label, anchor_box, anchor, bounds, outline):
    """Candidates for a stacked reference/value pair (or a single text) around a box: 4 sides x offsets x font ladder."""
    cands = []
    texts = label['texts']
    ladders = [font_ladder(t['height']) for t in texts]
    combos = []
    for i in range(max(len(l) for l in ladders)):
        combos.append([l[min(i, len(l) - 1)] for l in ladders])
    if len(texts) == 2 and texts[1]['role'] != 'legend':
        combos.append([0.8, None])   # reference only, last resort
    x0, y0, x1, y1 = anchor_box
    for fonts in combos:
        rows = []
        for t, f in zip(texts, fonts):
            if f is None: continue
            w, h = text_box(bounds, t['text'], f)
            rows.append((t, f, w, h))
        W = max(r[2] for r in rows); H = sum(r[3] for r in rows) + GAP * (len(rows) - 1)
        penalty = 0 if len(rows) == len(texts) else 400
        places = []
        OFF = BODY_MARGIN + 0.12 + 0.08
        wide = texts[0]['role'] == 'legend'
        for dx in ((0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.0, 4.0, -5.5, 5.5, -7.0, 7.0) if wide else (0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.0, 4.0)):
            for extra in ((0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0) if wide else (0, 0.5, 1.0, 1.5, 2.0)):
                places.append(('top', anchor[0] + dx, y0 - OFF - extra - H / 2)); places.append(('bottom', anchor[0] + dx, y1 + OFF + extra + H / 2))
        for dy in ((0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0, -4.5, 4.5) if wide else (0, -1.0, 1.0, -2.0, 2.0, -3.0, 3.0)):
            for extra in ((0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0) if wide else (0, 0.5, 1.0, 1.5, 2.0)):
                places.append(('left', x0 - OFF - extra - W / 2, anchor[1] + dy)); places.append(('right', x1 + OFF + extra + W / 2, anchor[1] + dy))
        if len(rows) == 1:
            t, f, w, h = rows[0]
            for extra in (0, 0.5, 1.0, 1.5):
                for d in (0, -0.6, 0.6):
                    for side, cx, cy in (('topR', anchor[0] + d, y0 - OFF - extra - w / 2), ('bottomR', anchor[0] + d, y1 + OFF + extra + w / 2), ('leftR', x0 - OFF - extra - h / 2, anchor[1] + d), ('rightR', x1 + OFF + extra + h / 2, anchor[1] + d)):
                        box = [cx - h / 2, cy - w / 2, cx + h / 2, cy + w / 2]
                        cands.append(dict(font=f, position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], boxes=[[round(v, 3) for v in box]], side=side, qualityPenalty=penalty + 4,
                                          children=[dict(role=t['role'], text=t['text'], size=f, x=round(cx, 3), y=round(cy, 3), w=h, h=w, rotation=90)]))
        for side, cx, cy in places:
            boxes = []; children = []; yy = cy - H / 2
            for t, f, w, h in rows:
                boxes.append([cx - w / 2, yy, cx + w / 2, yy + h])
                children.append(dict(role=t['role'], text=t['text'], size=f, x=round(cx, 3), y=round(yy + h / 2, 3), w=w, h=h))
                yy += h + GAP
            box = [cx - W / 2, cy - H / 2, cx + W / 2, cy + H / 2]
            cands.append(dict(font=min(r[1] for r in rows), position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], boxes=[[round(v, 3) for v in b] for b in boxes],
                              children=children, side=side, qualityPenalty=penalty + (0 if side in ('top', 'bottom') else 5)))
    return cands


def contact_candidates(label, fp, bounds, outline):
    """Machine contacts: outside-first (between the contact and the nearest edge), then inside; stacked ref/function."""
    c = fp['courtyard']['F']; cx, cy = fp['x'], fp['y']
    dists = {'left': cx - outline[0], 'right': outline[2] - cx, 'top': cy - outline[1], 'bottom': outline[3] - cy}
    near = min(dists, key=dists.get)
    cands = pair_candidates(label, c, (cx, cy), bounds, outline)
    for cd in cands:
        if cd['side'] == near: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 30   # outside-first
        cd['qualityPenalty'] = cd.get('qualityPenalty', 0) + 5 * abs(cd['position'][0] - cx) * (1 if near in ('left', 'right') else 0)
    # rotated pair (text running away from the edge) for contacts in a dense row along the top/bottom edge
    if near in ('top', 'bottom'):
        OFF = BODY_MARGIN + 0.12 + 0.08
        for fr, ff in ((0.8, 0.8), (0.8, None)):
            wr, hr = text_box(bounds, label['texts'][0]['text'], fr)
            cols = [(label['texts'][0], fr, wr, hr)]
            if ff:
                wf, hf = text_box(bounds, label['texts'][1]['text'], ff); cols.append((label['texts'][1], ff, wf, hf))
            W = sum(cl[3] for cl in cols) + GAP * (len(cols) - 1); H = max(cl[2] for cl in cols)
            for extra in (0, 0.5, 1.0, 1.5, 2.0, 2.5):
                for dx in (0, -0.5, 0.5, -1.0, 1.0):
                    cy0 = (c[1] - OFF - extra - H / 2) if near == 'bottom' else (c[3] + OFF + extra + H / 2)
                    xx = cx + dx - W / 2; boxes = []; children = []
                    for t, f, w, h in cols:
                        boxes.append([xx, cy0 - w / 2, xx + h, cy0 + w / 2]); children.append(dict(role=t['role'], text=t['text'], size=f, x=round(xx + h / 2, 3), y=round(cy0, 3), w=h, h=w, rotation=90)); xx += h + GAP
                    cands.append(dict(font=min(cl[1] for cl in cols), position=[round(cx + dx, 3), round(cy0, 3)], box=[round(v, 3) for v in [cx + dx - W / 2, cy0 - H / 2, cx + dx + W / 2, cy0 + H / 2]],
                                      boxes=[[round(v, 3) for v in b] for b in boxes], children=children, side='inside-rotated', qualityPenalty=8 + (0 if ff else 400)))
    return cands


def pin_candidates(label, fp, bounds):
    """Pin labels: outside the header's courtyard, in line with the pad; text rotated 90 deg when the pin row runs across so
    neighbouring pin labels at 2.0 / 2.54 mm pitch stay apart; unrotated beside a vertical row."""
    p = label['pad']; cands = []
    px, py = p['x'], p['y']; c = fp['courtyard']['F'] or fp['courtyard']['B']
    OFF = BODY_MARGIN + 0.12 + 0.08
    text = label['texts'][0]['text']
    for f in font_ladder(VAL_H):
        w, h = text_box(bounds, text, f)
        opts = []
        for extra in (0, 0.4, 0.8, 1.2):
            # rotated (vertical text) above / below the courtyard at the pad's x
            opts.append(('below', 90, px, c[3] + OFF + extra + w / 2, h, w)); opts.append(('above', 90, px, c[1] - OFF - extra - w / 2, h, w))
            # plain text left / right of the courtyard at the pad's y
            opts.append(('right', 0, c[2] + OFF + extra + w / 2, py, w, h)); opts.append(('left', 0, c[0] - OFF - extra - w / 2, py, w, h))
            # plain text above / below (2-pin headers and wide pitches)
            opts.append(('belowH', 0, px, c[3] + OFF + extra + h / 2, w, h)); opts.append(('aboveH', 0, px, c[1] - OFF - extra - h / 2, w, h))
        for side, rot, cx, cy, bw, bh in opts:
            box = [cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2]
            cands.append(dict(font=f, position=[round(cx, 3), round(cy, 3)], box=[round(v, 3) for v in box], side=side,
                              children=[dict(role='pin', text=text, size=f, x=round(cx, 3), y=round(cy, 3), w=bw, h=bh, rotation=rot)], qualityPenalty=(0 if rot == 0 else 2)))
    return cands


def identity_candidates(label, bounds, outline, grid=2.0):
    """Identity / note lines: a coarse grid over the board interior, preferring open space near the board centre-left."""
    cands = []
    t = label['texts'][0]
    for f in font_ladder(t['height']):
        w, h = text_box(bounds, t['text'], f)
        y = outline[1] + 3.0
        while y < outline[3] - 3.0:
            x = outline[0] + 3.0 + w / 2
            while x < outline[2] - 3.0 - w / 2:
                box = [x - w / 2, y - h / 2, x + w / 2, y + h / 2]
                cands.append(dict(font=f, position=[round(x, 3), round(y, 3)], box=[round(v, 3) for v in box], children=[dict(role=t['role'], text=t['text'], size=f, x=round(x, 3), y=round(y, 3), w=w, h=h)], qualityPenalty=0))
                x += grid
            y += grid
    return cands


def prefilter(groups, obs, bnd, gap=0.12, keep=150):
    """Drop candidates that leave the board or hit a fixed obstacle (grid-bucketed), keep the best `keep` per label by
    font then distance, so the solver's own pass is small. Same test as the solver's (rect overlap with its clearance)."""
    cell = 2.0
    grid = {}
    for o in obs:
        b = o['box']
        for ix in range(int((b[0] - gap) // cell), int((b[2] + gap) // cell) + 1):
            for iy in range(int((b[1] - gap) // cell), int((b[3] + gap) // cell) + 1):
                grid.setdefault((ix, iy), []).append(b)
    def clear(r):
        if not (r[0] >= bnd[0] and r[1] >= bnd[1] and r[2] <= bnd[2] and r[3] <= bnd[3]): return False
        seen = set()
        for ix in range(int((r[0] - gap) // cell), int((r[2] + gap) // cell) + 1):
            for iy in range(int((r[1] - gap) // cell), int((r[3] + gap) // cell) + 1):
                for b in grid.get((ix, iy), ()):
                    k = id(b)
                    if k in seen: continue
                    seen.add(k)
                    if not (r[2] + gap <= b[0] or b[2] + gap <= r[0] or r[3] + gap <= b[1] or b[3] + gap <= r[1]): return False
        return True
    total = 0; kept = 0
    for g in groups:
        ok = [c for c in g['candidates'] if all(clear(r) for r in c.get('boxes', [c['box']]))]
        total += len(g['candidates'])
        ok.sort(key=lambda c: (-c['font'] * 100 + c.get('qualityPenalty', 0), math.dist(c['position'], g['anchor'])))
        # keep alternatives at every font size
        byfont = {}
        for c in ok: byfont.setdefault(c['font'], []).append(c)
        sel = []
        for f in sorted(byfont, reverse=True):
            # round-robin over sides so every direction keeps alternatives, nearest first within a side
            sides = {}
            for c in byfont[f]: sides.setdefault(c.get('side', ''), []).append(c)
            quota = max(6, keep // max(1, len(byfont))); picked = []
            while len(picked) < quota and any(sides.values()):
                for k in list(sides):
                    if sides[k]: picked.append(sides[k].pop(0))
                    if len(picked) >= quota: break
            sel += picked
        g['candidates'] = sel[:keep] if len(sel) > keep else sel
        g['clearCandidates'] = len(ok)
        kept += len(g['candidates'])
    print('prefilter', total, '->', kept, 'candidates;', sum(1 for g in groups if not g['candidates']), 'labels with no clear candidate')


def block_candidates(label, bounds, outline, grid=2.0):
    """A documentation block: its rows stacked, left-aligned, as one candidate per grid position; the whole block shrinks
    together down the font ladder (heading stays larger than the body)."""
    cands = []
    ladders = [font_ladder(t['height']) for t in label['texts']]
    for k in range(max(len(x) for x in ladders)):
        fonts = [x[min(k, len(x) - 1)] for x in ladders]
        rows = []
        for t, f in zip(label['texts'], fonts):
            w, h = text_box(bounds, t['text'], f); rows.append((t, f, w, h))
        W = max(r[2] for r in rows); H = sum(r[3] for r in rows) + GAP * (len(rows) - 1)
        y = outline[1] + 3.0 + H / 2
        while y < outline[3] - 3.0 - H / 2:
            x = outline[0] + 3.0
            while x < outline[2] - 3.0 - W:
                boxes = []; children = []; yy = y - H / 2
                for t, f, w, h in rows:
                    boxes.append([x, yy, x + w, yy + h]); children.append(dict(role=t['role'], text=t['text'], size=f, x=round(x + w / 2, 3), y=round(yy + h / 2, 3), w=w, h=h)); yy += h + GAP
                cands.append(dict(font=min(fonts), position=[round(x + W / 2, 3), round(y, 3)], box=[round(v, 3) for v in [x, y - H / 2, x + W, y + H / 2]], boxes=[[round(v, 3) for v in b] for b in boxes], children=children, qualityPenalty=0))
                x += grid
            y += grid
    return cands


def phase_solve():
    os.makedirs(PRJ + '/aiflow/run/silkscreen-dashboard', exist_ok=True)
    board = json.load(open(OUT + '/board.json')); bounds = json.load(open(OUT + '/bounds.json')); labels = json.load(open(OUT + '/manifest.json'))
    outline = board['outline']; fps = {fp['ref']: fp for fp in board['footprints']}
    labels_by_id = {l['id']: l for l in labels}
    results = {}
    for face in ('F', 'B'):
        obs = obstacles_for(board, face, bounds)
        groups = []
        use_legend = set()
        for attempt in range(3):
          groups = []
          for l in labels:
            if l['face'] != face: continue
            if l['kind'] == 'legend' and l['ref'] not in use_legend: continue
            if l['kind'] == 'pin' and l['ref'] in use_legend: continue
            fp = fps.get(l['ref']) if l.get('ref') else None
            if l['kind'] == 'contact':
                cands = contact_candidates(l, fp, bounds, outline); anchor = [fp['x'], fp['y']]
            elif l['kind'] == 'pin':
                cands = pin_candidates(l, fp, bounds); anchor = [l['pad']['x'], l['pad']['y']]
            elif l['kind'] in ('identity',):
                cands = identity_candidates(l, bounds, outline); anchor = [146.0, 134.0]
            elif l['kind'] == 'block':
                cands = block_candidates(l, bounds, outline); anchor = [146.0, 134.0]
            elif l['kind'] == 'note':
                cands = identity_candidates(l, bounds, outline, grid=1.5); anchor = [fp['x'], fp['y']]
            elif l['kind'] == 'legend':
                c = fp['courtyard'].get('F') or fp['courtyard'].get('B')
                cands = pair_candidates(l, c, (fp['x'], fp['y']), bounds, outline); anchor = [fp['x'], fp['y']]
                for cd in cands: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 400 if len(cd['children']) == len(l['texts']) else 1e6
                if l.get('textsAlt'):
                    alt = pair_candidates(dict(l, texts=l['textsAlt']), c, (fp['x'], fp['y']), bounds, outline)
                    for cd in alt: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) - 395 if len(cd['children']) == len(l['textsAlt']) else 1e6
                    cands += alt
                cands = [cd for cd in cands if cd['qualityPenalty'] < 1e5]
            else:
                c = fp['courtyard'].get('F') or fp['courtyard'].get('B')
                cands = pair_candidates(l, c, (fp['x'], fp['y']), bounds, outline); anchor = [fp['x'], fp['y']]
                if l.get('textsAlt'):
                    alt = pair_candidates(dict(l, texts=l['textsAlt']), c, (fp['x'], fp['y']), bounds, outline)
                    for cd in alt: cd['qualityPenalty'] = cd.get('qualityPenalty', 0) + 300
                    cands += alt
            diag = math.dist((c[0], c[1]), (c[2], c[3])) if l['kind'] in ('part', 'header', 'contact', 'testpoint', 'hole') and (c := (fp['courtyard'].get('F') or fp['courtyard'].get('B'))) else 0
            g = dict(id=l['id'], anchor=[round(anchor[0], 3), round(anchor[1], 3)], candidates=cands, maxDistance=(60 if l['kind'] in ('identity', 'block') else 12 if l['kind'] == 'note' else 5 if l['kind'] == 'pin' else max(3.5, diag / 2 + 2.5)))
            if l['kind'] == 'identity':
                g['orderGroup'] = 'identity'; g['order'] = l['order']
            groups.append(g)
          bnd = [outline[0] + EDGE_MARGIN, outline[1] + EDGE_MARGIN, outline[2] - EDGE_MARGIN, outline[3] - EDGE_MARGIN]
          prefilter(groups, obs, bnd)
          res = run_solver(face, bnd, obs, groups)
          bad = {labels_by_id[u]['ref'] for u in res['unresolved'] if labels_by_id[u]['kind'] == 'pin'}
          if not bad or attempt == 2: break
          print(face, 'pin labels do not fit for', sorted(bad), '-> pin key legend instead')
          use_legend |= bad
        results[face] = res
        print(face, 'placed', len(res['placements']) - len(res['unresolved']), 'unresolved', res['unresolved'][:40])
    return results


def run_solver(face, bnd, obs, groups):
        spec = dict(bounds=bnd, obstacles=[dict(box=o['box'], id=o['id']) for o in obs], labels=groups, clearance=0.12, beamWidth=80, candidatesPerLabel=100, fontWeight=100, distanceWeight=6, rowWeight=0,
                    boundsSource='kicad_text_bounds native metrics where measured (bounds.json native flag), conservative courtyard/pad/via rectangles')
        json.dump(spec, open(OUT + f'/layout_in_{face}.json', 'w'))
        if os.path.exists(OUT + f'/layout_{face}.json'): os.remove(OUT + f'/layout_{face}.json')
        r = subprocess.run(['adom-aiflow', '--ai-thread', THREAD, '--run', PRJ + '/aiflow/run', 'silkscreen-layout', '--input', OUT + f'/layout_in_{face}.json', '--out', OUT + f'/layout_{face}.json',
                            '--events', PRJ + '/aiflow/run/silkscreen-dashboard/events.jsonl'], capture_output=True, text=True, cwd=PRJ)
        print(face, r.stdout.strip()[:200].replace('\n', ' '))
        if not os.path.exists(OUT + f'/layout_{face}.json'):
            raise SystemExit('solver wrote nothing: ' + r.stdout + r.stderr)
        return json.load(open(OUT + f'/layout_{face}.json'))


# ----------------------------------------------------------------------------------------------- batches
def phase_batches():
    board = json.load(open(OUT + '/board.json')); labels = {l['id']: l for l in json.load(open(OUT + '/manifest.json'))}
    fps = {fp['ref']: fp for fp in board['footprints']}
    fields = []; texts = {'F': [], 'B': []}; applied = []
    for face in ('F', 'B'):
        if not os.path.exists(OUT + f'/layout_{face}.json'):
            raise SystemExit('solver wrote nothing: ' + r.stdout + r.stderr)
        res = json.load(open(OUT + f'/layout_{face}.json'))
        for pl in res['placements']:
            if pl.get('unresolved'): continue
            l = labels[pl['id']]
            for ch in pl['children']:
                item = dict(label=pl['id'], role=ch['role'], text=ch['text'], x=ch['x'], y=ch['y'], size=ch['size'], layer=f'{face}.SilkS', box=[ch['x'] - ch['w'] / 2, ch['y'] - ch['h'] / 2, ch['x'] + ch['w'] / 2, ch['y'] + ch['h'] / 2])
                if l['kind'] in ('part', 'header', 'contact') and face == 'F' and ch['role'] in ('reference', 'value', 'function') and l['ref'] in fps and not (l['kind'] == 'contact' and ch['role'] == 'function'):
                    prop = fps[l['ref']]['props']['Reference' if ch['role'] == 'reference' else 'Value']
                    if prop['text'] == ch['text']:
                        fields.append(dict(id=prop['uuid'], x=round(ch['x'], 3), y=round(ch['y'], 3), rotation=0, layer='F.SilkS', height=ch['size'], width=ch['size'], stroke=STROKE.get(ch['size'], 0.15), align='center', visible=True))
                        item['via'] = 'field'; item['uuid'] = prop['uuid']; applied.append(item); continue
                texts[face].append(dict(text=ch['text'], layer=f'{face}.SilkS', x=round(ch['x'], 3), y=round(ch['y'], 3), rotation=ch.get('rotation', 0), height=ch['size'], width=ch['size'], stroke=STROKE.get(ch['size'], 0.15), align='center', mirrored=(face == 'B')))
                item['via'] = 'text'; applied.append(item)
    json.dump(dict(filePath=BOARD_WIN, update=fields), open(OUT + '/apply_fields.json', 'w'), indent=0)
    for face in ('F', 'B'):
        json.dump(dict(filePath=BOARD_WIN, create=texts[face]), open(OUT + f'/apply_text_{face}.json', 'w'), indent=0)
    json.dump(applied, open(OUT + '/applied.json', 'w'), indent=0)
    print('fields', len(fields), 'texts F', len(texts['F']), 'texts B', len(texts['B']))


# ----------------------------------------------------------------------------------------------- audit
def phase_audit():
    board = json.load(open(OUT + '/board.json')); bounds = json.load(open(OUT + '/bounds.json')); applied = json.load(open(OUT + '/applied.json'))
    inventory = []; obstacles = []; marks = []
    for fp in board['footprints']:
        face = fp['layer'][0]
        layers = [f'{face}.SilkS']
        inventory.append(dict(id=f"{fp['ref']}:body", kind='body', layers=layers))
        c = fp['courtyard'][face]
        obstacles.append(dict(id=f"{fp['ref']}:body", kind='body', layer=f'{face}.SilkS', box=c))
        for p in fp['pads']:
            inventory.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layers=[f'{f}.SilkS' for f in p['faces']]))
            for f in p['faces']:
                obstacles.append(dict(id=f"{fp['ref']}.{p['number']}", kind='pad', layer=f'{f}.SilkS', box=p['box']))
            if p['drill'] > 0:
                r = p['drill'] / 2
                inventory.append(dict(id=f"{fp['ref']}.{p['number']}:hole", kind='hole', layers=['F.SilkS', 'B.SilkS']))
                for f in ('F', 'B'):
                    obstacles.append(dict(id=f"{fp['ref']}.{p['number']}:hole", kind='hole', layer=f'{f}.SilkS', box=[p['x'] - r, p['y'] - r, p['x'] + r, p['y'] + r]))
    for i, v in enumerate(board['vias']):
        vid = f"via{i}:{v['x']:.2f},{v['y']:.2f}"; r = v['size'] / 2 + 0.02   # the audit box covers the whole via
        inventory.append(dict(id=vid, kind='via', layers=['F.SilkS', 'B.SilkS']))
        for f in ('F', 'B'):
            obstacles.append(dict(id=vid, kind='via', layer=f'{f}.SilkS', box=[v['x'] - r, v['y'] - r, v['x'] + r, v['y'] + r]))
    for a in applied:
        marks.append(dict(id=f"{a['label']}:{a['role']}", layer=a['layer'], box=[round(v, 3) for v in a['box']], text=a['text'], size=a['size']))
    applied_uuids = {a.get('uuid') for a in applied if a.get('uuid')}
    applied_pos = {(round(a['x'], 2), round(a['y'], 2), a['text']) for a in applied}
    for t in board['texts']:
        if t['layer'].endswith('.SilkS'):
            if t['uuid'] in applied_uuids or (round(t['x'], 2), round(t['y'], 2), t['text']) in applied_pos:
                continue   # already in marks with the solver's box (rotation and face known)
            w, h = text_box(bounds, t['text'], t['size'])
            if abs(t['rot']) % 180 == 90: w, h = h, w
            marks.append(dict(id=f"text:{t['uuid'][:8]}", layer=t['layer'], box=[t['x'] - w / 2, t['y'] - h / 2, t['x'] + w / 2, t['y'] + h / 2], text=t['text'], size=t['size']))
    audit = dict(schemaVersion=1, board=BOARD_WIN, inventory=inventory, obstacles=obstacles, marks=marks,
                 provenance='parse of the saved board (footprint courtyards, pads with mask, drills, vias); text boxes from kicad_text_bounds where native (bounds.json)')
    json.dump(audit, open(OUT + '/audit.json', 'w'))
    print('audit inventory', len(inventory), 'obstacles', len(obstacles), 'marks', len(marks))


if __name__ == '__main__':
    ph = sys.argv[1]
    if ph == 'parse': phase_parse(sys.argv[2])
    elif ph == 'manifest': phase_manifest()
    elif ph == 'measure': phase_measure()
    elif ph == 'solve': phase_solve()
    elif ph == 'batches': phase_batches()
    elif ph == 'audit': phase_audit()