main
Colby Knox AI Flow 0.2.69: Fields narration, relayout keeps names, Gerber silkscreen audit (#146 #134) b269b4e
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172
#!/usr/bin/env python3
"""adom-aiflow fusion: the Fusion side of a project, done the same way every time.

  library    one project library in Fusion: parts from their wiki component pages, the Mouser, DigiKey
             and LCSC numbers looked up and checked, a symbol per part (the chip outline for the parts a
             reader should recognise, standard symbols for passives), real 3D bound, and later parts
             added to the SAME library, never a new one
  reference  placement and spec coordinates from a reference KiCad board, moved into the Fusion board's
             frame (the board's lower-left corner at 0,0)
  schematic  the design started with the fab's rules and the fixed parts, then the schematic drawn live
             in labelled groups from the netlist, checked pin by pin
  reset      clear the live board's copper (tracks, vias, pours) for a refine pass; the parts stay
  export     Gerbers (the JLCPCB CAM job), pick and place and the BOM with every distributor number

Every step prints OK or ERROR with Hint lines and keeps its state in <run>/fusion/.
Called by `adom-aiflow fusion <step> ...`; AIFLOW_RUN, AIFLOW_TARGET and AIFLOW_THREAD come from it.
"""
import argparse, base64, csv, json, math, os, re, shutil, subprocess, sys, time
import xml.etree.ElementTree as ET
from pathlib import Path

RUN = Path(os.environ.get('AIFLOW_RUN', 'aiflow-run'))
TARGET = os.environ.get('AIFLOW_TARGET', '')
if not TARGET:
    # no run yet and no --target: the bridge's primary machine (the desktop this workspace belongs to)
    try:
        _t = json.loads(subprocess.run(['adom-bridge', 'targets'], capture_output=True, text=True, timeout=30).stdout)
        TARGET = _t.get('primaryMachine') or ''
    except Exception:
        TARGET = ''
THREAD = os.environ.get('AIFLOW_THREAD', 'adom-aiflow')
HOME_WIN = os.environ.get('AIFLOW_BOX_DIR', '')  # folder on the box; default under the user's Documents
STATE = RUN / 'fusion'

# KiCad's standard schematic symbols (KiCad library, CC-BY-SA 4.0 with the design exception), used for
# passives so a schematic reads the EE way. Pin 1 at the top for R/C/L, anode at the top for an LED.
DEVICE_SYMBOLS = {
 'R': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")
  (symbol "R" (pin_numbers hide) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
    (property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
    (symbol "R_0_1" (rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none))))
    (symbol "R_1_1"
      (pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
      (pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',
 'C': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")
  (symbol "C" (pin_numbers hide) (pin_names (offset 0.254)) (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "C" (at 0.635 2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
    (property "Value" "C" (at 0.635 -2.54 0) (effects (font (size 1.27 1.27)) (justify left)))
    (symbol "C_0_1"
      (polyline (pts (xy -2.032 -0.762) (xy 2.032 -0.762)) (stroke (width 0.508) (type default)) (fill (type none)))
      (polyline (pts (xy -2.032 0.762) (xy 2.032 0.762)) (stroke (width 0.508) (type default)) (fill (type none))))
    (symbol "C_1_1"
      (pin passive line (at 0 3.81 270) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
      (pin passive line (at 0 -3.81 90) (length 2.794) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',
 'L': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")
  (symbol "L" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "L" (at -1.27 0 90) (effects (font (size 1.27 1.27))))
    (property "Value" "L" (at 1.905 0 90) (effects (font (size 1.27 1.27))))
    (symbol "L_0_1"
      (arc (start 0 -2.54) (mid 0.6323 -1.905) (end 0 -1.27) (stroke (width 0) (type default)) (fill (type none)))
      (arc (start 0 -1.27) (mid 0.6323 -0.635) (end 0 0) (stroke (width 0) (type default)) (fill (type none)))
      (arc (start 0 0) (mid 0.6323 0.635) (end 0 1.27) (stroke (width 0) (type default)) (fill (type none)))
      (arc (start 0 1.27) (mid 0.6323 1.905) (end 0 2.54) (stroke (width 0) (type default)) (fill (type none))))
    (symbol "L_1_1"
      (pin passive line (at 0 3.81 270) (length 1.27) (name "1" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
      (pin passive line (at 0 -3.81 90) (length 1.27) (name "2" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',
 'LED': '''(kicad_symbol_lib (version 20231120) (generator "adom-aiflow")
  (symbol "LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (exclude_from_sim no) (in_bom yes) (on_board yes)
    (property "Reference" "D" (at 0 2.54 0) (effects (font (size 1.27 1.27))))
    (property "Value" "LED" (at 0 -2.54 0) (effects (font (size 1.27 1.27))))
    (symbol "LED_0_1"
      (polyline (pts (xy -1.27 -1.27) (xy -1.27 1.27)) (stroke (width 0.254) (type default)) (fill (type none)))
      (polyline (pts (xy -1.27 0) (xy 1.27 0)) (stroke (width 0) (type default)) (fill (type none)))
      (polyline (pts (xy 1.27 -1.27) (xy 1.27 1.27) (xy -1.27 0) (xy 1.27 -1.27)) (stroke (width 0.254) (type default)) (fill (type none))))
    (symbol "LED_1_1"
      (pin passive line (at -3.81 0 0) (length 2.54) (name "K" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))
      (pin passive line (at 3.81 0 180) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))))''',
}


def ok(msg, hints=()):
    print('OK: ' + msg)
    for h in hints:
        if h: print('Hint: ' + h)
    sys.exit(0)


def err(msg, hints=()):
    print('ERROR: ' + msg)
    for h in hints:
        if h: print('Hint: ' + h)
    sys.exit(1)


def clip(text, n=600):
    """Error text within n characters, keeping its beginning (the cause) and its end (issue #76)."""
    text = text or ""
    if len(text) <= n:
        return text
    head = int(n * 0.7)
    return text[:head] + " ... " + text[len(text) - (n - head - 5):]


def say(msg):
    print(msg, flush=True)


def run(cmd, timeout=600, check=False):
    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
    if check and p.returncode != 0:
        err('%s failed: %s' % (cmd[0], clip((p.stdout + p.stderr).strip(), 400)))
    return p


def ab(verb, args, timeout=600):
    """One adom-bridge verb on the run's desktop; the reply with a JSON-string `output` unwrapped."""
    if not TARGET: err('the run has no desktop target', ['Start the run with --target <box> (adom-aiflow start ... --target), or set AIFLOW_TARGET.'])
    a = dict(args); a.setdefault('reason', 'adom-aiflow fusion')
    p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)
    try: d = json.loads(p.stdout)
    except Exception: return {'success': False, 'error': clip(p.stdout + p.stderr, 400)}
    o = d.get('output')
    if isinstance(o, str) and o.startswith('{'):
        try: d = json.loads(o)
        except Exception: pass
    # Fusion busy for a long time is often a native file dialog (Open, Save, Export) left on screen by an
    # interrupted step: the bridge cannot see it, so name it (2026-10-07: a stopped schematic left "Open")
    if verb.startswith('fusion_') and 'busy' in str(d.get('error') or d.get('errorCode') or '').lower():
        w = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, 'desktop_list_windows', '{}'], timeout=60)
        try:
            wd = json.loads(w.stdout); wo = json.loads(wd['output']) if str(wd.get('output', '')).startswith('{') else wd
            stuck = [x.get('title') for x in ((wo.get('data') or wo).get('windows') or []) if x.get('image') == 'Fusion360.exe' and (x.get('title') or '').strip() in ('Open', 'Save', 'Save As', 'Export', 'Import')]
        except Exception:
            stuck = []
        if stuck:
            d['_hint'] = 'Fusion has a native %s dialog open (left by an interrupted step); it blocks every command. Cancel it on the desktop (or ask the human to), then retry.' % ' / '.join(stuck)
    return d


def box_dir():
    """The project's folder on the desktop, under the user's Documents (one per run)."""
    if HOME_WIN: return HOME_WIN.rstrip('/')
    st = load_state()
    if st.get('boxDir'): return st['boxDir']
    r = ab('shell_execute', {'command': 'echo %USERPROFILE%'})
    out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l]
    home = out[-1] if out else 'C:/Users/Public'
    # one folder per run, never shared: named after the project folder and the run (every run is
    # "aiflow-run", so the run's name alone sent a second project into the first one's folder and
    # its Altium files), and claimed with a marker naming this run; a folder another run claimed
    # gets a numbered sibling
    base = home.replace('\\', '/') + '/Documents/AdomAiFlow/'
    me = str(RUN.resolve())
    stem = re.sub(r'[^A-Za-z0-9._-]+', '-', '%s-%s' % (RUN.resolve().parent.name, RUN.resolve().name)).strip('-') or 'run'
    d = None
    for k in range(1, 50):
        cand = base + stem + ('' if k == 1 else '-%d' % k)
        w = cand.replace('/', '\\')
        r = ab('shell_execute', {'command': 'if exist "%s\\.aiflow-run" (type "%s\\.aiflow-run") else if exist "%s" (echo AIFLOW-TAKEN) else (echo AIFLOW-FREE)' % (w, w, w)})
        o = str(r.get('stdout') or r.get('output') or '').strip()
        if 'AIFLOW-FREE' in o or me in [l.strip() for l in o.splitlines()]:
            d = cand; break
    if d is None: err('no free project folder under %s for %s' % (base, stem), ['Move old AI Flow project folders out of Documents/AdomAiFlow on the desktop.'])
    w = d.replace('/', '\\')
    ab('shell_execute', {'command': 'mkdir "%s" 2>nul & (echo %s)> "%s\\.aiflow-run"' % (w, me, w)})
    st['boxDir'] = d; save_state(st)
    return d


def send(paths, dest):
    r = ab('send_files', {'filePaths': [str(Path(p).resolve()) for p in paths], 'dest': dest, 'destIsFolder': True}, timeout=1800)
    if r.get('success') is False: err('could not copy files to the desktop: %s' % str(r.get('error'))[:300], ['Check `adom-bridge --target %s ping` and retry.' % TARGET])


def load_state():
    f = STATE / 'project.json'
    return json.loads(f.read_text()) if f.is_file() else {'parts': {}}


def save_state(st):
    STATE.mkdir(parents=True, exist_ok=True)
    (STATE / 'project.json').write_text(json.dumps(st, indent=1))


def prove(key, data=None):
    """Record that a step's own work ran (the flow's proof, read by `step` and `finish`): <run>/proofs/<key>.json."""
    d = RUN / 'proofs'; d.mkdir(parents=True, exist_ok=True)
    (d / (key + '.json')).write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'data': data or {}}, indent=1))


# ---------------------------------------------------------------- cloud home

_PROJECTS = []  # the one fusion_list_cloud_projects answer of this command, in a list once read


def cloud_projects():
    """The user's Autodesk projects, {'hubs': [[names]], 'active': name or None}; None when the list cannot
    be trusted (no reply, or it timed out part way, so a missing name proves nothing)."""
    if not _PROJECTS:
        r = ab('fusion_list_cloud_projects', {})
        x = r.get('data') if isinstance(r.get('data'), dict) else r
        hubs = x.get('hubs')
        info = None
        if isinstance(hubs, list) and not x.get('listTimedOut'):
            info = {'hubs': [[str(p.get('name')) for p in (h.get('projects') or [])] for h in hubs], 'active': x.get('activeProject')}
        _PROJECTS.append(info)
    return _PROJECTS[0]


def _has_project(names, name):
    return any(n.lower() == str(name).lower() for n in names)


def default_project(info):
    """The project a run with no --project uses: Main when this account has one (a single hub only: the 3D
    build looks in every hub, but the design's save only in the active one), else the project open in Fusion's
    data panel, else the only project. Several to choose from and no way to tell is the user's call."""
    names = [n for h in info['hubs'] for n in h]
    if len(info['hubs']) == 1 and _has_project(names, 'Main'): return next(n for n in names if n.lower() == 'main')
    if info.get('active') and _has_project(names, info['active']): return info['active']
    if len(names) == 1: return names[0]
    err('which Fusion cloud project should this run save in? This account has no default AI Flow can pick: %s' % (', '.join(names) or 'no projects'),
        ['Ask the user, then run this again with --project "<name>" (the 3D models and the design go there, in the folder --folder, default "Adom AI Flow"). Or open that project in Fusion\'s data panel.'])


def cloud_place(a, st):
    """The run's Fusion cloud home (project, folder), kept in the run so the library's 3D models and the
    design land together: --project/--folder, else what this run used before, else default_project and
    Adom AI Flow. A name the account does not have is caught here, before any work, not per part: a user's
    hub need not have a project called Main (fusion-bridge#136), and the bridge never picks another."""
    c = st.get('cloud') or {}
    asked = getattr(a, 'project', None)
    project = asked or c.get('project')
    folder = getattr(a, 'folder', None) or c.get('folder') or 'Adom AI Flow'
    info = cloud_projects()
    if project and info and not _has_project([n for h in info['hubs'] for n in h], project):
        if asked: err('no Fusion cloud project named %s on this account' % asked, ['Its projects: %s. Pass one as --project.' % ', '.join(n for h in info['hubs'] for n in h)])
        say('this run was set to save in the Fusion project %s, which this account does not have; choosing again' % project)
        project = None
    if not project:
        if not info: err('could not read the Fusion cloud projects to choose one', ['Run this again with --project "<name>" (ask the user which). Check that Fusion is running and signed in (fusion_list_cloud_projects).'])
        project = default_project(info)
        say('saving in the Fusion project %s, folder %s (--project and --folder choose another)' % (project, folder))
    st['cloud'] = {'project': project, 'folder': folder}
    return project, folder


def save_refusal(app_state, name):
    """Why a Save As by name must not run now, or None. fusion_save_to_cloud is Save As on the ACTIVE
    document: the first save of a new design writes the whole design (fprj, fsch, fbrd, linked), but on a
    design already in the cloud with its 2D board in front it writes the board as a NEW unlinked .fbrd of
    the same name, and the design keeps linking the old board (issue #74 item 7: the routing, pours and
    silkscreen ended up in a board the design did not open)."""
    d = app_state.get('data') or app_state
    for x in d.get('documents') or []:
        df = x.get('dataFile') or {}
        if not df: continue
        if x.get('isActive') or (d.get('activeDocument') and x.get('name') == d.get('activeDocument')):
            return 'the open document %s is already saved in the cloud (as %s): a Save As by name would fork its board into a new, unlinked .fbrd' % (x.get('name'), df.get('name'))
        if df.get('name') == name:
            return 'a design named %s is already open from the cloud (%s): a Save As would make a second, unlinked copy of it' % (name, x.get('name'))
    return None


def cloud_copies(listing, name):
    """The files of a fusion_list_cloud_files reply named exactly `name`."""
    return [f for f in ((listing.get('data') or listing).get('files') or []) if (f.get('name') or '') == name]


def save_design(name, project, folder):
    """The new design's first save into <project>/<folder>, made only when it is safe: never over a design
    already in the cloud (see save_refusal) and never beside another design of the same name (the data panel
    cannot tell them apart). Read back after. Returns the hints worth passing on."""
    why = save_refusal(ab('fusion_get_app_state', {}), name)
    if why:
        err('not saving %s: %s' % (name, why),
            ['The Fusion bridge saves only by Save As today. Save an already-saved design in place in Fusion itself (File > Save with the design\'s own tab in front), then check its board still has the routing (open the design, not the .fbrd).',
             'Or close it without saving (fusion_close_document {name, save:false}) and run this again on a fresh import.'])
    ls = ab('fusion_list_cloud_files', {'projectName': project, 'folderPath': folder})
    if ls.get('success') is False:
        ab('fusion_create_cloud_folder', {'folderName': folder, 'projectName': project})
    elif cloud_copies(ls, name):
        have = ', '.join(sorted('%s.%s' % (f.get('name'), f.get('fileExtension') or '?') for f in cloud_copies(ls, name)))
        err('%s/%s already holds a design named %s (%s); a second one of the same name cannot be told apart in the data panel' % (project, folder, name, have),
            ['Use a new --name for this design, or open the existing one (fusion_schematic {action:"open", fileName, projectName}) and carry on with it.',
             'If the old copies are stale, remove them yourself in Fusion\'s data panel first; aiflow does not delete cloud files.'])
    s = ab('fusion_save_to_cloud', {'name': name, 'projectName': project, 'folderPath': folder})
    if not s.get('success'): err('could not save %s to %s/%s: %s' % (name, project, folder, s.get('error')), [s.get('_hint')])
    hints = []
    after = ab('fusion_list_cloud_files', {'projectName': project, 'folderPath': folder})
    if after.get('success') is not False:
        kinds = [(f.get('fileExtension') or '?') for f in cloud_copies(after, name)]
        twice = sorted({k for k in kinds if kinds.count(k) > 1})
        if not kinds: hints.append('Saved, but %s does not list %s yet; check the data panel before going on.' % (folder, name))
        elif twice: hints.append('%s/%s now holds more than one %s named %s: open the design (the .fprj) and check its board is the one you work on.' % (project, folder, '/'.join(twice), name))
    return hints


# ---------------------------------------------------------------- library

def lookup_numbers(mpn, page_links, cache):
    """Mouser, DigiKey and LCSC numbers for one MPN, each checked to belong to that exact MPN.
    The page's own links count; the distributor CLIs fill the gaps; a dead backend is said, never guessed."""
    got = {k: v for k, v in (page_links or {}).items() if k in ('mouser', 'digikey', 'lcsc') and v}
    notes = []
    if mpn in cache:
        for k, v in cache[mpn].items():
            if k in ('mouser', 'digikey', 'lcsc') and v and not got.get(k): got[k] = v
    def want(k): return not got.get(k)
    norm = lambda s: re.sub(r'[^A-Z0-9]', '', str(s).upper())
    for k, tool in (('mouser', 'adom-mouser'), ('digikey', 'adom-digikey')):
        if not want(k) or not shutil.which(tool): continue
        p = run([tool, 'part', mpn], timeout=90)
        if p.returncode != 0 or not p.stdout.strip().startswith(('{', '[')):
            txt = (p.stdout + p.stderr)
            notes.append('%s service %s' % (k, 'down (HTTP %s)' % m.group(1) if (m := re.search(r'HTTP (\d{3})', txt)) else 'unreachable')); continue
        try: d = json.loads(p.stdout)
        except Exception: continue
        items = d if isinstance(d, list) else (d.get('parts') or d.get('results') or [d])
        for it in items:
            if not isinstance(it, dict): continue
            m = it.get('mpn') or it.get('ManufacturerPartNumber') or it.get('manufacturerPartNumber')
            pn = it.get('mouserPartNumber') or it.get('MouserPartNumber') or it.get('digikeyPartNumber') or it.get('DigiKeyPartNumber') or it.get('partNumber')
            if m and pn and norm(m) == norm(mpn): got[k] = pn; break
    if want('lcsc') and shutil.which('adom-jlcpcb'):
        p = run(['adom-jlcpcb', 'search', mpn], timeout=90)
        try:
            d = json.loads(p.stdout); items = d if isinstance(d, list) else (d.get('results') or d.get('parts') or [])
            for it in items:
                m = it.get('mpn') or it.get('mfrPart') or it.get('manufacturerPartNumber')
                c = it.get('lcsc') or it.get('lcscPart') or it.get('code')
                if m and c and norm(m) == norm(mpn): got['lcsc'] = c; break
        except Exception:
            notes.append('lcsc service unreachable')
    return got, notes


def carried_part(slug, run=None):
    """The folder of a part AI Flow carries itself (issue #143): the test point, whose wiki page is private."""
    folder = Path(run or RUN) / 'testpoint'
    return folder if slug == 'adom/testpoint-pad-0-5mm' and (folder / 'page.json').is_file() else None


def pick_files(d, comp):
    parts = comp.get('parts', {})
    def first(*names):
        for n in names:
            if n and (d / n).is_file(): return d / n
        return None
    sym = first(parts.get('symbol')) or next(iter(sorted(x for x in d.glob('*.kicad_sym') if 'outline' not in x.name)), None)
    outline = next(iter(sorted(d.glob('*-outline.kicad_sym'))), None)
    fp = first(parts.get('footprint')) or next(iter(sorted(d.glob('*.kicad_mod'))), None)
    step = first(parts.get('model_3d_step'), parts.get('step')) or next(iter(sorted(x for x in d.glob('*.step') if 'unetched' not in x.name and 'uncoloured' not in x.name)), None)
    return sym, outline, fp, step


def symbol_choice(comp, fp_text, sym_path):
    """The chip outline for the parts a reader should recognise at a glance (ICs, inductors, big and
    polarised capacitors); KiCad's standard symbol for small passives and LEDs."""
    cat = (comp.get('category') or '').lower(); sub = (comp.get('subcategory') or '').lower()
    pads = len(set(re.findall(r'\(pad "([^"]+)"', fp_text or '')))
    if pads > 2: return 'outline'
    if 'inductor' in cat: return 'outline'
    if 'capacitor' in cat and any(w in (cat + sub + (comp.get('package') or '').lower()) for w in ('alumin', 'electrolytic', 'polar', 'tantal', 'smd alu')): return 'outline'
    if 'resistor' in cat: return 'R'
    if 'capacitor' in cat: return 'C'
    if 'inductor' in cat or 'ferrite' in cat: return 'L'
    if 'led' in cat or 'diode' in cat and 'light' in sub: return 'LED'
    return 'own'


# -------------------------------------------------------------- 3D model orientation
# A STEP model is often authored Y-up (the convention of many mechanical tools and every glTF), and
# nothing in the file says which axis is up. Bound as published, a Y-up part lies on its side on the
# board: the electrolytic can on its flank, the inductor on edge. So every model is checked against its
# own footprint before it is bound: the two axes that lie on the board must span the footprint, and the
# one left over is the height. A model whose height is not on Z is turned upright and re-seated at z=0.

_NUM = r'[-+0-9.Ee]+'

def _blocks(text, head):
    """Every balanced (head ...) s-expression in text."""
    out, i = [], 0
    while True:
        i = text.find('(' + head, i)
        if i < 0: return out
        depth, j = 0, i
        while j < len(text):
            depth += (text[j] == '(') - (text[j] == ')')
            if depth == 0: break
            j += 1
        out.append(text[i:j + 1]); i = j + 1

def footprint_span(text):
    """The part's room on the board, in mm: its courtyard (leads included), else its fab drawing, else its pads."""
    def outline(layer):
        pts = []
        for head in ('fp_line', 'fp_rect', 'fp_poly', 'fp_arc'):
            for blk in _blocks(text, head + ' ') + _blocks(text, head + '\n') + _blocks(text, head + '\t') + _blocks(text, head + '\r'):
                if '"%s"' % layer not in blk: continue
                pts += [(float(x), float(y)) for x, y in re.findall(r'\((?:start|end|mid|xy)\s+(%s)\s+(%s)\)' % (_NUM, _NUM), blk)]
        for blk in _blocks(text, 'fp_circle'):
            if '"%s"' % layer not in blk: continue
            c = re.search(r'\(center\s+(%s)\s+(%s)\)' % (_NUM, _NUM), blk); e = re.search(r'\(end\s+(%s)\s+(%s)\)' % (_NUM, _NUM), blk)
            if c and e:
                cx, cy = float(c.group(1)), float(c.group(2)); r = math.hypot(float(e.group(1)) - cx, float(e.group(2)) - cy)
                pts += [(cx - r, cy - r), (cx + r, cy + r)]
        return pts
    pts = outline('F.CrtYd') or outline('F.Fab')
    if not pts:
        for blk in _blocks(text, 'pad '):
            a = re.search(r'\(at\s+(%s)\s+(%s)' % (_NUM, _NUM), blk); sz = re.search(r'\(size\s+(%s)\s+(%s)\)' % (_NUM, _NUM), blk)
            if a and sz:
                x, y, w, h = float(a.group(1)), float(a.group(2)), float(sz.group(1)), float(sz.group(2))
                pts += [(x - w / 2, y - h / 2), (x + w / 2, y + h / 2)]
    if not pts: return None
    xs, ys = [q[0] for q in pts], [q[1] for q in pts]
    return max(xs) - min(xs), max(ys) - min(ys)

def up_axis(ext, span):
    """Which model axis is the height: the one whose removal leaves the two that best match the footprint.
    Returns (up, misfit of that choice, misfit with Z as the height, extents)."""
    mn, mx = ext; d = [mx[i] - mn[i] for i in range(3)]
    fw, fh = sorted(span)
    def miss(up):
        a, b = sorted(d[i] for i in range(3) if i != up)
        return abs(a - fw) / max(fw, 0.1) + abs(b - fh) / max(fh, 0.1)
    best = min((2, 1, 0), key=miss)
    # Z stays the height unless another axis fits the footprint decisively better
    if best != 2 and not (miss(2) > 0.35 and miss(best) < 0.5 * miss(2)): best = 2
    return best, miss(best), miss(2), d

def _rot(up):
    # rotation that carries the model's up axis onto +Z (right-handed, determinant +1)
    if up == 1: return lambda x, y, z: (x, -z, y)    # +90 about X: +Y -> +Z
    if up == 0: return lambda x, y, z: (-z, y, x)    # -90 about Y: +X -> +Z
    return lambda x, y, z: (x, y, z)

_ENT = re.compile(r'#(\d+)\s*=\s*([A-Z0-9_]+)\s*\((.*?)\)\s*;', re.S)
_CPLX = re.compile(r'#(\d+)\s*=\s*\(\s*(REPRESENTATION_RELATIONSHIP.*?)\)\s*;', re.S)
_PT = lambda kind: re.compile(r"%s\s*\(\s*'([^']*)'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)\s*\)" % (kind, _NUM, _NUM, _NUM))

def _vec(body):
    return [float(v) for v in re.findall(_NUM, body.split('(', 1)[1])[:3]]

def _structure(text):
    """The file as CAD readers see it: entities, the coordinate FRAMES (a representation's own placement
    among its items, both ends of an ITEM_DEFINED_TRANSFORMATION, the frames of a REPRESENTATION_MAP /
    MAPPED_ITEM), and for every representation its transform to the world. OCCT (KiCad, step2glb,
    adom-fields) applies frames as transforms: rotating them with the geometry turns a part twice, and an
    assembly's geometry lives in child representations placed by those transforms."""
    ents = {m.group(1): (m.group(2), m.group(3)) for m in _ENT.finditer(text)}
    refs = lambda body: re.findall(r'#(\d+)', body)
    kind = lambda r: ents.get(r, ('',))[0]
    frames, reps = set(), {}
    for k, (kd, body) in ents.items():
        if kd.endswith('SHAPE_REPRESENTATION') and '(' in body:  # not the styling (presentation) ones
            items = refs(body[body.find('(') + 1:body.find(')')])
            reps[k] = items
            frames.update(r for r in items if kind(r) == 'AXIS2_PLACEMENT_3D')
        elif kd in ('ITEM_DEFINED_TRANSFORMATION', 'REPRESENTATION_MAP', 'MAPPED_ITEM'):
            frames.update(r for r in refs(body) if kind(r) == 'AXIS2_PLACEMENT_3D')
    # links: (child, parent, idt or None). SHAPE_REPRESENTATION_RELATIONSHIP without a transform is identity
    # which representation is the parent: the assembly's own product structure says so. A shape
    # representation belongs to a product definition (SHAPE_DEFINITION_REPRESENTATION -> its
    # PRODUCT_DEFINITION_SHAPE), and a placement's CONTEXT_DEPENDENT_SHAPE_REPRESENTATION names the
    # NEXT_ASSEMBLY_USAGE_OCCURRENCE (relating = parent, related = child). Exporters write the
    # relationship's two representations in either order.
    pd_of_rep = {}
    for k, (kd, body) in ents.items():
        if kd == 'SHAPE_DEFINITION_REPRESENTATION':
            r = refs(body)
            if len(r) >= 2 and kind(r[0]) == 'PRODUCT_DEFINITION_SHAPE':
                d = refs(ents[r[0]][1])
                if d: pd_of_rep[r[1]] = d[-1]
    parent_pd = {}
    for k, (kd, body) in ents.items():
        if kd == 'CONTEXT_DEPENDENT_SHAPE_REPRESENTATION':
            r = refs(body)
            if len(r) >= 2 and kind(r[1]) == 'PRODUCT_DEFINITION_SHAPE':
                d = refs(ents[r[1]][1])
                if d and kind(d[-1]) == 'NEXT_ASSEMBLY_USAGE_OCCURRENCE':
                    n = refs(ents[d[-1]][1])
                    if len(n) >= 2: parent_pd[r[0]] = n[0]
    links = []
    for m in _CPLX.finditer(text):
        body = m.group(2); rr = re.search(r"REPRESENTATION_RELATIONSHIP\s*\(\s*'[^']*'\s*,\s*'[^']*'\s*,\s*#(\d+)\s*,\s*#(\d+)", body)
        idt = re.search(r'WITH_TRANSFORMATION\s*\(\s*#(\d+)', body)
        if not rr: continue
        a, b = rr.group(1), rr.group(2)
        if idt:
            want = parent_pd.get(m.group(1))
            if want and pd_of_rep.get(a) == want: a, b = b, a      # (child, parent)
            elif not want and len(reps.get(a, [])) > len(reps.get(b, [])): a, b = b, a
        links.append((a, b, idt.group(1) if idt else None))
    for k, (kd, body) in ents.items():
        if kd == 'SHAPE_REPRESENTATION_RELATIONSHIP':
            r = refs(body)
            # an identity link joins a part's wrapper SHAPE_REPRESENTATION to the rep holding its B-rep:
            # the B-rep side is the child, so it goes wherever the wrapper is placed
            if len(r) >= 2: links.append((r[1], r[0], None) if kind(r[0]) != 'SHAPE_REPRESENTATION' and kind(r[1]) == 'SHAPE_REPRESENTATION' else (r[0], r[1], None) if kind(r[0]) != 'SHAPE_REPRESENTATION' else (r[1], r[0], None))
    return ents, frames, reps, links

def _frame(ents, ref):
    """An AXIS2_PLACEMENT_3D as (origin, 3x3 columns x,y,z)."""
    r = re.findall(r'#(\d+)', ents[ref][1]); o = _vec(ents[r[0]][1])
    z = _vec(ents[r[1]][1]) if len(r) > 1 and r[1] in ents else [0, 0, 1]
    x = _vec(ents[r[2]][1]) if len(r) > 2 and r[2] in ents else [1, 0, 0]
    n = lambda v: [c / (math.sqrt(sum(d * d for d in v)) or 1) for c in v]
    z = n(z); x = n([x[i] - sum(x[j] * z[j] for j in range(3)) * z[i] for i in range(3)])
    y = [z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]]
    return o, [[x[i], y[i], z[i]] for i in range(3)]

def _compose(A, B):  # A then... A∘B as (o, M): p -> A(B(p))
    (oa, Ma), (ob, Mb) = A, B
    M = [[sum(Ma[i][k] * Mb[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return [sum(Ma[i][k] * ob[k] for k in range(3)) + oa[i] for i in range(3)], M

def _inverse(A):
    o, M = A; Mt = [[M[j][i] for j in range(3)] for i in range(3)]
    return [-sum(Mt[i][k] * o[k] for k in range(3)) for i in range(3)], Mt

_I = ([0, 0, 0], [[1, 0, 0], [0, 1, 0], [0, 0, 1]])

def _placed(text):
    """[(transform to world, [point ids])] for every placed instance of every shape representation."""
    ents, frames, reps, links = _structure(text)
    ups = {}
    for c, p, idt in links: ups.setdefault(c, []).append((p, idt))
    def worlds(rep, seen=()):
        if rep not in ups or rep in seen: return [_I]
        out = []
        for p, idt in ups[rep]:
            T = _I
            if idt:
                a, b = re.findall(r'#(\d+)', ents[idt][1])[:2]
                if b in reps.get(rep, []) and a not in reps.get(rep, []): a, b = b, a   # a: the child's frame
                T = _compose(_inverse(_frame(ents, a)), _frame(ents, b))  # as OCCT reads it, checked against step2glb
            out += [_compose(W, T) for W in worlds(p, seen + (rep,))]
        return out
    # the body's extent is its vertices: surface placements (a plane's or cylinder's origin) lie off the body
    verts = {r for kd, body in ents.values() if kd == 'VERTEX_POINT' for r in re.findall(r'#(\d+)', body)}
    out = []
    for rep, items in reps.items():
        pts, stack, seen = [], [i for i in items if i not in frames], set()
        while stack:
            r = stack.pop()
            if r in seen or r not in ents: continue
            seen.add(r); kd, body = ents[r]
            if kd == 'CARTESIAN_POINT':
                if len(_vec(body)) == 3 and (r in verts or not verts): pts.append(r)  # 2D points are curve parameters
            elif not kd.endswith('REPRESENTATION'): stack.extend(re.findall(r'#(\d+)', body))
        for W in worlds(rep): out.append((W, pts))
    return ents, frames, links, out

def step_extents(text):
    """World extents, as a CAD reader places the geometry (assembly transforms applied, frames not
    counted as geometry)."""
    try:
        ents, frames, links, placed = _placed(text)
    except Exception:
        placed = None
    if placed and any(p for _, p in placed):
        pts = []
        for (o, M), ids in placed:
            for i in ids:
                v = _vec(ents[i][1]); pts.append([sum(M[r][k] * v[k] for k in range(3)) + o[r] for r in range(3)])
    else:
        pts = [tuple(map(float, m)) for m in re.findall(r"CARTESIAN_POINT\s*\(\s*'[^']*'\s*,\s*\(\s*(%s)\s*,\s*(%s)\s*,\s*(%s)\s*\)" % (_NUM, _NUM, _NUM), text)]
    if not pts: return None
    return [min(p[i] for p in pts) for i in range(3)], [max(p[i] for p in pts) for i in range(3)]

def rigid_step(text, M, off=(0, 0, 0)):
    """Move the whole model by p -> M p + off, the way every reader will see it. Geometry of the root
    representation (and of ones linked to it without a transform) is moved point by point; a child placed
    by an ITEM_DEFINED_TRANSFORMATION is moved by moving its placement in the parent; frames that are not
    such placements keep the values the file had."""
    ents, frames, reps, links = _structure(text)
    moved_links = [(c, p, idt) for c, p, idt in links if idt]
    children = {c for c, _, _ in moved_links}
    # representations reached from a root through identity links only: their geometry moves raw
    parents = {c: p for c, p, idt in links if not idt}
    def root_side(rep, seen=()):
        if rep in children: return False
        if rep in parents and rep not in seen: return root_side(parents[rep], seen + (rep,))
        return True
    raw_pts, raw_dirs = set(), set()
    for rep, items in reps.items():
        if not root_side(rep): continue
        stack, seen = [i for i in items if i not in frames], set()
        while stack:
            r = stack.pop()
            if r in seen or r not in ents: continue
            seen.add(r); kd, body = ents[r]
            if kd == 'CARTESIAN_POINT':
                if len(_vec(body)) == 3: raw_pts.add(r)
            elif kd == 'DIRECTION':
                if len(_vec(body)) == 3: raw_dirs.add(r)
            elif not kd.endswith('REPRESENTATION'): stack.extend(re.findall(r'#(\d+)', body))
    # the placements in the parent of the top-level children (their parent is root-side)
    targets = set()
    for c, p, idt in moved_links:
        if root_side(p):
            a, b = re.findall(r'#(\d+)', ents[idt][1])[:2]
            # move the end that lives in the parent (exporters write the two ends in either order)
            targets.add(a if a in reps.get(p, []) and b not in reps.get(p, []) else b)
    ap = lambda v, t: [sum(M[i][k] * v[k] for k in range(3)) + t[i] for i in range(3)]
    nxt = [max(int(k) for k in ents) + 1]; add = []
    def new(kind, v):
        add.append("#%d = %s('',(%.9g,%.9g,%.9g)) ;" % (nxt[0], kind, *v)); nxt[0] += 1; return '#%d' % (nxt[0] - 1)
    def sub_ent(t, ref, kind, v):
        return re.sub(r'#%s\s*=\s*%s\s*\(.*?\)\s*;' % (ref, kind), lambda m: "#%s = %s('',(%.9g,%.9g,%.9g)) ;" % (ref, kind, *v), t, count=1, flags=re.S)
    out = text
    for r in raw_pts: out = sub_ent(out, r, 'CARTESIAN_POINT', ap(_vec(ents[r][1]), off))
    for r in raw_dirs: out = sub_ent(out, r, 'DIRECTION', ap(_vec(ents[r][1]), (0, 0, 0)))
    for f in targets:  # fresh entities: a placement's point and directions are often shared
        rr = re.findall(r'#(\d+)', ents[f][1])
        o = new('CARTESIAN_POINT', ap(_vec(ents[rr[0]][1]), off))
        rest = [new('DIRECTION', ap(_vec(ents[d][1]), (0, 0, 0))) if d in ents and ents[d][0] == 'DIRECTION' else '#' + d for d in rr[1:]]
        out = re.sub(r'#%s\s*=\s*AXIS2_PLACEMENT_3D\s*\(.*?\)\s*;' % f, lambda m: "#%s = AXIS2_PLACEMENT_3D('',%s) ;" % (f, ','.join([o] + rest)), out, count=1, flags=re.S)
    # frames that are root-side items share points/directions with geometry: give them their originals back
    keep = [f for f in frames if f not in targets]
    for f in keep:
        rr = re.findall(r'#(\d+)', ents[f][1])
        if not any(r in raw_pts or r in raw_dirs for r in rr): continue
        vals = ['#' + r if r not in ents or ents[r][0] not in ('CARTESIAN_POINT', 'DIRECTION') else new(ents[r][0], _vec(ents[r][1])) for r in rr]
        out = re.sub(r'#%s\s*=\s*AXIS2_PLACEMENT_3D\s*\(.*?\)\s*;' % f, lambda m: "#%s = AXIS2_PLACEMENT_3D('',%s) ;" % (f, ','.join(vals)), out, count=1, flags=re.S)
    if add:
        i = out.rfind('ENDSEC;'); out = out[:i] + '\n'.join(add) + '\n' + out[i:]
    return out

def make_upright(text, up):
    """Turn the model so `up` becomes Z, then seat it on z=0 (a rigid motion as every reader sees it)."""
    R = {1: [[1, 0, 0], [0, 0, -1], [0, 1, 0]], 0: [[0, 0, -1], [0, 1, 0], [1, 0, 0]]}.get(up, [[1, 0, 0], [0, 1, 0], [0, 0, 1]])
    t = rigid_step(text, R)
    zmin = step_extents(t)[0][2]
    return rigid_step(t, _I[1], (0, 0, -zmin)) if abs(zmin) >= 1e-6 else t

def transform_step(text, M, off=(0, 0, 0)):
    """Apply a rigid motion (KiCad's model transform) as every reader will see it."""
    return rigid_step(text, M, off)

def kicad_model_transform(fp_text):
    """The footprint's own (model ...) entry: rotate (deg) and offset (mm), or None. KiCad applies these
    when it shows the model; a Fusion package gets the raw STEP, so they must be applied here."""
    i = fp_text.find('(model')
    if i < 0: return None
    blk = fp_text[i:i + 600]
    rot = re.search(r'\(rotate\s*\(xyz\s+(%s)\s+(%s)\s+(%s)\s*\)' % (_NUM, _NUM, _NUM), blk)
    off = re.search(r'\(offset\s*\(xyz\s+(%s)\s+(%s)\s+(%s)\s*\)' % (_NUM, _NUM, _NUM), blk)
    return [float(v) for v in rot.groups()] if rot else [0.0, 0.0, 0.0], [float(v) for v in off.groups()] if off else [0.0, 0.0, 0.0]

def _matrix(rx, ry, rz):
    # KiCad: p' = Rz(-rz) . Ry(-ry) . Rx(-rx) . p (angles in degrees, applied X first)
    def R(axis, deg):
        c, s_ = math.cos(math.radians(-deg)), math.sin(math.radians(-deg))
        return {'x': [[1, 0, 0], [0, c, -s_], [0, s_, c]], 'y': [[c, 0, s_], [0, 1, 0], [-s_, 0, c]], 'z': [[c, -s_, 0], [s_, c, 0], [0, 0, 1]]}[axis]
    mul = lambda A, B: [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
    return mul(R('z', rz), mul(R('y', ry), R('x', rx)))

def upright_model(step, fp, out_dir):
    """The model as it must sit on the board: the footprint's own transform applied, then checked against
    the footprint (the two board-plane extents must span it). Returns (path, note); note is None when the
    STEP was already right."""
    text = step.read_text(errors='ignore'); fpt = fp.read_text(errors='ignore')
    notes = []
    tr = kicad_model_transform(fpt)
    if tr and (any(abs(a) > 1e-9 for a in tr[0]) or any(abs(o) > 1e-9 for o in tr[1])):
        text = transform_step(text, _matrix(*tr[0]), tr[1])
        notes.append("the footprint's own model transform (rotate %s, offset %s mm) applied, as KiCad does" % (' '.join('%g' % a for a in tr[0]), ' '.join('%g' % o for o in tr[1])))
    ext = step_extents(text); span = footprint_span(fpt)
    if ext and span:
        up, miss, miss_z, d = up_axis(ext, span)
        if up != 2:
            what = 'its height is on %s, not Z (extents %s mm against a %.1f x %.1f mm courtyard)' % ('XYZ'[up], ' x '.join('%.2f' % x for x in d), span[0], span[1])
            if notes:  # the footprint gave a transform and the result still lies wrong: say so, do not guess twice
                notes.append(what + ': CHECK this part in 3D; its footprint\'s (model ... (rotate ...)) entry may be wrong')
            else:
                text = make_upright(text, up)
                notes.append(what + ': turned upright and seated on the board')
    if not notes: return step, None
    out_dir.mkdir(parents=True, exist_ok=True)
    out = out_dir / (step.stem + '-zup.step'); out.write_text(text)
    return out, '; '.join(notes)


def service_pose(step, fp):
    """The model as every CAD reader sees it (the Adom STEP service, OpenCascade, the reader KiCad,
    step2glb and adom-fields use): None when it is Z-up and seated, else what is wrong. Skipped (None)
    when step2glb is not installed."""
    if not shutil.which('step2glb'): return None
    r = run(['step2glb', 'features', str(step)], timeout=300)
    m = re.search(r'bbox x=([-\d.]+)\.\.([-\d.]+) y=([-\d.]+)\.\.([-\d.]+) z=([-\d.]+)\.\.([-\d.]+)', r.stdout + r.stderr)
    feat = Path(str(step).rsplit('.', 1)[0] + '-features.json')
    try: feat.unlink()
    except OSError: pass
    if not m: return None
    v = [float(x) for x in m.groups()]
    ext = ([v[0], v[2], v[4]], [v[1], v[3], v[5]])
    span = footprint_span(fp.read_text(errors='ignore')) if fp else None
    if span:
        up = up_axis(ext, span)[0]
        if up != 2: return 'its height is on %s (service bbox %s)' % ('XYZ'[up], m.group(0))
    if abs(v[4]) > 0.06 and v[4] > -0.06 - 3.0 and not (fp and 'thru_hole' in fp.read_text(errors='ignore')):
        return 'not seated: z starts at %.2f mm (service bbox %s)' % (v[4], m.group(0))
    return None


ORIENT_RULES = 3  # 3: Fusion library names are uppercase (ADD cannot find a lowercase name). Bump when the orientation rules change: parts bound under older rules are bound again


def silk_text(t):
    """Board text a small board can carry: each package's >NAME at 0.8 mm (thick enough for a fab's
    0.15 mm line), and its >VALUE on tDocu (51), drawn in the editor but never printed. Fusion copies
    these onto every element when the part is added, so the library is where they are set."""
    def fix(m):
        tag, label = m.group(1), m.group(2)
        tag = re.sub(r'size="[^"]*"', 'size="0.8"', tag)
        tag = tag if 'ratio=' in tag else tag.replace('<text ', '<text ratio="15" ', 1)
        if label == 'VALUE': tag = re.sub(r'layer="27"', 'layer="51"', tag)
        return tag + '&gt;' + label
    def pkg(m):
        return re.sub(r'(<text [^>]*layer="2[57]"[^>]*>)&gt;(NAME|VALUE)', fix, m.group(0))
    return re.sub(r'<package [^>]*>.*?</package>', pkg, t, flags=re.S)


_TAG = re.compile(r'<(?:!--.*?--|!\[CDATA\[.*?\]\]|[?!][^>]*|(?:[^>"\']|"[^"]*"|\'[^\']*\')*)>', re.S)

def break_lines(text):
    """The library with a line break between elements: Fusion 2706 raises PlacePartWorker assetAdded on
    every ADD from a .lbr whose elements share one line (fusion-bridge#135, adom-aiflow#114). Whitespace
    only, and only where it means nothing: before an element that opens, after one that closes, never
    inside an element's own text (an empty <text></text> stays empty), never inside an attribute value."""
    out, last, prev = [], 0, None
    for m in _TAG.finditer(text):
        gap = text[last:m.start()]
        tag = m.group(0)
        if prev is not None and gap == '':
            opens = not tag.startswith('</')
            closed = prev.startswith('</') or prev.endswith('/>') or prev.startswith('<?') or prev.startswith('<!')
            if opens or closed: gap = '\n'
        out.append(gap); out.append(tag); last = m.end(); prev = tag
    out.append(text[last:])
    return ''.join(out)


def _xml_attr(v):
    return str(v).replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')


def _sibling(pairs, urn, name, package):
    """Note that the 3D model `urn` (named after `name`) sits on the footprint `package`."""
    e = pairs.setdefault(urn, (name, []))
    if package and package not in e[1]: e[1].append(package)


def bind_library_3d(t, urns, extra=None):
    """Every part's own 3D model in the library text, on the footprint that part really uses. `urns` is
    {part (deviceset) name in upper case: urn}; `extra` adds {urn: (name, [packages])} for a text with no
    devicesets (a board's copy of the library).

    adom-lbr assemble merges parts with the same footprint into one package, so a part's name is not
    always a package's name. A <package3d> naming a package that is gone makes Fusion 2706 assert on EVERY
    ADD (PlacePartWorker assetAdded; fusion-bridge#136), and looking a device's model up by its package
    gives it another part's body. So each model lists the packages its part uses, and each device names
    its own part's model; one footprint may carry several models."""
    t = re.sub(r'(?s)<packages3d>.*?</packages3d>', '', t); t = re.sub(r'(?s)<package3dinstances>.*?</package3dinstances>', '', t)
    pairs = {u: (n, list(pk)) for u, (n, pk) in (extra or {}).items()}
    for ds in ET.fromstring(t).iter('deviceset'):
        u = urns.get((ds.get('name') or '').upper())
        if not u: continue
        for dv in ds.iter('device'): _sibling(pairs, u, ds.get('name'), dv.get('package'))
    p3 = ''.join('<package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model"><description>%s</description><packageinstances>%s</packageinstances></package3d>'
                 % (_xml_attr(n), _xml_attr(u), _xml_attr(n), ''.join('<packageinstance name="%s"/>' % _xml_attr(k) for k in pk)) for u, (n, pk) in pairs.items() if pk)
    if p3: t = t.replace('</packages>', '</packages><packages3d>' + p3 + '</packages3d>', 1)
    def one(mt):
        u = urns.get(re.search(r'name="([^"]+)"', mt.group(0).split('>', 1)[0]).group(1).upper())
        if not u: return mt.group(0)
        return re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>',
                      lambda d: d.group(0).replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % _xml_attr(u), 1), mt.group(0))
    return re.sub(r'(?s)<deviceset [^>]*>.*?</deviceset>', one, t)


def library_3d_problems(t):
    """What is wrong with a library's 3D bindings: a model naming a package that is not there, or a device
    naming a model that does not list the device's package. Empty when the library is right."""
    root = ET.fromstring(t)
    pks = {p.get('name') for p in root.iter('package')}
    models = {q.get('wip_urn') or q.get('urn'): {i.get('name') for i in q.iter('packageinstance')} for q in root.iter('package3d')}
    out = []
    for q in root.iter('package3d'):
        for i in q.iter('packageinstance'):
            if i.get('name') not in pks: out.append('3D model %s names the package %s, which is not in the library' % (q.get('name'), i.get('name')))
    for ds in root.iter('deviceset'):
        for dv in ds.iter('device'):
            for i in dv.iter('package3dinstance'):
                if dv.get('package') not in models.get(i.get('package3d_urn'), ()): out.append('%s uses a 3D model that does not list its package %s' % (ds.get('name'), dv.get('package')))
    return out


def cmd_library(a):
    for tool, pkg in (('adom-lbr', 'adom/adom-lbr'), ('adom-wiki', 'adom/adom-wiki')):
        if not shutil.which(tool): err('%s is not installed (it turns each part page into Fusion library parts)' % tool, ['adom-wiki pkg install %s, then run this again.' % pkg])
    st = load_state(); name = st.get('library') or a.name or 'Project'
    if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', name): err('library name: letters, digits, _ and - only')
    if not st.get('library'):
        # Fusion remembers every library it has used; a second library with the same name (an earlier run of
        # the same project on this desktop) makes the schematic stop on "several libraries named ...". A new
        # project takes a name no other run on this desktop has; this run keeps it from then on.
        root = box_dir().rsplit('/', 1)[0]
        r = ab('shell_execute', {'command': 'dir /b /s "%s\\%s.lbr"' % (root.replace('/', '\\'), name), 'timeoutSeconds': 30}, timeout=60)
        mine = box_dir().replace('\\', '/').lower().rstrip('/') + '/'
        taken = [l.strip().replace('\\', '/') for l in str(r.get('stdout') or '').splitlines() if l.strip().lower().endswith('.lbr')]
        taken = [l for l in taken if not l.lower().startswith(mine)]
        if taken:
            base = name; name = '%s_%s' % (base, time.strftime('%m%d')); k = 1
            while any(('/%s.lbr' % name).lower() in l.lower() for l in taken): k += 1; name = '%s_%s%s' % (base, time.strftime('%m%d'), chr(96 + k))
            say('another run on this desktop already has a library named %s; this project\'s library is %s' % (base, name))
    st['library'] = name
    want = []
    src = json.loads(Path(a.parts).read_text()) if a.parts.endswith('.json') else []
    if isinstance(src, dict) and 'components' in src:
        # a netlist: each component's MPN; a component with none is a board-only pad, its footprint found
        # by name under --footprints (a KiCad library folder)
        refs = st.setdefault('refs', {})
        for c in src['components']:
            if c.get('mpn'):
                w_ = {'mpn': c['mpn'], 'manufacturer': c.get('mfr')}
                if c.get('page'): w_['page'] = c['page']   # a part from a named wiki page (the test plan's test points)
                want.append(w_); refs[c['ref']] = c['mpn']
            else:
                fpname = (c.get('fp') or '').split(':')[-1]
                hit = next(iter(sorted(Path(a.footprints).rglob(fpname + '.kicad_mod'))), None) if a.footprints and fpname else None
                if not hit: err('%s has no MPN and its footprint %s was not found' % (c['ref'], fpname or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpname])
                pad = re.sub(r'[^A-Za-z0-9_-]', '-', fpname).upper()
                want.append({'mpn': pad, 'footprint': str(hit)}); refs[c['ref']] = pad
        save_state(st)
    else:
        want.extend(src)
    if a.parts.endswith('.csv'):
        for row in csv.DictReader(open(a.parts, newline='')):
            mpn = row.get('mpn') or row.get('MPN')
            if mpn and mpn.strip().lower() not in ('', 'no part'): want.append({'mpn': mpn.strip(), 'ref': row.get('ref') or row.get('Designator')})
    if not want: err('no parts: give --parts parts.json ([{mpn, page?, footprint?}]) or a BOM .csv with an mpn column')
    cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {}
    lib_dir = STATE / 'library'; (lib_dir / 'parts').mkdir(parents=True, exist_ok=True)
    added, kept, unverified, turned = [], [], {}, {}
    seen = set()
    # a part is kept as built only when the adom-lbr that made it and the orientation rules it was checked
    # under are the current ones; otherwise it is made again (its 3D binding survives when the rules match)
    lbr_ver = (run(['adom-lbr', '--version']).stdout or '').strip().split()[-1:] or ['?']; lbr_ver = lbr_ver[0]
    for w in want:
        mpn = w['mpn']
        if mpn in seen: continue
        seen.add(mpn)
        prev_ = st['parts'].get(mpn, {})
        current = prev_.get('lbrVersion') == lbr_ver and (prev_.get('boardOnly') or prev_.get('orient') == ORIENT_RULES)
        if prev_ and current and (prev_.get('urn') or prev_.get('boardOnly')):
            kept.append(mpn); continue
        pdir = STATE / 'pages' / re.sub(r'[^A-Za-z0-9._-]', '_', mpn)
        out_lbr = lib_dir / 'parts' / (mpn + '.lbr')
        if w.get('footprint'):  # a board-only part (test pad) from a footprint file
            r = run(['adom-lbr', 'generate', '--fp', w['footprint'], '--name', mpn.upper(), '--package-name', mpn.upper(), '-o', str(out_lbr)])
            if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, clip(r.stdout + r.stderr, 300)))
            st['parts'][mpn] = {'lbr': str(out_lbr), 'step': None, 'boardOnly': True, 'numbers': {}, 'lbrVersion': lbr_ver}
            added.append(mpn); say('part %s: board only (footprint %s)' % (mpn, Path(w['footprint']).name)); continue
        slug = w.get('page') or mpn.lower()
        shutil.rmtree(pdir, ignore_errors=True)  # always the page as it is now (its numbers may have been added since)
        if True:
            r = run(['adom-wiki', 'repo', 'clone', slug, '--dir', str(pdir)], timeout=900)
            if r.returncode != 0 or not (pdir / 'page.json').is_file():
                bundled = carried_part(slug)
                if bundled:
                    shutil.rmtree(pdir, ignore_errors=True); shutil.copytree(bundled, pdir)
                    say('part %s: the page %s cannot be read from this account, so using the copy carried in AI Flow' % (mpn, slug))
            if not (pdir / 'page.json').is_file():
                err('%s: no wiki component page at %s' % (mpn, slug), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in parts.json: {"mpn": "%s", "page": "<owner>/<slug>"}.' % mpn])
        page = json.loads((pdir / 'page.json').read_text()); comp = page.get('component', {})
        sym, outline, fp, step = pick_files(pdir, comp)
        if not fp: err('%s: the page has no .kicad_mod footprint' % mpn)
        numbers, notes = lookup_numbers(mpn, comp.get('distributor_links'), cache)
        meta = dict(page); meta['component'] = dict(comp); meta['component']['distributor_links'] = numbers
        if not meta['component'].get('manufacturer') and w.get('manufacturer'): meta['component']['manufacturer'] = w['manufacturer']
        (pdir / 'page.meta.json').write_text(json.dumps(meta))
        choice = w.get('symbol') or symbol_choice(comp, fp.read_text(errors='ignore'), sym)
        if choice == 'outline' and outline: use = outline
        elif choice in DEVICE_SYMBOLS:
            use = STATE / 'symbols' / (choice + '.kicad_sym'); use.parent.mkdir(parents=True, exist_ok=True); use.write_text(DEVICE_SYMBOLS[choice])
        else: use = sym
        args = ['adom-lbr', 'generate', '--fp', str(fp), '--part-metadata', str(pdir / 'page.meta.json'), '--name', mpn.upper(), '--package-name', mpn.upper(), '-o', str(out_lbr)]
        if use: args[2:2] = ['--sym', str(use)]
        if choice in DEVICE_SYMBOLS: args += ['--symbol-layout', 'keep']
        r = run(args)
        if 'Lint PASSED' not in r.stdout + r.stderr: err('%s: adom-lbr generate failed: %s' % (mpn, clip(r.stdout + r.stderr, 300)))
        missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]
        stock = (meta['component'].get('manufacturer') or '').strip().lower() == 'adom'
        if missing and not stock: unverified[mpn] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])
        onote = None
        if step:
            step, onote = upright_model(step, fp, STATE / 'models')
            if onote: turned[mpn] = onote
            bad = service_pose(step, fp)
            if bad: err('%s: its 3D model is not right side up as CAD tools read it: %s' % (mpn, bad), ['Fix the component page (the STEP must be Z-up, seated on z=0, centred, as `step2glb features` reads it, not as raw coordinates say), then `fusion library` again. The 2026-10-07 inductor bug: a frame turned with the geometry.'])
        prev = st['parts'].get(mpn, {})
        if prev.get('urn') and prev.get('orient') != ORIENT_RULES: prev = {k: v for k, v in prev.items() if k != 'urn'}  # bound before the check: bind again
        st['parts'][mpn] = {**prev, 'lbr': str(out_lbr), 'step': str(step) if step else None, 'numbers': numbers, 'symbol': choice, 'orient': ORIENT_RULES, 'orientNote': onote, 'lbrVersion': lbr_ver}
        added.append(mpn)
        say('part %s: %s symbol, model %s, %s' % (mpn, choice, step.name if step else 'none', 'Adom stock' if stock else 'Mouser %s, DigiKey %s, LCSC %s' % (numbers.get('mouser', '-'), numbers.get('digikey', '-'), numbers.get('lcsc', '-'))))
    save_state(st)
    # every part's checked (Z-up) model where adom-fields looks for a Fusion board's parts: <run>/3d/<MPN>.step
    d3 = RUN / '3d'; d3.mkdir(parents=True, exist_ok=True)
    for m, p_ in st['parts'].items():
        if p_.get('step') and Path(p_['step']).is_file(): shutil.copy(p_['step'], d3 / (m + '.step'))
    # the one library: every part, user values allowed (VALUE never asks), assembled into <name>.lbr
    combined = lib_dir / (name + '.lbr')
    lbrs = sorted(str(Path(p['lbr'])) for p in st['parts'].values())
    r = run(['adom-lbr', 'assemble', *lbrs, '-o', str(combined), '--name', name], check=True)
    t = combined.read_text()
    t = re.sub(r'<deviceset ((?:(?!uservalue)[^>])*)>', lambda m: '<deviceset %s uservalue="yes">' % m.group(1), t)
    t = silk_text(t)
    combined.write_text(break_lines(t))
    # 3D: build only the parts that have no binding yet, then carry every binding into the library
    todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]
    bdir = box_dir() + '/library'
    models_home = ''
    why3d = {}  # each distinct failure of the 3D build, with the parts it stopped
    if todo:
        stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); (stage / 'lib').mkdir(parents=True); (stage / 'models').mkdir()
        for m in todo:
            shutil.copy(st['parts'][m]['lbr'], stage / 'lib' / (m + '.lbr')); shutil.copy(st['parts'][m]['step'], stage / 'models' / (m + '.step'))
        send(sorted(stage.glob('lib/*')), bdir + '/lib'); send(sorted(stage.glob('models/*')), bdir + '/models'); send([combined], bdir)
        say('binding real 3D to %d part(s) in Fusion (about a minute each)' % len(todo))
        # the models' cloud home: the run's project and folder (a bridge that does not take them yet puts
        # them under the project open in Fusion's data panel; the reply says which)
        project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}; save_state(st)
        r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True, 'projectName': project, 'folderPath': folder,
                                           'parts': [{'package': m.upper(), 'lbrPath': bdir + '/lib/' + m + '.lbr', 'modelPath': bdir + '/models/' + m + '.step'} for m in todo]}, timeout=300)
        if r.get('success') is False:  # refused at the start: nothing is building, so there is nothing to wait for
            (STATE / 'library' / 'build3d.json').write_text(json.dumps({'start': r}, indent=1))
            err('the 3D build did not start: %s' % clip(str(r.get('error') or r), 400), [str(r.get('_hint') or ''), 'The reply is in <run>/fusion/library/build3d.json. Parts already bound are kept; run this again when it is fixed.'])
        t0 = time.time(); res = {}
        while time.time() - t0 < 60 * (5 + 3 * len(todo)):
            time.sleep(20)
            pr = ab('fusion_prove_result', {}).get('result') or {}
            if pr.get('boundLbr', '').endswith(name + '_3d.lbr') and pr.get('partsDone') == len(todo) and all((x.get('package') or '').upper() in {m.upper() for m in todo} for x in pr.get('parts', [])):
                res = pr; break
            if pr.get('stage'): say('  3D: %s' % pr.get('stage'))
        if not res: err('the 3D build did not finish in time', ['Read fusion_prove_result; run `adom-aiflow fusion library` again: parts already bound are kept and only the rest are built.'])
        (STATE / 'library' / 'build3d.json').write_text(json.dumps({'start': r, 'result': res}, indent=1))
        # only a project the bridge names counts: its build result does not echo one (1.13.47 saved the
        # models in --project/--folder and said nothing, and this used to claim they went elsewhere)
        went = res.get('projectName') or r.get('projectName')
        if went and went != project:
            models_home = 'The 3D models went to %s, not %s/%s: this Fusion bridge does not take a destination for them. Update the Fusion bridge (1.12.0+ saves them in --project/--folder), or make %s the active project in the data panel before `fusion library`. The build\'s replies are in <run>/fusion/library/build3d.json.' % (went, project, folder, project)
        back = {m.upper(): m for m in st['parts']}
        for x in res.get('parts', []):
            if x.get('success') and x.get('wip_urn'): st['parts'][back.get(x['package'].upper(), x['package'])]['urn'] = x['wip_urn']
            elif not x.get('success') and x.get('error'): why3d.setdefault(clip(str(x['error']), 240), []).append(x.get('package') or '?')
        save_state(st)
    # inject every binding (this build's and earlier ones) into the library, then put it on the box
    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) deviceset name
    t = bind_library_3d(combined.read_text(), urns)
    bad3d = library_3d_problems(t)
    if bad3d: err('the library\'s 3D bindings are wrong: %s' % '; '.join(bad3d[:6]), ['This is a bug in AI Flow, not your parts: Fusion would refuse every part. Report it on adom/adom-aiflow with <run>/fusion/library/.'])
    combined.write_text(break_lines(t)); send([combined], bdir)
    st['libraryPath'] = bdir + '/' + combined.name; save_state(st)
    unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]
    prove('library', {'name': name, 'parts': len(st['parts'])})
    ok('library %s: %d part(s) (%d added, %d kept), %d with real 3D, one library at %s' % (name, len(st['parts']), len(added), len(kept), len(urns), st['libraryPath']),
       ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `fusion library` again when the services are back, or add the numbers to the part\'s wiki page (component.distributor_links).' if unverified else '',
        '3D not bound yet: %s; run this again to retry them' % ', '.join(unbound) if unbound else '',
        ('Why: ' + '; '.join('%s (%s)' % (w, ', '.join(ps)) for w, ps in why3d.items()) + '. These parts still add to the schematic, with flat placeholders in 3D. The replies are in <run>/fusion/library/build3d.json.') if why3d else '',
        ('3D turned upright before binding: ' + '; '.join('%s (%s)' % (m, n) for m, n in turned.items()) + '.') if turned else '',
        'Check the 3D yourself before going on: a model can be authored Y-up while Fusion and the board are Z-up, and a part bound that way lies on its side (an electrolytic can on its flank, an inductor on edge). '
        'This step already applied each footprint\'s own model transform and checked every model against its footprint, but look anyway: once the board exists, '
        '`adom-aiflow fusion view3d` (a still from Fusion\'s own renderer, then a live orbit), and every part must sit flat on its pads the way its datasheet drawing shows. '
        'A part that does not: say which, and fix its model or its footprint\'s (model ... (rotate ...)) entry, then run `fusion library` again.',
        models_home,
        'Next: adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --name <Design> (the design starts from this library). New parts later: run `fusion library` again with them; they join this same library.'])


# ---------------------------------------------------------------- reference

def kicad_footprints(text):
    out = {}
    for m in re.finditer(r'\(footprint "([^"]+)".*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(property "Reference" "([^"]+)"', text, re.S):
        out[m.group(5)] = (float(m.group(2)), float(m.group(3)), float(m.group(4) or 0) % 360)
    return out


def kicad_outline(text):
    xs, ys = [], []
    for m in re.finditer(r'\(gr_(?:line|rect)[^()]*\(start ([-\d.]+) ([-\d.]+)\)\s*\(end ([-\d.]+) ([-\d.]+)\).*?\(layer "Edge.Cuts"\)', text, re.S):
        xs += [float(m.group(1)), float(m.group(3))]; ys += [float(m.group(2)), float(m.group(4))]
    if not xs: err('the reference board has no Edge.Cuts outline')
    return min(xs), min(ys), max(xs), max(ys)


def cmd_reference(a):
    text = Path(a.kicad).read_text(); x0, y0, x1, y1 = kicad_outline(text)
    fps = kicad_footprints(text); spec = json.loads(Path(a.spec).read_text())
    # the Fusion board's frame: lower-left corner at 0,0, y up; the run's KiCad frame is (x, -y) of it
    def to_run(x, y): return round(x - x0, 3), round(y - y1, 3)
    fixed = set(spec.get('fixedRefs') or [])
    moves = {r: {'x': to_run(x, y)[0], 'y': to_run(x, y)[1], 'rotation': rot} for r, (x, y, rot) in fps.items() if r not in fixed}
    out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
    (out / 'moves.json').write_text(json.dumps(moves, indent=1))
    fixed_pos = {r: {'x': round(x - x0, 3), 'y': round(y1 - y, 3), 'rot': rot} for r, (x, y, rot) in fps.items() if r in fixed}
    (out / 'fixed.json').write_text(json.dumps({'outlineMm': [round(x1 - x0, 3), round(y1 - y0, 3)], 'fixed': fixed_pos}, indent=1))
    moved = 0
    def tr(p): return [round(p[0] - x0, 3), round(p[1] - y1, 3)]
    for key in ('pours', 'planeUnder'):
        for z in spec.get(key, []) or []:
            if 'polygon' in z and z['polygon'] and any(abs(v) > 40 for pt in z['polygon'] for v in pt): z['polygon'] = [tr(p) for p in z['polygon']]; moved += 1
    for v in spec.get('stitchVias', []) or []:
        if abs(v.get('x', 0)) > 40 or abs(v.get('y', 0)) > 40: v['x'], v['y'] = tr([v['x'], v['y']]); moved += 1
    spec['frameNote'] = "Coordinates are in the Fusion board's frame (reference board shifted by (%g, %g))." % (-x0, -y1)
    (out / 'spec.json').write_text(json.dumps(spec, indent=1))
    dru = Path(a.kicad).with_suffix('.kicad_dru')
    if dru.is_file(): shutil.copy(dru, out / 'board.kicad_dru')  # the fab's rules travel with the design
    ok('reference %s: %d part placements, %d fixed parts, outline %.2f x %.2f mm, %d spec coordinate(s) moved into the board frame' % (Path(a.kicad).name, len(moves), len(fixed_pos), x1 - x0, y1 - y0, moved),
       ['Wrote %s/moves.json (for place check/land), %s/fixed.json (for fusion schematic) and %s/spec.json (use it for start).' % (out, out, out)])


# ---------------------------------------------------------------- schematic

def wired_groups(layout, lib, by_ref, values, libname, fixed_refs):
    """A wired sheet from a hand layout: every part where the layout puts it and every net drawn as wires
    from pin to pin, so the sheet reads as a circuit rather than a list of parts with net names.
    layout: {"groups": [{"name", "text": [x, y]?, "parts": {ref: [x, y, rot]}, "nets": {net: [[point, ...], ...]},
    "labels": [[net, x, y]]}]}; a point is "REF.PIN" (pad or pin name) or [x, y] in mm. The fixed parts the
    seed drew are reached by coordinates (their stub ends). Points are checked: every "REF.PIN" must exist."""
    syms = {s.get('name'): s for s in lib.iter('symbol')}
    pins_of = {}  # deviceset -> {pad or pin name: (x, y)}
    for d in lib.iter('deviceset'):
        g = next(d.iter('gate')); s = syms[g.get('symbol')]
        pin_xy = {p.get('name'): (float(p.get('x')), float(p.get('y'))) for p in s.iter('pin')}
        m = dict(pin_xy)
        for dv in d.iter('device'):
            for c in dv.iter('connect'):
                for pad in c.get('pad', '').split(): m.setdefault(pad, pin_xy[c.get('pin')])
        pins_of[d.get('name').upper()] = m
    placed = {}
    for g in layout['groups']:
        for r, (x, y, rot) in g.get('parts', {}).items(): placed[r] = (float(x), float(y), rot or 'R0')
    def pt(p):
        if isinstance(p, (list, tuple)): return (float(p[0]), float(p[1]))
        r, pin = p.split('.', 1)
        if r not in placed: err('layout: %s is not placed (only the parts in "parts" can be reached by pin)' % p)
        x, y, rot = placed[r]
        px, py = pins_of[by_ref[r].upper()].get(pin) or err('layout: %s has no pin %s' % (by_ref[r], pin))
        k = int(rot.lstrip('MR') or 0) // 90 % 4
        for _ in range(k): px, py = -py, px
        if rot.startswith('M'): px = -px
        return (round(x + px, 3), round(y + py, 3))
    f = lambda q: '(%.3fmm %.3fmm)' % q
    missing = [r for r in by_ref if r not in placed and r not in fixed_refs]
    if missing: err('layout: no place for %s' % ', '.join(missing), ['Every part that is not fixed needs an entry in a group\'s "parts".'])
    groups = []
    for g in layout['groups']:
        cmds = []
        if g.get('text'):
            cmds += ['LAYER 97;', 'CHANGE SIZE 2.54mm;', "TEXT '%s' R0 %s;" % (g['name'], f(tuple(g['text']))), 'CHANGE SIZE 1.778mm;']
        cmds.append('LAYER 91;')
        for r, (x, y, rot) in g.get('parts', {}).items():
            cmds += ["ADD '%s@%s' '%s' %s %s;" % (by_ref[r], libname, r, rot or 'R0', f((float(x), float(y)))), "VALUE '%s' '%s';" % (r, values.get(r) or by_ref[r])]
        for n, paths in g.get('nets', {}).items():
            for path in paths:
                cmds.append("NET '%s' %s;" % (n, ' '.join(f(pt(p)) for p in path)))
        for n, x, y in g.get('labels', []):
            cmds.append('LABEL %s %s;' % (f((x, y)), f((x, y))))
        groups.append({'group': g['name'], 'parts': list(g.get('parts', {})), 'commands': cmds})
    return groups


# fusion_schematic_run refuses an ADD past this (off_the_sheet): past about 2 m Fusion drops the part silently
SHEET_LIMIT_MM = 1950.0
# fusion_schematic_plan puts a block's parts in one row and up to 3 frames in a row of the sheet: 3 frames this wide
# stay well inside the limit, and a frame stays small enough to read
FRAME_MAX_MM = 400.0
# a group small enough to answer as one call; one part with very many pins can still go past it (see run_group)
GROUP_MAX_COMMANDS = 380
FRAME_COMMANDS = 10
# a long list answers stillRunning with a runId after about 100 s (fusion-bridge 1.13.64, #137); Adom Bridge
# gives the run 20 minutes. A poll that gets no answer says nothing about the run, so a few are allowed.
RUN_POLL_S = 5
RUN_BUDGET_S = 1260
RUN_DROPPED_POLLS = 6


def run_group(args, timeout=180):
    """One fusion_schematic_run to its end: the whole reply, however long Fusion takes. The list is sent once.
    A final answer always carries stillRunning false, so a reply without it is a poll that got no answer."""
    r = ab('fusion_schematic_run', args, timeout=timeout)
    rid, start, dropped, polls = r.get('runId'), time.time(), 0, 0
    while r.get('stillRunning') is True:
        if time.time() - start > RUN_BUDGET_S:
            return {'success': False, 'errorCode': 'still_running', 'runId': rid, 'error': 'Fusion was still drawing after %d s' % RUN_BUDGET_S}
        time.sleep(RUN_POLL_S)
        polls += 1
        if polls % 6 == 0: say('still drawing, %d s so far' % (time.time() - start))
        try: p = ab('fusion_schematic_run', {'runId': rid}, timeout=60)
        except subprocess.TimeoutExpired: p = {'error': 'no answer in 60 s'}
        if 'stillRunning' in p: r, dropped = p, 0; continue
        dropped += 1
        if p.get('errorCode') == 'unknown_run_id' or dropped > RUN_DROPPED_POLLS:
            return {'success': False, 'errorCode': 'run_lost', 'runId': rid, 'error': 'polling run %s failed: %s' % (rid, p.get('error'))}
    return r


# An electronics design that was never saved or changed and has no schematic or board open under its name. Anything
# else named Untitled may be someone's work and stays as it is.
BLANK_DESIGN = '''import adsk.core
app = adsk.core.Application.get()
docs = [app.documents.item(i) for i in range(app.documents.count)]
result = {'reused': False}
for d in docs:
    if not d.name.startswith('Untitled') or d.dataFile is not None or d.isModified or d.isSaved: continue
    if [p.productType for p in d.products] != ['EcadDesignProductType']: continue
    kin = [x for x in docs if x.name == d.name and x.classType() != d.classType() and 'electron' in x.classType()]
    if kin or any(x.isModified for x in docs if x.name == d.name and 'electron' in x.classType()): continue
    d.activate()
    result = {'reused': True}
    break
'''


def reuse_blank_design():
    """Bring an empty electronics design to the front if one is open. False (open a new one) when none is, or on any doubt."""
    r = ab('fusion_run_modeling_script', {'script': BLANK_DESIGN})
    res = (r.get('data') or r).get('result')
    return isinstance(res, dict) and res.get('reused') is True


def symbol_sizes(lib):
    """{DEVICESET: (symbol width mm, pins)}, measured the way fusion_schematic_plan does (its first gate's symbol)."""
    syms = {s.get('name'): s for s in lib.iter('symbol')}
    sizes = {}
    for d in lib.iter('deviceset'):
        g = d.find('gates/gate')
        s = syms.get(g.get('symbol')) if g is not None else None
        if s is None: continue
        xs = [0.0] + [float(p.get('x')) for p in s.iter('pin')]
        for w in s.iter('wire'): xs += [float(w.get('x1')), float(w.get('x2'))]
        sizes[d.get('name').upper()] = (max(xs) - min(xs), sum(1 for _ in s.iter('pin')))
    return sizes


def split_blocks(parts, sizes):
    """Cut a block too wide or too long for one frame into several (issue #133), in netlist order. Each part takes
    its symbol's width plus the planner's 25.4 mm of label room, and at most ADD, VALUE and NET + LABEL per pin.
    Returns (parts with their block renamed where it was cut, [(block, parts, frames)]). A block that fits is untouched."""
    order = []
    for q in parts:
        if (q.get('block') or 'parts') not in order: order.append(q.get('block') or 'parts')
    out, split = {}, []
    for b in order:
        chunks, cur, width, cmds = [], [], 7.62, FRAME_COMMANDS
        for q in [q for q in parts if (q.get('block') or 'parts') == b]:
            w, pins = sizes.get(str(q.get('deviceset')).upper(), (25.4, 8))
            pw, pc = w + 25.4, 2 + 2 * pins
            if cur and (width + pw > FRAME_MAX_MM or cmds + pc > GROUP_MAX_COMMANDS):
                chunks.append(cur); cur, width, cmds = [], 7.62, FRAME_COMMANDS
            cur.append(q); width += pw; cmds += pc
        chunks.append(cur)
        if len(chunks) > 1: split.append((b, sum(len(c) for c in chunks), len(chunks)))
        for i, c in enumerate(chunks):
            for q in c: out[q['ref']] = dict(q, block=b if len(chunks) == 1 else '%s %d of %d' % (b, i + 1, len(chunks)))
    return [out[q['ref']] for q in parts], split


def off_sheet(groups):
    """Every planned ADD past Fusion's limit, as (ref, x, y, group). The bridge would refuse the first mid-sheet."""
    bad = []
    for g in groups:
        for c in g['commands']:
            m = re.match(r"\s*ADD\s+'[^']*'\s+'([^']*)'.*\(\s*(-?[\d.]+)mm\s+(-?[\d.]+)mm\s*\)", c)
            if m and max(abs(float(m.group(2))), abs(float(m.group(3)))) > SHEET_LIMIT_MM:
                bad.append((m.group(1), float(m.group(2)), float(m.group(3)), g['group']))
    return bad


def cmd_schematic(a):
    st = load_state()
    if not st.get('libraryPath'): err('no project library yet', ['Run `adom-aiflow fusion library --parts <parts>` first.'])
    cloud_place(a, st); save_state(st)  # settled now: the design's save comes after the whole sheet is drawn
    nl = json.loads(Path(a.netlist).read_text()); spec = json.loads(Path(a.spec).read_text()) if a.spec else {}
    fx = json.loads(Path(a.fixed).read_text()) if a.fixed else {'outlineMm': a.outline, 'fixed': {}}
    by_ref = {}
    mpn_of = {c['ref']: c.get('mpn') for c in nl.get('components', [])}
    mpn_of.update(st.get('refs') or {})
    for r, m in (json.loads(Path(a.mpns).read_text()) if a.mpns else {}).items(): mpn_of[r] = m
    lib = ET.parse(STATE / 'library' / (st['library'] + '.lbr')).getroot()
    dsets = {d.get('name').upper(): d.get('name') for d in lib.iter('deviceset')}
    for c in nl['components']:
        m = mpn_of.get(c['ref'])
        if not m or m.upper() not in dsets: err('%s: no part %s in the project library' % (c['ref'], m), ['Add it with `adom-aiflow fusion library` (a board-only pad takes {"mpn": "<name>", "footprint": "<file.kicad_mod>"}), or map the reference with --mpns refs.json.'])
        by_ref[c['ref']] = dsets[m.upper()]
    name = a.name
    rules = {'clearance': spec.get('clearance', 0.15), 'edge': spec.get('edgeClearance', 0.3), 'minWidth': 0.1, 'minDrill': 0.15, 'holeToHole': 0.2,
             'padRestringRatio': 0.15, 'padRestring': 0.2, 'viaRestring': 0.1, 'copperUm': spec.get('copperUm', 35)}
    rules.update(spec.get('fusionRules') or {})
    nets = nl.get('nets', {})
    pin_net = {}
    for n, ms in nets.items():
        for m in ms: pin_net[m] = n
    fixed = []
    for r, p in fx['fixed'].items():
        fixed.append({'ref': r, 'deviceset': by_ref[r], 'value': next((n for k, n in pin_net.items() if k.split('.')[0] == r), ''),
                      'x': p['x'], 'y': p['y'], 'rot': ('R%d' % int(p.get('rot', 0))) if p.get('rot') else None,
                      'nets': {k.split('.', 1)[1]: n for k, n in pin_net.items() if k.split('.')[0] == r}})
    # the whole sheet is planned and checked first: a layout Fusion can't draw stops here, before any document opens
    fixed_refs = set(fx['fixed'])
    parts = [{'ref': c['ref'], 'deviceset': by_ref[c['ref']], 'value': c.get('value'), 'block': c.get('block')} for c in nl['components'] if c['ref'] not in fixed_refs]
    if a.layout:
        plan = {'groups': wired_groups(json.loads(Path(a.layout).read_text()), lib, by_ref, {c['ref']: c.get('value') for c in nl['components']}, Path(st['libraryPath'].replace('\\', '/')).stem, fixed_refs)}
    else:
        parts, split = split_blocks(parts, symbol_sizes(lib))
        for b, n, k in split: say('block %s has %d parts: drawing it in %d frames, so it stays on the sheet' % (b, n, k))
        plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})
    if not plan.get('groups'): err('schematic plan: %s' % plan.get('error'))
    if fixed_refs and not a.layout:
        # the seed draws the fixed parts (a molecule's pins and contacts) as a column at the sheet's left edge;
        # the planner starts its groups at that same x, so move every group clear of the column
        shift = 45.72
        mv = lambda m: '(%.3fmm %smm)' % (float(m.group(1)) + shift, m.group(2))
        for g in plan['groups']:
            g['commands'] = [re.sub(r'\(([-\d.]+)mm ([-\d.]+)mm\)', mv, c) for c in g['commands']]
    off = off_sheet(plan['groups'])
    if off:
        err('%d part(s) would land past Fusion\'s %g mm sheet limit, e.g. %s. Nothing was drawn.' % (len(off), SHEET_LIMIT_MM, ', '.join('%s at (%g, %g) in %s' % o for o in off[:3])),
            ['Move those parts in your --layout file.' if a.layout else 'A single part this wide is the likely cause: check its symbol in the project library, or lay the sheet out yourself with --layout.'])
    bdir = box_dir() + '/design'
    say('starting the design with the fab rules (clearance %.2f mm, edge %.2f mm, 0.15 mm drill) and %d fixed part(s)' % (rules['clearance'], rules['edge'], len(fixed)))
    r = ab('fusion_board_seed', {'libraryPath': st['libraryPath'], 'outDir': bdir, 'name': 'Seed', 'title': nl.get('title', name), 'outlineMm': fx['outlineMm'], 'fixed': fixed, 'rules': rules})
    if not r.get('success'): err('seed: %s' % r.get('error'), [r.get('_hint')])
    # the import starts from an empty electronics design and lands in its own document, so that one stays open and
    # empty: use one that's already there instead of adding another Untitled each run (issue #133)
    if reuse_blank_design(): say('starting from the empty Untitled electronics design already open')
    else: ab('fusion_execute_text_command', {'command': 'Commands.Start NewElectronDesignDocumentCommand'})
    for _ in range(20):  # the import starts from an electronics design that has settled
        time.sleep(2)
        st_ = ab('fusion_get_app_state', {}); d_ = st_.get('data') or st_
        if d_.get('productType') == 'EcadDesignProductType' and not d_.get('busyCommand'): break
    time.sleep(3)
    r = ab('fusion_new_electronics_from_eagle', {'schPath': r['schPath'], 'brdPath': r['brdPath']})
    rid = r.get('runId'); s = {}
    for _ in range(60):
        time.sleep(6)
        s = ab('fusion_eagle_import_status', {'runId': rid})
        if s.get('stage') in ('done', 'failed'): break
    keep_reply('import', {'runId': rid, 'start': r, 'status': s})
    if s.get('stage') != 'done': err('the design import (runId %s) did not finish: %s' % (rid, str((s.get('result') or {}).get('error'))[:300]), ['Start from a clean Fusion session (save and close half-finished designs) and run this again. The import\'s replies are in <run>/fusion/schematic/.'])
    ab('fusion_schematic', {'action': 'show'}); time.sleep(3)
    sheet = {}
    for g in plan['groups']:
        say('drawing %s: %s' % (g['group'], ', '.join(g['parts'])))
        r = run_group({'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms})
        saved = keep_reply('run-%02d-%s' % (plan['groups'].index(g) + 1, g['group']), {'group': g['group'], 'parts': g['parts'], 'commands': g['commands'], 'reply': r})
        if not r.get('success'): schematic_failure(g, r, saved)
        sheet = r.get('nets') or sheet
    # pin by pin against the netlist (pads and pin names both count)
    pad2pin = {}
    for d in lib.iter('deviceset'):
        for dv in d.iter('device'):
            for c in dv.iter('connect'):
                for pad in c.get('pad', '').split(): pad2pin[(d.get('name').upper(), pad)] = c.get('pin')
    want = {n: sorted('%s.%s' % (m.split('.')[0], pad2pin.get((by_ref[m.split('.')[0]].upper(), m.split('.', 1)[1]), m.split('.', 1)[1])) for m in ms) for n, ms in nets.items()}
    got = {n: sorted(v) for n, v in sheet.items()}
    bad = [n for n in want if want[n] != got.get(n)]
    if bad: err('the sheet differs from the netlist on %s' % ', '.join(bad), ['Compare with fusion_schematic_run {"commands":["WINDOW FIT;"]} (its nets) and fix the netlist or the library pins.'])
    ab('fusion_show_2d_board', {}); time.sleep(3)
    bi = ab('fusion_board_info', {}); n = ((bi.get('data') or bi).get('summary') or (bi.get('data') or bi)).get('componentCount')
    if n != len(nl['components']): err('the board has %s of %d parts: schematic/board sync is off' % (n, len(nl['components'])), ['Start from a clean Fusion session (close half-finished imported designs) and run this again.'])
    # 3D on the board: a part added live arrives without the library's 3D binding (Fusion then draws a flat
    # placeholder box in the 3D PCB). Count them and put them back before the design is saved.
    # A part's footprint may be named after another part (adom-lbr merges equal footprints), so go by the part.
    want3d = {m.upper() for m, p_ in st['parts'].items() if p_.get('urn')}
    def wants_3d(e):
        ref = re.search(r'name="([^"]+)"', e).group(1)
        return (by_ref[ref].upper() if ref in by_ref else ((re.search(r'package="([^"]+)"', e) or [None, ''])[1] or '').upper()) in want3d
    def missing3d():
        cur = ab('fusion_get_app_state', {}); cur = (cur.get('data') or cur).get('activeDocument') or name
        sb = ab('fusion_board_source', {'expectDocument': cur})
        if not sb.get('source'): err('could not read the board to check its 3D: %s' % sb.get('error'), ['Show the board (fusion_show_2d_board) and run this again.'])
        return sorted(re.search(r'name="([^"]+)"', e).group(1) for e in re.findall(r'<element [^>]*>', sb.get('source') or '')
                      if wants_3d(e) and 'package3d_urn' not in e)
    lost = missing3d(); restored = []
    if lost:
        restored = restore_3d(st, name, *cloud_place(a, st), by_ref=by_ref)
        ab('fusion_show_2d_board', {}); time.sleep(3)
        lost = missing3d()
    project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}
    save_hints = save_design(name, project, folder)
    src = ab('fusion_board_source', {'expectDocument': name})
    if not src.get('source'): err('could not read the board back: %s' % src.get('error'))
    out = Path(a.board_out); out.write_text(src['source'])
    dru = Path(a.fixed).parent / 'board.kicad_dru' if a.fixed else None
    if dru and dru.is_file(): shutil.copy(dru, out.with_suffix('.kicad_dru'))
    st['design'] = name; save_state(st)
    prove('schematic', {'name': name, 'groups': len(plan['groups'])})
    ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s in %s/%s; board written to %s' % (len(plan['groups']), n, name, project, folder, out),
       save_hints + [('3D: Fusion dropped the 3D models of %d part(s) when they were added; they were put back (the design was imported again with every binding on it): %s.' % (len(restored), ', '.join(restored))) if restored else '',
        ('3D: %d part(s) still show as flat placeholder boxes: %s. Read <run>/fusion/restore3d/ and fusion_check_dialogs.' % (len(lost), ', '.join(lost))) if lost else '',
        'Look at the board in 3D before going on: `adom-aiflow fusion view3d` saves a still from Fusion\'s own renderer and orbits the live view, and every part must sit on its pads the way its datasheet shows.',
        'Next: simulate it before the board: the Adom SPICE dashboard (adom-spice start / study / run ngspice / show, full screen in a Hydrogen webview); change values in the schematic if a measurement misses; record it with `evidence add --kind simulation`.',
        'Then the board: adom-aiflow start --board %s --spec <spec.json> --target %s --remote-board fusion:%s --engine <you> --prompt-time <paste time>' % (out, TARGET or '<box>', name)])


def keep_reply(key, data):
    """A bridge reply kept whole in the run (<run>/fusion/schematic/<key>.json), for the next report."""
    d = STATE / 'schematic'; d.mkdir(parents=True, exist_ok=True)
    f = d / (re.sub(r'[^A-Za-z0-9._-]+', '-', key) + '.json')
    f.write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), **data}, indent=1))
    return f


ASSET_ADDED = ('Fusion 2706 raises "PlacePartWorker.cpp ... Condition: assetAdded" on an ADD from a library whose XML elements '
               'share one line (fusion-bridge#135): this step writes the library with line breaks, and Fusion bridge 1.13.47+ '
               'rewrites a compact one before loading it. Update the Fusion bridge if it is older, then run this step again.')


def schematic_failure(g, r, saved):
    """A failed fusion_schematic_run group, said in words, with the full reply's path."""
    code = r.get('errorCode') or ''
    where = 'The whole reply is in %s.' % saved
    retry = 'Close the design without saving (fusion_close_document {save:false}) before you retry.'
    if code in ('still_running', 'run_lost'):
        err('schematic group %s: %s' % (g['group'], r.get('error')),
            ['Fusion may still be drawing this group, so don\'t send the list again. Poll fusion_schematic_run {"runId":"%s"} until stillRunning is false, then read fusion_check_dialogs.' % r.get('runId'), retry, where])
    if code == 'add_failed':
        err('schematic group %s: the ADD of %s did not land (command %s of the group). %s' % (g['group'], r.get('part'), r.get('failedAt'), r.get('error')),
            [ASSET_ADDED, 'The commands before it ran. ' + retry, where])
    if code == 'fusion_assert':
        err('schematic group %s: Fusion raised an internal error during the run: %s' % (g['group'], '; '.join(str(x) for x in (r.get('fusionAsserts') or [r.get('error')]))),
            [ASSET_ADDED if 'assetAdded' in json.dumps(r) else 'Read fusion_check_dialogs and the reply below.', retry, where])
    if code == 'no_3d_package':
        err('schematic group %s: %s' % (g['group'], r.get('error')),
            ['Fusion bridge 1.13.46 refused parts without a 3D package; that refusal was wrong (the cause of fusion-bridge#135 is the library\'s layout, not 3D) and 1.13.47 drops it. Update the Fusion bridge, then run this step again.', where])
    err('schematic group %s: %s%s' % (g['group'], ('[%s] ' % code) if code else '', r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.', where])


def restore_3d(st, name, project, folder, by_ref=None):
    """Fusion drops a part's 3D binding when the part is added from a library file (the 3D PCB then draws a
    flat placeholder box). The design is exported, every schematic part and board element gets its own
    part's package3d_urn, the library gets its packages3d, and the design is imported again. `by_ref` is
    {reference: deviceset}: footprints may be shared, so a model is chosen by part, not by package.
    Returns the parts that were restored."""
    urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')}  # keyed by Fusion's (uppercase) deviceset name
    by_ref = by_ref or {}
    lib = st['library']; bdir = box_dir() + '/design'; loc = STATE / 'restore3d'; loc.mkdir(parents=True, exist_ok=True)
    for ext in ('sch', 'brd'):
        r = ab('fusion_export_eagle_source', {'outputPath': '%s/%s-src.%s' % (bdir, name, ext)})
        if not r.get('savedTo') and not r.get('success'): err('3D restore: could not export the %s: %s' % (ext, r.get('error')))
    ab('pull_file', {'filePaths': ['%s/%s-src.sch' % (bdir, name), '%s/%s-src.brd' % (bdir, name)], 'saveTo': str(loc.resolve())}, timeout=600)
    def fix_lib(t, extra=None):
        def one(mt):
            L = mt.group(0)
            if 'name="%s"' % lib not in L[:300]: return L
            return bind_library_3d(L, urns, extra)
        return re.sub(r'(?s)<library [^>]*>.*?</library>', one, t)
    restored = set(); on_board = {}  # a board's copy of the library has no devicesets: its models come from its elements
    def el(mt):
        e = mt.group(0); pk = re.search(r'package="([^"]+)"', e); ref = re.search(r'name="([^"]+)"', e).group(1)
        part_ = by_ref.get(ref) or (pk.group(1) if pk else '')
        u = urns.get(part_.upper())
        if not u or not pk: return e
        _sibling(on_board, u, part_, pk.group(1))
        if 'package3d_urn' in e: return e
        restored.add(ref)
        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % u + ('/>' if e.endswith('/>') else '>')
    brd = re.sub(r'<element [^>]*>', el, (loc / (name + '-src.brd')).read_text())
    brd = fix_lib(brd, on_board)
    sch = fix_lib((loc / (name + '-src.sch')).read_text())
    def part(mt):
        e = mt.group(0)
        if 'package3d_urn' in e or 'library="%s"' % lib not in e: return e
        u = urns.get(re.search(r'deviceset="([^"]*)"', e).group(1).upper())
        if not u: return e
        return e[:-1].rstrip('/') + ' package3d_urn="%s"' % u + ('/>' if e.endswith('/>') else '>')
    sch = re.sub(r'<part [^>]*>', part, sch)
    if not restored: return []
    ET.fromstring(brd); ET.fromstring(sch)
    (loc / (name + '.sch')).write_text(sch); (loc / (name + '.brd')).write_text(brd)
    send([loc / (name + '.sch'), loc / (name + '.brd')], bdir + '/restored')
    say('putting the 3D models back on %d part(s): the design is imported again with every binding written on it' % len(restored))
    ab('fusion_close_document', {'save': False})  # the active design (not saved under its name yet)
    # the import starts from an empty electronics design and lands in its own document, so that one stays open and
    # empty: use one that's already there instead of adding another Untitled each run (issue #133)
    if reuse_blank_design(): say('starting from the empty Untitled electronics design already open')
    else: ab('fusion_execute_text_command', {'command': 'Commands.Start NewElectronDesignDocumentCommand'})
    for _ in range(20):
        time.sleep(2)
        d_ = ab('fusion_get_app_state', {}); d_ = d_.get('data') or d_
        if d_.get('productType') == 'EcadDesignProductType' and not d_.get('busyCommand'): break
    time.sleep(3)
    r = ab('fusion_new_electronics_from_eagle', {'schPath': bdir + '/restored/' + name + '.sch', 'brdPath': bdir + '/restored/' + name + '.brd'})
    rid = r.get('runId'); s_ = {}
    for _ in range(60):
        time.sleep(6)
        s_ = ab('fusion_eagle_import_status', {'runId': rid})
        if s_.get('stage') in ('done', 'failed'): break
    keep_reply('restore3d-import', {'runId': rid, 'start': r, 'status': s_})
    if s_.get('stage') != 'done': err('3D restore: the import did not finish: %s' % str((s_.get('result') or {}).get('error'))[:300], ['The design before the restore is on the box: %s/%s-src.sch and .brd.' % (bdir, name)])
    return sorted(restored)


def cmd_view3d(a):
    """The board in 3D, for the human and for the check: Fusion's 3D PCB, an iso view, a still from Fusion's
    own renderer saved in the run (read it: every part must sit flat on its pads), then a slow live orbit
    the step clip and the screen recording catch. Keep Fusion windowed (never maximized): a full-screen
    window can leave a screen recording on a stale frame."""
    st = load_state(); name = a.design or st.get('design')
    if not name: err('which design? pass --design <name>')
    w = ab('desktop_list_windows', {}); wl = ((w.get('data') or w).get('windows') or [])
    fh = next((x['hwnd'] for x in wl if x.get('image') == 'Fusion360.exe' and x.get('kind') == 'normal' and 'Autodesk Fusion' in (x.get('title') or '')), None)
    if fh: ab('desktop_bring_to_front', {'hwnd': fh, 'reason': 'show the board in 3D'})
    r = ab('fusion_show_3d_board', {'documentName': name})
    if r.get('success') is False: err('could not show %s in 3D: %s' % (name, r.get('error')), ['Open the design (it must be the active document) and run this again.'])
    time.sleep(a.settle)
    ab('fusion_view', {'action': 'iso'}); ab('fusion_view', {'action': 'fit'}); time.sleep(1.5)
    out = RUN / 'view3d'; out.mkdir(parents=True, exist_ok=True)
    shot = ab('fusion_take_screenshot', {})
    sp = (shot.get('data') or shot).get('fullPath') or shot.get('fullPath')
    still = None
    if sp:
        ab('pull_file', {'filePaths': [sp], 'saveTo': str(out.resolve())}, timeout=300)
        got = out / Path(sp.replace('\\', '/')).name
        if got.is_file():
            still = out / ('board-3d-%s.png' % time.strftime('%H%M%S')); got.rename(still)
    say('orbiting the board once (%d steps)' % a.steps)
    for _ in range(a.steps):
        ab('fusion_view', {'action': 'orbit', 'degrees': 360.0 / a.steps}); time.sleep(a.pause)
    ab('fusion_view', {'action': 'iso'}); ab('fusion_view', {'action': 'fit'})
    prove('tour-3d', {'still': str(still) if still else None})
    ok('%s shown in 3D and orbited once%s' % (name, '; still: %s' % still if still else ''),
       ['Read the still before going on: every part sits flat on its pads, the way its datasheet drawing shows (no part on its side or on edge, no flat placeholder box). The AI does not catch a sideways model by itself; this look is the check.' if still else 'No still came back (fusion_take_screenshot); look at Fusion yourself.',
        'A part that is wrong: say which; fix its wiki component page (the STEP must be Z-up, seated, centred, with an identity footprint binding) and run `fusion library` again.',
        'Back to 2D: fusion_show_2d_board {"documentName": "%s"}.' % name])


# ---------------------------------------------------------------- silkscreen

def _num(tag, k, d=None):
    m = re.search(r'\s%s="([^"]*)"' % k, ' ' + tag)
    if not m: return d
    try: return float(m.group(1))
    except ValueError: return m.group(1)

def _attr(tag, k, d=''):
    """An attribute as written (a name, package or value of digits stays a string: 0603, 10)."""
    m = re.search(r'\s%s="([^"]*)"' % k, ' ' + tag)
    return m.group(1) if m else d

def _rot_of(r):
    r = r or 'R0'; mir = r.startswith('M') or 'M' in r[:2]
    try: ang = float(re.sub(r'[^0-9.\-]', '', r) or 0)
    except ValueError: ang = 0.0
    return ang, mir

def _xf(x, y, ex, ey, ang, mir):
    """A package point on the board for an element at (ex, ey) with EAGLE rotation (ang, mir): MRθ is
    rotate θ counterclockwise, then mirror x (Fusion's order; see aiflow-board eagle.rs)."""
    c, s_ = math.cos(math.radians(ang)), math.sin(math.radians(ang))
    x, y = x * c - y * s_, x * s_ + y * c
    return ex + (-x if mir else x), ey + y


def board_elements(text):
    """{ref: {'smashed': bool, 'name': (x, y) or None}} from the board XML: each element's own NAME
    attribute (where a smashed name stands), read with a real parse so a self-closing <element .../> is
    never confused with its neighbour. {} when the text is not readable XML."""
    try: root = ET.fromstring(text)
    except ET.ParseError: return {}
    out = {}
    for e in root.iter('element'):
        na = next((x for x in e.findall('attribute') if x.get('name') == 'NAME'), None)
        xy = (float(na.get('x')), float(na.get('y'))) if na is not None and na.get('x') is not None and na.get('y') is not None else None
        out[e.get('name')] = {'smashed': e.get('smashed') == 'yes', 'name': xy, 'hasName': na is not None}
    return out


def nameless_parts(text):
    """Smashed parts whose NAME text is gone (an earlier, interrupted pass deleted it): there is no name to
    move or remove, so the reference is printed as plain text instead."""
    return {r for r, e in board_elements(text).items() if e['smashed'] and not e['hasName']}


def plain_texts(text, layer='21'):
    """[(text, x, y)] of the board's plain texts on one layer."""
    try: root = ET.fromstring(text)
    except ET.ParseError: return []
    return [(t.text or '', float(t.get('x', 0)), float(t.get('y', 0))) for pl in root.iter('plain') for t in pl.findall('text') if t.get('layer') == layer]

def board_model(text):
    """The board as the label search sees it: copper and mask boxes, part bodies, the outline, and each
    element's NAME text. EAGLE coordinates (mm, y up)."""
    pk = {}
    for m in re.finditer(r'<package name="([^"]+)"[^>]*>(.*?)</package>', text, re.S):
        body, pads = [], []
        for t in re.findall(r'<smd [^>]*>', m.group(2)):
            x, y, dx, dy = _num(t, 'x', 0), _num(t, 'y', 0), _num(t, 'dx', 0), _num(t, 'dy', 0)
            rr = _rot_of(_num(t, 'rot', 'R0') if isinstance(_num(t, 'rot', 'R0'), str) else 'R0')[0]
            if int(round(rr)) % 180 == 90: dx, dy = dy, dx
            pads.append((x, y, dx / 2, dy / 2, _num(t, 'layer', 1)))
        for t in re.findall(r'<pad [^>]*>', m.group(2)):
            d = _num(t, 'diameter', 0) or (_num(t, 'drill', 0.8) * 1.6)
            pads.append((_num(t, 'x', 0), _num(t, 'y', 0), d / 2, d / 2, 0))
        for t in re.findall(r'<wire [^>]*layer="(?:51|39|21)"[^>]*>', m.group(2)):
            body += [(_num(t, 'x1', 0), _num(t, 'y1', 0)), (_num(t, 'x2', 0), _num(t, 'y2', 0))]
        for t in re.findall(r'<circle [^>]*layer="(?:51|21)"[^>]*>', m.group(2)):
            cx, cy, r = _num(t, 'x', 0), _num(t, 'y', 0), _num(t, 'radius', 0)
            body += [(cx - r, cy - r), (cx + r, cy + r)]
        nt = re.search(r'<text [^>]*>&gt;NAME</text>', m.group(2))
        pk[m.group(1)] = {'pads': pads, 'body': body, 'nameAlign': (re.search(r'align="([^"]+)"', nt.group(0)) or [None, 'bottom-left'])[1] if nt else 'bottom-left',
                          'nameXY': (_num(nt.group(0), 'x', 0), _num(nt.group(0), 'y', 0)) if nt else (0, 0)}
    els = []
    # the self-closing form first: `<element ...>(.*?)</element>` also matches `<element .../>` and then runs
    # on through the next element (its NAME read as this one's, the next part lost)
    for m in re.finditer(r'<element ([^>]*?)/>|<element ([^>]*)>(.*?)</element>', text, re.S):
        tag = m.group(1) or m.group(2); inner = m.group(3) or ''
        name, pkg = _attr(tag, 'name', None), _attr(tag, 'package', None)
        ex, ey = _num(tag, 'x', 0), _num(tag, 'y', 0)
        ang, mir = _rot_of(_num(tag, 'rot', 'R0') if isinstance(_num(tag, 'rot', 'R0'), str) else 'R0')
        p = pk.get(pkg, {'pads': [], 'body': []})
        pads = []
        for (x, y, hx, hy, layer) in p['pads']:
            cx, cy = _xf(x, y, ex, ey, ang, mir)
            if int(round(ang)) % 180 == 90: hx, hy = hy, hx
            pads.append((cx - hx, cy - hy, cx + hx, cy + hy))
        pts = [_xf(x, y, ex, ey, ang, mir) for (x, y) in p['body']] or [(b[0], b[1]) for b in pads] + [(b[2], b[3]) for b in pads]
        body = (min(q[0] for q in pts), min(q[1] for q in pts), max(q[0] for q in pts), max(q[1] for q in pts)) if pts else (ex, ey, ex, ey)
        na = re.search(r'<attribute name="NAME"[^>]*>', inner)
        align = (re.search(r'align="([^"]+)"', na.group(0)) or [None, None])[1] if na else None
        hx_, hy_ = p.get('nameXY', (0, 0))
        els.append({'name': name, 'package': pkg, 'value': _attr(tag, 'value'), 'x': ex, 'y': ey, 'bottom': mir, 'pads': pads, 'body': body, 'rot': ang,
                    'homeName': _xf(hx_, hy_, ex, ey, ang, mir),
                    'nameAttr': na.group(0) if na else None, 'nameAlign': align or p.get('nameAlign', 'bottom-left')})
    # each element's nets from the board's own connections (a value field can say anything); a pin with
    # no net is mechanical only, and its tag carries the name alone
    nets = {}
    for sg in re.finditer(r'<signal name="([^"]+)"[^>]*>(.*?)</signal>', text, re.S):
        for cr in re.findall(r'<contactref element="([^"]+)"', sg.group(2)): nets.setdefault(cr, set()).add(sg.group(1))
    for e in els:
        n = sorted(nets.get(e['name'], ()))
        e['net'] = n[0] if len(n) == 1 else (None if not n else '/'.join(n))
    xs, ys = [], []
    for t in re.findall(r'<wire [^>]*layer="20"[^>]*>', text):
        xs += [_num(t, 'x1', 0), _num(t, 'x2', 0)]; ys += [_num(t, 'y1', 0), _num(t, 'y2', 0)]
    outline = (min(xs), min(ys), max(xs), max(ys)) if xs else None
    vias = [(_num(t, 'x', 0), _num(t, 'y', 0), (_num(t, 'diameter', 0) or _num(t, 'drill', 0.3) * 2) / 2) for t in re.findall(r'<via [^>]*>', text)]
    # board text already printed (labels from an earlier pass, the board identity): kept, and in the way
    plain = text[text.find('<plain>'):text.find('</plain>')] if '<plain>' in text else ''
    texts = []
    for m in re.finditer(r'<wire ([^>]*layer="21"[^>]*)/?>', plain):
        tag = m.group(1)
        texts.append({'text': None, 'layer': 'wire21', 'box': (_num(tag, 'x1', 0), _num(tag, 'y1', 0), _num(tag, 'x2', 0), _num(tag, 'y2', 0))})
    for m in re.finditer(r'<text ([^>]*)>([^<]*)</text>', plain):
        tag, body = m.group(1), m.group(2)
        layer = int(_num(tag, 'layer', 0) or 0)
        if layer not in (21, 22): continue
        sz = _num(tag, 'size', 0.8) or 0.8; x, y = _num(tag, 'x', 0), _num(tag, 'y', 0)
        r_ = str(_num(tag, 'rot', 'R0')); w, h = 0.95 * sz * len(body) + 0.1, sz * 1.1
        box = (x, y, x + w, y + h) if '90' not in r_ else (x - h, y, x, y + w)
        texts.append({'text': body, 'layer': layer, 'box': box, 'at': (x, y)})
    for e in els:
        e['nameBox'] = None
        if e['nameAttr']:
            a_ = e['nameAttr']; x, y = _num(a_, 'x', None), _num(a_, 'y', None)
            if x is not None:
                w = 0.95 * 0.8 * len(e['name']) + 0.1; h = 0.88
                al = e['nameAlign']; x0 = x - w / 2 if 'center' in al else (x - w if al.endswith('right') else x)
                y0 = y if al.startswith('bottom') else (y - h if al.startswith('top') else y - h / 2)
                e['nameBox'] = (x0, y0, x0 + w, y0 + h) if '90' not in str(_num(a_, 'rot', 'R0')) else (x - h, y0, x, y0 + w)
    return els, outline, vias, texts

def placed_name_boxes(els, nameless):
    """Each top-side part's name as placed: (ref, box). Boxes are estimated from the NAME's size and length."""
    boxes = []
    for e in els:
        if e['bottom'] or e['name'] in nameless: continue
        na = e.get('nameAttr') or ''
        if 'display="off"' in na:
            continue
        xy = _num(na, 'x', None) if na else None
        if na and xy is not None:
            x, y = _num(na, 'x', 0), _num(na, 'y', 0); size = _num(na, 'size', 0.8) or 0.8
        else:
            (x, y), size = e['homeName'], 0.8
        w, h = tw_(e['name'], size), size * TEXT_H
        rot = _rot_of(_num(na, 'rot', 'R0') if na and isinstance(_num(na, 'rot', 'R0'), str) else 'R0')[0] if na else 0
        if int(round(rot)) % 180 == 90: w, h = h, w
        al = e.get('nameAlign') or 'bottom-left'
        x0 = x - w / 2 if 'center' in al and not al.endswith('left') and not al.endswith('right') else (x - w if al.endswith('right') else x)
        y0 = y - h / 2 if al.startswith('center') else (y - h if al.startswith('top') else y)
        boxes.append((e['name'], (x0, y0, x0 + w, y0 + h)))
    return boxes


def name_problems(boxes, els, outline, vias, edge=0.3):
    """What is wrong with each placed name: {ref: [problem, ...]}. Of two names that overlap, the second one
    listed carries the problem (so a re-lay moves one of them, not both)."""
    probs = {}
    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None
    pads = [(e['name'], p) for e in els for p in e['pads']]
    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]
    for ref, bx in boxes:
        if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]):
            probs.setdefault(ref, []).append('%s: name off the board or within %.1f mm of the edge' % (ref, edge))
        hit = next((r for r, p in pads if _ov(bx, p, 0.0)), None)
        if hit: probs.setdefault(ref, []).append('%s: name over a pad of %s (the fab clips silkscreen on pads)' % (ref, hit))
        if any(_ov(bx, v, 0.0) for v in viab): probs.setdefault(ref, []).append('%s: name over a via' % ref)
    for i, (ra, ba) in enumerate(boxes):
        for rb, bb in boxes[i + 1:]:
            if _ov(ba, bb, 0.0): probs.setdefault(rb, []).append('%s and %s: names overlap' % (ra, rb))
    return probs


def silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, n_top):
    """Read the silkscreen as placed, move nothing, and report what is worth a look (issue #134). Name boxes
    are estimated from each NAME's size and length; the fab's Gerber is the exact measure of what prints."""
    edge = 0.3
    boxes = placed_name_boxes(els, nameless)
    probs = name_problems(boxes, els, outline, vias, edge)
    findings = [p for ref, _ in boxes for p in probs.get(ref, [])]
    unnamed = sorted(r for r in nameless) + sorted(e['name'] for e in els if not e['bottom'] and 'display="off"' in (e.get('nameAttr') or ''))
    if unnamed: findings.append('no visible name: %s (fine when intended)' % ', '.join(unnamed[:20]))
    prove('silkscreen', {'mode': 'adopt', 'labels': len(boxes), 'moved': 0, 'findings': findings, 'placedByDesigner': by_hand})
    head = 'silkscreen audited as placed (%d of %d top-side names placed by the designer), nothing moved: %s' % (
        by_hand, n_top, ('%d finding(s) worth a look:\n  ' % len(findings)) + '\n  '.join(findings[:40]) if findings else 'nothing found')
    ok(head, ['Each finding is a suggestion: fix the ones you agree with in Fusion, then run this again.',
              'Name boxes are estimated from size and length; for the exact measure of what prints, export the Gerbers and run `adom-aiflow silkscreen gerber-audit --dir <the Gerber folder> --board <the KiCad copy>` (silkscreen on exposed copper, off the edge, too thin).',
              'To have AI Flow move only the names with a finding: `fusion silkscreen --relayout` (every name: --relayout --all; values: --values none|where-room|required).'])


def cmd_silkscreen(a):
    st = load_state(); name = a.design or st.get('design')
    if not name: err('which design? pass --design <name>')
    if a.board_file:
        text = Path(a.board_file).read_text()  # a saved copy of the board, for planning without Fusion (--dry)
    else:
        src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''
        if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.'])
    els, outline, vias, texts = board_model(text)
    nameless = nameless_parts(text)
    global VALUES_MODE
    VALUES_MODE = a.values
    # issue #134: a silkscreen the designer placed is audited where it stands, never re-laid unasked
    placed = board_elements(text)
    top = [e for e in els if not e['bottom']]
    by_hand = sum(1 for e in top if (placed.get(e['name']) or {}).get('smashed'))
    if a.adopt or (not a.relayout and not a.fresh and top and by_hand >= max(3, len(top) // 2)):
        return silkscreen_audit(a, name, text, els, outline, vias, texts, nameless, by_hand, len(top))
    contacts = [e for e in els if is_contact(e)]
    fresh_cmds = []
    if a.fresh:
        # NEVER clear an earlier pass with DELETE (x y): in the board editor a point on a part's own tPlace
        # artwork picks the PART, and DELETE removes the whole element with its connections (2026-10-08: J4 and
        # MP4 went this way). A board carrying an earlier pass is cleaned by opening its previous cloud version
        # (fusion_aps_versions, then DataFile.promote() on the clean one); --fresh only puts names back.
        vis = re.findall(r'<layer number="(\d+)"[^>]*visible="yes"', text)
        near = lambda x, y: any(abs(x - e['x']) < 4 and abs(y - e['y']) < 4 for e in contacts)
        # an earlier pass's printed net labels (plain tPlace text by a contact) go to the documentation layer
        # (51, not printed): picked at the text's own origin, nothing deleted
        for t in texts:
            if t['layer'] == 21 and t.get('at') and t['text'] in {e.get('net') or e['value'] for e in contacts} and near(*t['at']):
                # picked at the text's own origin with only tPlace shown, and never where a part's origin is
                # close (CHANGE LAYER by point does not land in Fusion; DELETE on a part's tPlace art removes the part)
                pt = (t['at'][0] - 0.3, t['at'][1] - 0.3, t['at'][0] + 0.3, t['at'][1] + 0.3)
                if any(_ov(pt, e['body'], 0.0) or any(_ov(pt, q, 0.0) for q in e['pads']) for e in els): continue
                fresh_cmds += ['GRID MM;', 'DISPLAY NONE 21;', 'DELETE (%.3f %.3f);' % t['at']]
                t['_gone'] = True
        dels = [t for t in texts if t['layer'] == 'wire21']
        if dels:
            err('this board already carries %d silkscreen lines from an earlier pass; --fresh will not delete them (a DELETE by point can remove a whole part)' % len(dels),
                ['Restore the board version from before the earlier pass: `fusion_aps_versions {query}` lists them; promote the clean one (Fusion API DataFile.versions[n].promote()), close the open copy without saving, reopen, then run this again.'])
        texts = [t for t in texts if t['layer'] != 'wire21' and not t.get('_gone')]
        if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(sorted(set(vis or ['1', '16']) | {'17', '18', '20', '21', '22', '25'}, key=int)))]
    # the corner pins' shape is chosen with the columns: each corner variant in turn (beside the pin, two
    # lines over or under it, one line), keeping whichever frames the most contacts
    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]
    # issue #134: a re-lay starts from the designer's own spots: every placed name with no finding stays where
    # it is (a fixed obstacle, nothing landed for it) and only the names with a finding are planned again;
    # --all plans every name from scratch
    kept = []
    if a.relayout and not getattr(a, 'all', False) and not a.fresh:
        boxes = placed_name_boxes(els, nameless)
        probs = name_problems(boxes, els, outline, vias)
        smashed = {r for r, v in placed.items() if (v or {}).get('smashed')}
        kept = [(r, b) for r, b in boxes if r in smashed and r not in probs and r not in grouped]
        fixed = fixed + [b for _, b in kept]
    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=set(grouped) | {r for r, _ in kept})
    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']]
    title = a.title or st.get('title') or name
    lines = [title, 'adom-aiflow %s' % time.strftime('%Y-%m-%d')]
    spot = None if any(t['layer'] == 22 and t['text'] == title for t in texts) else identity_spot(els, outline, vias, lines)
    aligns = {e['name']: e['nameAlign'] for e in els}
    def anchor(box, align, rot='R0'):
        # where EAGLE puts a text's origin for its alignment (a smashed NAME keeps its library alignment);
        # R90 turns the text's own frame: its 'bottom' is the box's right side, its 'left' the box's bottom
        hor = 'left' if align.endswith('left') else 'right' if align.endswith('right') else 'center'
        ver = 'bottom' if align.startswith('bottom') else 'top' if align.startswith('top') else 'center'
        if rot == 'R0':
            x = {'left': box[0], 'right': box[2], 'center': (box[0] + box[2]) / 2}[hor]
            y = {'bottom': box[1], 'top': box[3], 'center': (box[1] + box[3]) / 2}[ver]
        else:
            x = {'bottom': box[2], 'top': box[0], 'center': (box[0] + box[2]) / 2}[ver]
            y = {'left': box[1], 'right': box[3], 'center': (box[1] + box[3]) / 2}[hor]
        return x, y
    if a.fresh:
        # names this pass places go back to their library spot first, so each is seen moving to its new one;
        # a name with no planned spot is left where it is (it was readable before)
        placed = {L['ref'] for L in labels if L['kind'] == 'name'}
        for e in els:
            if e['bottom'] or not e['nameAttr'] or e['name'] not in placed: continue
            hx, hy = e['homeName']
            fresh_cmds += ["SMASH %s;" % e['name'], "ROTATE =R%d '%s>NAME';" % (int(round(e['rot'])) % 360, e['name']), "MOVE '%s>NAME' (%.3f %.3f);" % (e['name'], hx, hy)]
    vis_now = re.findall(r'<layer number="(\d+)"[^>]*visible="yes"', text) or ['1', '16', '17', '18', '20', '21', '25']
    # the restore after each name removal must keep every layer this step draws on visible: with an odd set
    # (only 23 24 25 on, issue #74) each later CHANGE LAYER 21 failed 'Unavailable layer' after the NAME was
    # already deleted. So: the board's own visible set plus pads, vias, outline, tPlace, bPlace and tNames.
    vis_now = sorted(set(vis_now) | {'17', '18', '20', '21', '22', '25'}, key=int)
    parked = [0]
    def remove_name(ref):
        """a smashed name off the print, safely: MOVE it by name (lands) to an empty spot off the board, then
        DELETE it there, where nothing else can be under the point (a DELETE where the name stood can pick
        the part itself: J4, 2026-10-08)"""
        parked[0] += 1
        px = (outline[2] if outline else 0) + 6.0; py = (outline[3] if outline else 0) + 2.0 * parked[0]
        return ["SMASH %s;" % ref, "MOVE '%s>NAME' (%.3f %.3f);" % (ref, px, py), 'DISPLAY NONE 25;', 'DELETE (%.3f %.3f);' % (px, py), 'DISPLAY NONE %s;' % (a.display or ' '.join(vis_now))]
    steps = []  # one entry per thing drawn: (what, [commands]); each lands as its own script, so it is seen
    pieces_of = {g['ref']: g.get('pieces') for g in groups}
    for (ref, f) in frames:
        if pieces_of.get(ref):
            steps.append(('%s frame' % ref, ['GRID MM;', 'CHANGE LAYER 21;'] + ['WIRE 0.15 (%.3f %.3f) (%.3f %.3f);' % (a_[0], a_[1], b_[0], b_[1]) for (a_, b_) in pieces_of[ref]]))
            continue
        steps.append(('%s frame' % ref, ['GRID MM;', 'CHANGE LAYER 21;', 'WIRE 0.15 (%.3f %.3f) (%.3f %.3f) (%.3f %.3f) (%.3f %.3f) (%.3f %.3f);' % (f[0], f[1], f[2], f[1], f[2], f[3], f[0], f[3], f[0], f[1])]))
    cmds = []
    for L in labels:
        cmds = ['GRID MM;']
        x0, y0 = L['origin']
        if L['kind'] == 'name':
            ax, ay = anchor(L['box'], aligns.get(L['ref'], 'bottom-left'), L.get('rot', 'R0'))
            L['anchor'] = [round(ax, 3), round(ay, 3)]
            if L['ref'] in nameless:
                # a smashed part whose NAME text is gone (an earlier, interrupted pass deleted it): there is no
                # name to move or remove, so the reference is printed as plain text at the planned spot
                tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])
                cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]
            elif abs(L['size'] - 0.8) < 1e-6:
                cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)]
            else:
                # CHANGE SIZE does not land in Fusion (by point or by name): the smashed name comes off at its
                # own origin (deleting a smashed name removes only that text) and the reference is printed as
                # plain text at the planned size
                cmds += remove_name(L['ref'])
                tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])
                cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]
        else:
            tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])
            cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]
        steps.append(('%s %s' % (L['ref'], L['kind']), cmds))
    if spot:
        cmds = ['GRID MM;', 'CHANGE LAYER 22;', 'CHANGE SIZE %s;' % spot[2], 'CHANGE RATIO 15;', 'CHANGE ALIGN center;']
        for k, l in enumerate(lines):
            cmds.append("TEXT '%s' MR0 (%.3f %.3f);" % (l.replace("'", ''), spot[0], spot[1] + (len(lines) / 2 - k - 0.5) * spot[2] * 1.5))
        steps.append(('board identity', cmds))
    # a framed pin whose tag shows only its net (the one-line corner tag) keeps its reference off the print
    for g in groups:
        if any(t['kind'] == 'name' for t in g['texts']): continue
        e = next((x for x in els if x['name'] == g['ref'] and x['nameAttr']), None)
        if not e: continue
        nx, ny = _num(e['nameAttr'], 'x', None), _num(e['nameAttr'], 'y', None)
        if nx is None: continue
        steps.append(('%s name off print' % g['ref'], ['GRID MM;'] + remove_name(g['ref'])))
    # a name with no clear spot, even with a leader, goes to the documentation layer (51, not printed)
    # rather than sitting over copper or another label; the unresolved list still names it
    off = set()
    for u in unresolved:
        ref, kind = u.rsplit(' ', 1)
        if kind not in ('name', 'leader') or ref in off: continue
        e = next((x for x in els if x['name'] == ref and x['nameAttr']), None)
        if not e or is_contact(e) or any(L['ref'] == ref and L['kind'] == 'name' for L in labels): continue
        nx, ny = _num(e['nameAttr'], 'x', None), _num(e['nameAttr'], 'y', None)
        if nx is None: continue
        off.add(ref)
        steps.append(('%s name off print' % ref, ['GRID MM;'] + remove_name(ref)))
    for l in leaders:
        (x1, y1), (x2, y2) = l['leader']
        steps.append(('%s leader' % l['ref'], ['GRID MM;', 'CHANGE LAYER 21;', 'WIRE 0.15 (%.3f %.3f) (%.3f %.3f);' % (x1, y1, x2, y2)]))
    # draw each contact as a tag: its frame, then its name and net (and its leader), before the rest
    fsteps = {w.split()[0]: st for st in steps for w in [st[0]] if w.endswith(' frame')}
    rest = [st for st in steps if not st[0].endswith(' frame')]
    ordered = []
    for g in groups:
        if g['ref'] in fsteps: ordered.append(fsteps[g['ref']])
        ordered += [st for st in rest if st[0] in ('%s name' % g['ref'], '%s net' % g['ref'])]
    for l in leaders:
        if l['ref'] in fsteps: ordered.append(fsteps[l['ref']])
        ordered += [st for st in rest if st[0] in ('%s name' % l['ref'], '%s net' % l['ref'], '%s leader' % l['ref'])]
    steps = ordered + [st for st in rest if st not in ordered]
    plan = {'frames': frames, 'leaders': leaders, 'labels': labels, 'unresolved': unresolved, 'identity': {'lines': lines, 'at': spot}, 'fresh': fresh_cmds, 'steps': steps, 'kept': sorted(r for r, _ in kept)}
    STATE.mkdir(parents=True, exist_ok=True); (STATE / 'silkscreen-plan.json').write_text(json.dumps(plan, indent=1))
    if a.dry:
        ok('silkscreen plan (not landed): %d contact frames, %d labels, %d unresolved%s%s; %s' % (len(frames), len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved else '', ('; %d placed name(s) kept where the designer put them' % len(kept)) if kept else '', STATE / 'silkscreen-plan.json'))
    if not a.no_switch: ab('fusion_show_2d_board', {'documentName': name})
    # each frame and label is its own small script run by Fusion's SCRIPT command, one after another with a
    # pause, so the board is seen being drawn (the bridge refuses a bare MOVE; a script lands every item as
    # the editor's own commands, undoable). The scripts go to the desktop in one copy.
    failed = []
    sdir = STATE / 'silkscreen'; sdir.mkdir(parents=True, exist_ok=True)
    for f_ in sdir.glob('*.scr'): f_.unlink()
    files = []
    if a.display: fresh_cmds = fresh_cmds + ['DISPLAY NONE %s;' % a.display]
    if fresh_cmds:
        (sdir / '000-fresh.scr').write_text('\n'.join(fresh_cmds) + '\n'); files.append(sdir / '000-fresh.scr')
    for i, (what, cs) in enumerate(steps, 1):
        f_ = sdir / ('%03d.scr' % i); f_.write_text('\n'.join(cs) + '\n'); files.append(f_)
    dest = box_dir() + '/silkscreen'
    send(files, dest)
    if fresh_cmds:
        r = ab('fusion_electron_run', {'command': "SCRIPT '%s/000-fresh.scr';" % dest})
        if r.get('success') is False: failed.append('fresh -> %s' % str(r.get('error'))[:160])
        time.sleep(max(0.5, a.pause_ms / 1000.0))
    for i, (what, cs) in enumerate(steps, 1):
        r = ab('fusion_electron_run', {'command': "SCRIPT '%s/%03d.scr';" % (dest, i)})
        if r.get('success') is False: failed.append('%s -> %s' % (what, str(r.get('error'))[:160]))
        time.sleep(a.pause_ms / 1000.0)
    time.sleep(1.0)
    # read the board back: every name where it was put, nothing on copper, and every part still there
    src2 = ab('fusion_board_source', {'expectDocument': name}); t2 = (src2.get('data') or src2).get('source') or src2.get('source') or ''
    lost = sorted(set(re.findall(r'<element name="([^"]+)"', text)) - set(re.findall(r'<element name="([^"]+)"', t2)))
    if lost:
        err('the silkscreen pass removed parts from the board: %s' % ', '.join(lost),
            ['Do not save. In Fusion press Undo (Ctrl+Z) until those parts are back, or close the document without saving (fusion_close_document {name, save:false}) and reopen it; if it was already saved, promote the previous cloud version (fusion_aps_versions). Then report it: a silkscreen command picked a part.'])
    moved, off = 0, []
    after = board_elements(t2); printed = plain_texts(t2)
    for L in labels:
        if L['kind'] != 'name': continue
        if L['ref'] in nameless:
            # printed as plain text at its planned spot (a nameless part has no NAME to read back)
            tx, ty = L['origin'] if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])
            if any(t == L['text'] and abs(x - tx) < 0.05 and abs(y - ty) < 0.05 for (t, x, y) in printed): moved += 1
            else: off.append(L['ref'])
            continue
        if abs(L['size'] - 0.8) > 1e-6: continue  # smaller names are printed as plain text
        xy = (after.get(L['ref']) or {}).get('name')
        if xy and abs(xy[0] - L['anchor'][0]) < 0.05 and abs(xy[1] - L['anchor'][1]) < 0.05: moved += 1
        else: off.append(L['ref'])
    nets_written = sum(1 for L in labels if L['kind'] == 'net')
    nets_kept = sum(1 for t in texts if t['layer'] == 21)
    identity_kept = any(t['layer'] == 22 and t['text'] == title for t in texts)
    prove('silkscreen', {'frames': len(frames), 'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot), 'kept': [r for r, _ in kept]})
    if kept:
        print('Note: %d placed name(s) had no finding and stayed where the designer put them (--relayout --all re-lays them too): %s' % (len(kept), ', '.join(r for r, _ in kept[:30])))
    if SKIPPED_VALUES:
        print('Note: %d value(s) had no room beside their part and were left off the silkscreen (--values where-room): %s' % (len(SKIPPED_VALUES), ', '.join(SKIPPED_VALUES[:20])))
    if failed or off:
        # a suggestion engine: report what did not land, never refuse (John, 2026-11-04)
        ok('silkscreen landed in part (findings, nothing blocked): %d of %d names where planned%s%s' % (moved, len([l for l in labels if l['kind'] == 'name']), ('; not where planned: ' + ', '.join(off)) if off else '', ('; refused: ' + '; '.join(failed[:4])) if failed else ''),
            ['Look at the board in Fusion; move the named labels by hand or fix the command, then `fusion silkscreen` again.'])
    ok('silkscreen: %d contact frames, %d part names placed readable and clear of copper, %d contact net labels on the edge side%s%s, board identity on the bottom%s' % (len(frames), moved, nets_written, (' (%d kept from before)' % nets_kept) if nets_kept else '', (', unresolved: ' + ', '.join(unresolved)) if unresolved else '', ' (kept from before)' if identity_kept else '' if spot else ' (no clear spot found)'),
       ['Show the human both faces: the top labels next to their parts, the bottom identity. Then `step 3d`.',
        'Good silkscreen (aiflow-silkscreen): every edge contact a framed tag of the same width, the four corner pins matched, a label next to its own part (a leader line where there is no room), text at the fab profile minimum (now %.1f mm).' % min_text(),
        'Offer the user their logo and the board wiki page URL on the bottom layer: anyone holding the board can then find its files.']
       + (['%d frames or labels did not fit at %.1f mm text: move a test pad out of a contact column, use a fab with a smaller minimum (3rdPartyFab 0.5 mm), or grow the board on the molecule grid.' % (len(unresolved), min_text())] if len(unresolved) > 3 else []))


# ---------------------------------------------------------------- export

TP_NAME = re.compile(r'test[ _.-]?(point|pad)|^tp[_-]', re.I)
MACHINE_NAME = re.compile(r'machine[ _-]?(contact|pin)|press[ _-]?fit', re.I)


def fab_roles(text, st, board_only=()):
    """{ref: 'tp' | 'machine'} for the parts a fab must not place: bare-copper test points (a test point
    package or part, a MANUFACTURER of PCB copper, a part the library recorded as board only) and the
    machine contacts and pins (press-fit Adom stock, fitted after assembly: DNP)."""
    roles = {r: 'tp' for r in board_only}
    only = {m for m, p in (st.get('parts') or {}).items() if p.get('boardOnly')}
    for r, m in (st.get('refs') or {}).items():
        if m in only: roles[r] = 'tp'
    try: root = ET.fromstring(text) if text else None
    except ET.ParseError: root = None
    for e in (root.iter('element') if root is not None else []):
        ref = e.get('name'); attrs = {x.get('name', '').upper(): x.get('value') or '' for x in e.iter('attribute')}
        names = ' '.join([e.get('package') or '', e.get('library') or '', e.get('value') or '', attrs.get('MPN', ''), attrs.get('DEVICESET', '')])
        if TP_NAME.search(names) or re.search(r'pcb copper|board only', attrs.get('MANUFACTURER', ''), re.I) or attrs.get('MPN', '').upper() in {m.upper() for m in only}: roles[ref] = 'tp'
        elif MACHINE_NAME.search(names): roles[ref] = 'machine'
    return roles


def fab_rewrite(path, roles, drop_machine=False):
    """Rewrite a BOM or pick-and-place CSV in place: test points out, machine contacts/pins out of the
    CPL (and the fab BOM) or marked DNP in the BOM. A BOM row also names its parts by package and maker, so
    a test point the board source missed is still caught. Returns the refs it changed."""
    # blank rows dropped (the bridge writes \r\r\n line ends, which read as an empty row after each line)
    rows = [r for r in csv.reader(open(path, newline='', encoding='utf-8-sig')) if any(c.strip() for c in r)]
    if not rows: return set()
    h = [c.strip().lower() for c in rows[0]]
    di = next((h.index(c) for c in ('designator', 'designators', 'refdes', 'references', 'reference', 'parts', 'part', 'name') if c in h), None)
    if di is None: return set()
    qi = h.index('quantity') if 'quantity' in h else None
    bom = qi is not None or any(c in h for c in ('mpn', 'lcsc', 'comment', 'footprint', 'package'))
    cpl = not bom
    if bom and not drop_machine and 'dnp' not in h: rows[0].append('DNP'); h.append('dnp')
    dn = h.index('dnp') if 'dnp' in h else None
    out, changed = [rows[0]], set()
    for row in rows[1:]:
        if di >= len(row): out.append(row); continue
        refs = [x for x in re.split(r'[ ,;]+', row[di]) if x.strip()]
        sep = ',' if ',' in row[di] else ' '
        info = ' '.join(row[i] for i, c in enumerate(h) if c in ('package', 'footprint', 'mpn', 'value', 'comment') and i < len(row))
        maker = ' '.join(row[i] for i, c in enumerate(h) if c in ('manufacturer', 'mpn') and i < len(row))
        if bom and (TP_NAME.search(info) or re.search(r'pcb copper|board only', maker, re.I)):
            for r in refs: roles.setdefault(r, 'tp')
        elif bom and MACHINE_NAME.search(info):
            for r in refs: roles.setdefault(r, 'machine')
        keep = [r for r in refs if roles.get(r) not in ('tp', 'machine')]
        mach = [r for r in refs if roles.get(r) == 'machine']
        changed |= set(refs) - set(keep)
        def part(rs, dnp):
            nr = list(row) + [''] * (len(h) - len(row)); nr[di] = sep.join(rs)
            if qi is not None: nr[qi] = str(len(rs))
            if dn is not None: nr[dn] = 'DNP' if dnp else ''
            return nr
        if keep: out.append(part(keep, False))
        if mach and bom and not drop_machine: out.append(part(mach, True))
    with open(path, 'w', newline='', encoding='utf-8') as f: csv.writer(f).writerows(out)
    return changed


def copper_layer_count(state):
    """How many copper layers the board's own stack has, from fusion_routing_state; None when it does not say."""
    layers = (state.get('data') or state).get('copperLayers')
    return len(layers) if isinstance(layers, list) and len(layers) >= 2 else None


def gerber_args(path, copper):
    """The export call. The count goes with it (issue #144): a bridge before 1.13.72 guessed it from the layers
    carrying traces, so inner planes that are only pours came out as a 2-layer board. Its jobs cover 2 and 4;
    with any other count the bridge is left to answer, and it refuses rather than leave layers out."""
    return {'outputPath': path, **({'layers': str(copper)} if copper in (2, 4) else {})}


def gerber_copper_problem(names, copper):
    """The zip's inner copper against the board's: a 4-layer board with no inner Gerbers is not fab-ready."""
    if not copper or copper <= 2: return None
    inner = [n for n in names if re.search(r'\.g\d+$|inner|in\d+[._]cu|copper_l(?!1\b)\d+', n) and not re.search(r'bottom|\.gbl$', n)]
    if len(inner) >= copper - 2: return None
    return 'the board has %d copper layers but the Gerbers carry %d inner layer(s), not %d' % (copper, len(inner), copper - 2)


def cmd_export(a):
    st = load_state(); name = a.design or st.get('design')
    if not name: err('which design? pass --design <name>')
    out = Path(a.out_dir); out.mkdir(parents=True, exist_ok=True)
    bdir = box_dir() + '/manufacturing'
    copper = copper_layer_count(ab('fusion_routing_state', {'expectDocument': name}))
    g = ab('fusion_export_gerbers', gerber_args(bdir + '/' + name + '-gerbers.zip', copper), timeout=600)
    gd = g.get('data') or g
    if not gd.get('zipPath'): err('Gerbers: %s' % g.get('error'))
    ab('fusion_export_cpl', {'outputPath': bdir + '/' + name + '-cpl.csv'})
    b = ab('fusion_board_bom', {'expectDocument': name, 'outputDir': bdir})
    if not b.get('success'): err('BOM: %s' % b.get('error'))
    files = [gd['zipPath'], bdir + '/' + name + '-cpl.csv', bdir + '/bom.csv', bdir + '/bom-jlcpcb.csv']
    r = ab('pull_file', {'filePaths': files, 'saveTo': str(out.resolve())}, timeout=600)
    missing = {k: v for k, v in (b.get('missingPartNumbers') or {}).items() if v}
    # check what was written before calling it good: every layer a fab needs, and the BOM and the pick and
    # place naming exactly the board's parts
    import zipfile
    problems = []
    zp = out / Path(gd['zipPath'].replace('\\', '/')).name
    names = []
    try: names = [n.lower() for n in zipfile.ZipFile(zp).namelist()]
    except Exception as e: problems.append('Gerber zip unreadable (%s)' % e)
    need = {'top copper': ('gtl', 'copper_top', 'top_copper', 'f_cu', 'copper_l1'), 'bottom copper': ('gbl', 'copper_bottom', 'bottom_copper', 'b_cu', 'copper_l2'),
            'top mask': ('gts', 'soldermask_top', 'mask_top', 'f_mask'), 'top silkscreen': ('gto', 'silkscreen_top', 'silk_top', 'f_silks', 'legend_top'),
            'outline': ('gko', 'gm1', 'profile', 'outline', 'edge_cuts', 'dimension'), 'drill': ('drl', 'xln', '.drd', 'drill', '.txt')}
    if names:
        for what, keys in need.items():
            if not any(any(k in n for k in keys) for n in names): problems.append('no %s layer in the Gerbers' % what)
        short = gerber_copper_problem(names, copper)
        if short: problems.append(short)
    src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''
    board_refs = set(re.findall(r'<element name="([^"]+)"', text))
    # fab-ready as written: bare-copper test points are not parts (out of both files), the machine contacts
    # and pins are fitted after assembly (DNP in the BOM, out of the pick and place and the fab's BOM)
    roles = fab_roles(text, st, b.get('boardOnly') or ())
    for f_, drop in (('bom.csv', False), ('bom-jlcpcb.csv', True), (name + '-cpl.csv', True)):
        try:
            if (out / f_).is_file(): fab_rewrite(out / f_, roles, drop)
        except Exception as e: problems.append('%s could not be made fab-ready (%s)' % (f_, e))
    tps = sorted(r for r, k in roles.items() if k == 'tp'); dnp = sorted(r for r, k in roles.items() if k == 'machine')
    def refs_in(path, cols, dnp_too=True):
        try:
            rows = list(csv.reader(open(path, newline='', encoding='utf-8-sig')))
            h = [c.strip().lower() for c in rows[0]]; i = next((h.index(c) for c in cols if c in h), None)
            d = h.index('dnp') if 'dnp' in h else None
            out_refs = set()
            for row in rows[1:]:
                if not dnp_too and d is not None and d < len(row) and row[d].strip(): continue
                if i is not None and i < len(row): out_refs |= {x.strip() for x in re.split(r'[ ,;]+', row[i]) if x.strip()}
            return out_refs
        except Exception as e:
            problems.append('%s unreadable (%s)' % (Path(path).name, e)); return set()
    bom_cols = ('designator', 'designators', 'parts', 'part', 'references', 'reference', 'refdes')
    bom_all = refs_in(out / 'bom.csv', bom_cols); bom_refs = refs_in(out / 'bom.csv', bom_cols, False)
    jlc_refs = refs_in(out / 'bom-jlcpcb.csv', bom_cols) if (out / 'bom-jlcpcb.csv').is_file() else set()
    cpl_refs = refs_in(out / (name + '-cpl.csv'), ('designator', 'refdes', 'part', 'reference', 'name'))
    # every part a fab places is in both; test points in neither; machine parts only as DNP lines
    placed = board_refs - set(roles)
    if board_refs and bom_refs and placed - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted(placed - bom_refs)))
    if board_refs and cpl_refs and placed - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted(placed - cpl_refs)))
    if set(roles) & (cpl_refs | jlc_refs | bom_refs): problems.append('a test point or machine part is still listed to place: %s' % ', '.join(sorted(set(roles) & (cpl_refs | jlc_refs | bom_refs))))
    if set(tps) & bom_all: problems.append('test points still in the BOM: %s' % ', '.join(sorted(set(tps) & bom_all)))
    if bom_all and (set(dnp) & board_refs) - bom_all: problems.append('machine parts missing from the BOM as DNP: %s' % ', '.join(sorted((set(dnp) & board_refs) - bom_all)))
    if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the export (the CAM job, the parts\' attributes) and run `fusion export` again; never hand the human files that do not match the board.'])
    prove('export', {'dir': str(out.resolve()), 'gerberFiles': len(names), 'bomRefs': len(bom_refs), 'cplRefs': len(cpl_refs)})
    if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60)
    ok('manufacturing files in %s, checked: Gerbers (%d files, %d layers: copper, mask, silkscreen, outline, drill), pick and place (%d parts), BOM (%d lines) and the JLCPCB BOM; every part the fab places is in both' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(cpl_refs), len(b.get('lines') or [])),
       ['Parts without a distributor number: ' + '; '.join('%s: %s' % (k, ', '.join(v)) for k, v in missing.items()) if missing else '',
        'Test points are bare board copper, left out of the BOM and the pick and place: ' + ', '.join(tps) if tps else '',
        'Machine contacts and pins are fitted after assembly: DNP in bom.csv, left out of the pick and place and the JLCPCB BOM: ' + ', '.join(dnp) if dnp else ''])


def cmd_reset(a):
    """Clear the live board's copper for a refine pass: every track and via (ripped up net by net, pours
    kept filled), then every pour. The parts stay where they are; route, gate, land and pour again after."""
    st = load_state(); name = a.design or st.get('design')
    if not name: err('which design? pass --design <name>')
    s0 = ab('fusion_routing_state', {'expectDocument': name})
    if not s0.get('success'): err('could not read the board: %s' % s0.get('error'))
    nets = sorted({w['net'] for w in s0.get('segments', []) if w.get('net')} | {v['net'] for v in s0.get('vias', []) if v.get('net')})
    if nets:
        r = ab('fusion_ripup_nets', {'expectDocument': name, 'boardId': s0['boardId'], 'expectedRevision': s0['revision'], 'nets': nets})
        if not r.get('success'): err('rip-up: %s' % r.get('error'), [r.get('_hint')])
    s1 = ab('fusion_routing_state', {'expectDocument': name})
    r = ab('fusion_remove_pours', {'expectDocument': name, 'boardId': s1['boardId'], 'expectedRevision': s1['revision'], 'pours': 'all'})
    if not r.get('success'): err('pours: %s' % r.get('error'), [r.get('_hint')])
    ok('copper cleared on %s: %d net(s) ripped up, %d pour(s) removed; the parts did not move' % (name, len(nets), r.get('removed') or 0),
       ['Next: adom-aiflow route (it plans on the live board), gate, land route, pour, land vias, land pours, measure, then analyze again.'])


def main():
    ap = argparse.ArgumentParser(prog='adom-aiflow fusion')
    sub = ap.add_subparsers(dest='cmd', required=True)
    p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json, parts.json [{mpn, page?, footprint?}] or a BOM .csv'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} of numbers already checked')
    p.add_argument('--project', help='Fusion cloud project for the run (the 3D models and the design); default: the run\'s, else Main if this account has one, else the project open in the data panel; it asks when it cannot tell'); p.add_argument('--folder', help='folder in that project; default: the run\'s, else "Adom AI Flow"')
    p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'fusion'))
    p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float)
    p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', help='Fusion cloud project (default: the run\'s, else Main if this account has one, else the project open in the data panel; it asks when it cannot tell)'); p.add_argument('--folder', help='folder in that project (default: the run\'s, else "Adom AI Flow")')
    p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)
    p.add_argument('--layout', help='wired.json: parts placed by hand and every net drawn as wires pin to pin (a sheet an engineer can read), instead of the planner\'s labelled stubs')
    p = sub.add_parser('reset'); p.add_argument('--design')
    p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing'))
    p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8)
    p = sub.add_parser('silkscreen', help='every part name readable and clear of copper, contact nets on the edge side, the board identity on the bottom'); p.add_argument('--design'); p.add_argument('--title'); p.add_argument('--dry', action='store_true')
    p.add_argument('--adopt', action='store_true', help='audit the silkscreen as placed and move nothing (the default when the designer already placed most names)')
    p.add_argument('--relayout', action='store_true', help='re-lay the names that have a finding (over a pad or via, off the board, overlapping) even though the designer placed them; the others stay')
    p.add_argument('--all', action='store_true', help='with --relayout: plan every name from scratch, the designer\'s spots too')
    p.add_argument('--values', choices=['none', 'where-room', 'required'], default='where-room', help='values on the silkscreen: none, where there is room (default), or every one')
    p.add_argument('--fresh', action='store_true', help='take an earlier pass off first (its frames and contact net labels; names back to their library spots) so this pass is drawn from the start')
    p.add_argument('--no-switch', action='store_true', help='the 2D board is already showing: do not ask the bridge to switch views (its progress panel would sit over the board on camera)')
    p.add_argument('--board-file', help='plan from a saved .brd instead of the open board (use with --dry)')
    p.add_argument('--display', help='the layers to show while it draws, e.g. "17 18 20 21 25" (pads, vias, outline, tPlace, tNames): the silkscreen reads clearly over no copper')
    p.add_argument('--pause-ms', type=int, default=700, help='pause after each frame or label, so the recording sees it land')
    a = ap.parse_args()
    {'silkscreen': cmd_silkscreen, 'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'reset': cmd_reset, 'export': cmd_export, 'view3d': cmd_view3d}[a.cmd](a)


if __name__ == '__main__':
    main()