← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.66"+version = "0.1.67" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.66"+version = "0.1.67" dependencies = [  "roxmltree",  "serde",@@ -42,7 +42,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "serde",@@ -51,7 +51,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "serde",@@ -70,7 +70,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "serde",@@ -79,7 +79,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.66"+version = "0.1.67" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.66"+version = "0.1.67" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.66"+version = "0.1.67" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
docs/release-0.1.67.mdadded+16
@@ -0,0 +1,16 @@+# AI Flow 0.1.67++Silkscreen in the Fusion lane, made to the standard of the KiCad lane, and the traps found doing it.++- **Contact tags.** Every edge machine contact gets a framed tag: its reference over its net, the net read from the board's connections. Every tag in the set is the same width (text steps down rather than a frame growing), and a column shares its rails and row height.+- **Corner machine pins** are framed too, all four the same shape mirrored into each corner, labelled with their net. The corner shape is chosen together with the contact columns.+- **Frames break, never enclose**: a short gap round a small pad or via on the frame line (70% of it drawn at least); never another part's pad inside.+- **Leader lines** for labels with no room beside their part; a contact's net outranks a test pad's name; a net more than 1 mm from its pad is moved next to it (beside the pad, turned 90 degrees, where needed).+- **Drawn one item at a time** for video: `fusion silkscreen --pause-ms 700 --display "17 18 20 21 25 31" --no-switch`.+- **Text at the fab minimum**: the size ladders stop at the fab profile's minimum (3rdPartyFab 0.5 mm, JLCPCB 0.8 mm, Adom-tested; `AIFLOW_MIN_TEXT` overrides). The rule packs on adom/pcb-design-rules carry the same minimums.+- **Safety.** No DELETE on a line (a point on a part's tPlace art deletes the part): only at a plain text's or smashed name's exact origin. The pass checks the part count and stops with recovery hints if a part went missing. `--fresh` refuses to clear an earlier pass's lines; restore the earlier cloud version instead.+- **Hints** name the silkscreen standard, offer the user's logo and the board's wiki URL for the bottom layer, and say what to do when tags do not fit.++The aiflow-silkscreen skill gains "What good silkscreen looks like" and "Fusion lane: traps that cost a board".++Tested on the TPS54202 buck in Fusion (ConfRoomROG): 10 framed tags, all 38 parts kept, DRC 0 errors, and a dry run at the JLCPCB minimum reporting what does not fit.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.66",+  "version": "0.1.67",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.66",+  "version": "0.1.67",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
rules/jlcpcb-2L.kicad_dru+3−3
@@ -53,13 +53,13 @@ (rule "jlcpcb copper to edge"   (constraint edge_clearance (min 0.3mm))) -# Silkscreen. Page: "Min line width >= 0.15mm", "Min text height 40 mil (1.0mm)".+# Silkscreen. Page: "Min line width >= 0.15mm", "Min text height 40 mil (1.0mm)"; Adom has tested 0.8 mm text at JLCPCB and it prints legibly (2026-10-08). (rule "jlcpcb silk text"   (layer "F.SilkS")   (constraint text_thickness (min 0.153mm))-  (constraint text_height (min 1.0mm)))+  (constraint text_height (min 0.8mm)))  (rule "jlcpcb silk text back"   (layer "B.SilkS")   (constraint text_thickness (min 0.153mm))-  (constraint text_height (min 1.0mm)))+  (constraint text_height (min 0.8mm)))
skills/aiflow-silkscreen/SKILL.md+21
@@ -97,3 +97,24 @@ For a short silkscreen population video, prefer a fixed full-board camera with F - After reopening the editor, start the clip with `capture start` (0.1.38 finds the reopened editor window); check the clip is recording before the first label. - Read the saved board back: every label at its designed position, layer and rotation, then the gate's native DRC and a native render of each face (`kicad-cli pcb render --side top|bottom`). - The dashboard replay opens in a Hydrogen webview on the user's machine; when the work has to stay on the test box, leave board.json and events.jsonl in the run for a later replay and say so.++## What good silkscreen looks like (every lane)++Silkscreen is the first thing a person reads on a board, and it carries Adom's name. Hold every board to these:++- **Every edge contact is a framed tag.** A rectangle round the contact holding its reference over its net (J1 over VIN). The net comes from the board's own connections, never from a value field.+- **Tags in a set are the same size.** Every edge contact's frame is the same width, and a column of contacts shares its rails and row height, so the eye scans the row as one set. A frame that would be wider than the others gets smaller text (0.8, then 0.6, then 0.5 mm) rather than a wider box.+- **Corner machine pins are framed too, all four the same shape**, mirrored into each corner, labelled with their net (GND) when wired and their reference when mechanical only. Choose the corner shape together with the contact columns: a corner frame that crowds the columns makes them uneven.+- **A frame may break, never enclose.** It may have a short gap where it would cross a small pad or a via (at least 70% of it drawn); it never shuts another part's pad inside it. When a test pad sits in a contact's column, move the test pad, not the contact.+- **A label belongs next to its part.** A contact's net more than about 1 mm from its pad reads as a neighbour's. When there is no room beside a part, put the label where there is room and draw a thin leader line to the part. A contact's net outranks a test pad's or a small part's name.+- **Text as small as the fab allows, no smaller.** Use the fab profile's minimum text height (3rdPartyFab: 0.5 mm; JLCPCB: 0.8 mm, tested by Adom), 0.15 mm stroke.+- **The bottom carries the identity**: the board's title, the date, the owner's logo (encourage the user to add theirs: it is good practice), and the URL of the board's wiki page so anyone holding the board can find its files.+- **On video, draw it one item at a time**: each frame, then its name and net, then the rest, with a short pause each (`fusion silkscreen --pause-ms 700`), on a view with only tPlace, tNames, pads, vias, cream and the outline showing (`--display "17 18 20 21 25 31"`).++## Fusion lane: traps that cost a board (2026-10-08)++- **DELETE by point can delete a whole part.** In the board editor a point on a part's own tPlace artwork picks the part, and DELETE removes it with its connections (J4 and MP4 went this way). Delete only at a plain text's or smashed name's exact origin, never on a line, and check the part count after. `fusion silkscreen` refuses to clear an earlier pass's lines for this reason.+- **CHANGE by point does not land** (CHANGE LAYER, CHANGE SIZE at a point, or by 'R1>NAME'): use DELETE at the origin, or MOVE/ROTATE by name.+- **`fusion_board_source` saves a new cloud version** each time it reads the board. An edit is never "unsaved": to undo a pass, promote the earlier version (`fusion_aps_versions {query}`, then Fusion API `DataFile.versions[n].promote()`), close the open copy with `{name, save:false}`, and reopen.+- **Never close the bridge's "ADOM" panel window with a window-close call**: it closes Fusion. Click its own Close button.+- **`fusion_close_document` with no name closes the active document.** Always pass `{name, save:false}`.
tools/fusion-project.py+662−33
@@ -1040,7 +1040,8 @@ def board_model(text):             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'}+        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 = []     for m in re.finditer(r'<element ([^>]*)>(.*?)</element>|<element ([^>]*)/>', text, re.S):         tag = m.group(1) or m.group(3); inner = m.group(2) or ''@@ -1057,8 +1058,18 @@ def board_model(text):         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-        els.append({'name': name, 'package': pkg, 'value': _num(tag, 'value', ''), 'x': ex, 'y': ey, 'bottom': mir, 'pads': pads, 'body': body,+        hx_, hy_ = p.get('nameXY', (0, 0))+        els.append({'name': name, 'package': pkg, 'value': _num(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)]@@ -1067,6 +1078,9 @@ def board_model(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)@@ -1074,7 +1088,7 @@ def board_model(text):         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})+        texts.append({'text': body, 'layer': layer, 'box': box, 'at': (x, y)})     for e in els:         e['nameBox'] = None         if e['nameAttr']:@@ -1098,7 +1112,377 @@ def text_box(x, y, s, size, align='bottom-left'):     if align == 'center': x0 = x - w / 2     return (x0, y0, x0 + w, y0 + h) -def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=()):+def is_contact(e):+    """edge machine contacts and corner machine pins: both get a framed tag (name, and net when wired)"""+    pk = (e['package'] or '').upper()+    return ('MACHINECONTACT' in pk or 'MACHINEPIN' in pk) and not e['bottom']++def _ring_gaps(f, blockers, width=0.15, clear=0.15):+    """The frame f (stroke centre line) as straight pieces with gaps where it would cross a blocker (pad,+    mask opening or via, inflated by the clearance), the KiCad lane's 'interrupted for clearance' frame.+    Returns (pieces, kept fraction)."""+    x0, y0, x1, y1 = f; h = width / 2 + clear+    sides = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]+    pieces, total, kept = [], 0.0, 0.0+    for (a, b) in sides:+        horiz = abs(a[1] - b[1]) < 1e-9+        lo, hi = (min(a[0], b[0]), max(a[0], b[0])) if horiz else (min(a[1], b[1]), max(a[1], b[1]))+        c = a[1] if horiz else a[0]+        cuts = []+        for bb in blockers:+            if horiz and bb[1] - h < c < bb[3] + h: cuts.append((bb[0] - h, bb[2] + h))+            if not horiz and bb[0] - h < c < bb[2] + h: cuts.append((bb[1] - h, bb[3] + h))+        cuts.sort(); cur = lo; total += hi - lo+        for (u, v) in cuts:+            if v <= cur or u >= hi: continue+            if u > cur + 0.15: pieces.append(((cur, c), (u, c)) if horiz else ((c, cur), (c, u))); kept += u - cur+            cur = max(cur, v)+        if hi > cur + 0.15: pieces.append(((cur, c), (hi, c)) if horiz else ((c, cur), (c, hi))); kept += hi - cur+    return pieces, (kept / total if total else 0)++def plan_frames(els, outline, vias, edge=0.12, width=0.15, gap=0.2, corner_orients=None):+    """Machine-contact groups, the way the KiCad lane draws them (aiflow-silkscreen, contactBorders): one+    printed frame per contact enclosing the contact, its reference and its net, as large as the space+    allows. A column of contacts shares rails (from just inside the board edge to past the widest label)+    and a row height of the pitch less 0.2 mm, so the column reads as a set of tags. A frame is rejected+    when any other part's copper, any via or the board edge comes inside it; that contact then gets its own+    frame (its own width, shifted up or down), and only if nothing fits does it fall back to a frame round+    the pad with the labels outside. Text steps down from 0.8 to 0.5 mm before a frame is given up.+    Returns (groups, unresolved): a group is {'ref', 'frame': (x0, y0, x1, y1) stroke centre line,+    'texts': [label dicts like plan_labels makes]}."""+    if not outline: return [], []+    cs = [e for e in els if is_contact(e) and e['pads']]+    others = [b for e in els if not is_contact(e) for b in e['pads']] + [b for e in els if is_contact(e) for b in e['pads']]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    cx_b = (outline[0] + outline[2]) / 2+    def pad_box(e): return (min(p[0] for p in e['pads']), min(p[1] for p in e['pads']), max(p[2] for p in e['pads']), max(p[3] for p in e['pads']))+    def tw(t, size): return 0.95 * size * len(t) + 0.1+    def fits(e, fr):+        """the frame fr (outer bounds incl. stroke) holds nothing of anyone else and stays on the board"""+        if fr[0] < outline[0] + edge or fr[2] > outline[2] - edge or fr[1] < outline[1] + edge or fr[3] > outline[3] - edge: return False+        own = pad_box(e)+        for b in others:+            if b == own or (own[0] <= b[0] and b[2] <= own[2] and own[1] <= b[1] and b[3] <= own[3]): continue+            if _ov(fr, b, 0.15): return False+        for v in viab:+            inside_own = own[0] <= (v[0] + v[2]) / 2 <= own[2] and own[1] <= (v[1] + v[3]) / 2 <= own[3]+            if not inside_own and _ov(fr, v, 0.15): return False+        for o in els:+            if o is e or is_contact(o) or o['bottom'] or o['name'] in skip_bodies: continue+            if _ov(fr, o['body'], 0.05): return False+        if any(_ov(fr, ob, 0.2) for (ref_, ob) in placed_outers if ref_ != e['name']): return False+        return True+    skip_bodies = set(); placed_outers = []+    def fit_or_gap(e, f, outer, texts):+        """(ok, pieces): the frame fits whole (pieces None), or breaks around small pads and vias that sit on its+        line (none shut inside, at least 70% of it drawn, no label on them), the aiflow-silkscreen rule"""+        if fits(e, outer): return True, None+        small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]+        small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]+        small += [v for v in viab if _ov(outer, v, 0.3)]+        if not small: return False, None+        if any(f[0] + 0.1 < (b[0] + b[2]) / 2 < f[2] - 0.1 and f[1] + 0.1 < (b[1] + b[3]) / 2 < f[3] - 0.1 for b in small): return False, None+        saved = list(others); saved_v = list(viab)+        others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]+        skip_bodies.update(o['name'] for o in small_owners)+        ok_ = fits(e, outer)+        others[:] = saved; viab[:] = saved_v; skip_bodies.clear()+        pieces, frac = _ring_gaps(f, small)+        if not ok_ or frac < 0.7 or any(_ov(t['box'], b, 0.1) for t in texts for b in small): return False, None+        return True, pieces+    def layout(e, size, rail=None, dy=0.0, pitch=None, mode='side'):+        """(frame, texts) for one contact. 'side': name over net inward of the pad. 'stack': name above the+        pad and net below it, both centred on the pad, for a contact with a big part close beside it."""+        pb = pad_box(e); left_col = (pb[0] + pb[2]) / 2 < cx_b; cy = (pb[1] + pb[3]) / 2 + dy+        if mode == 'stack':+            pcx = (pb[0] + pb[2]) / 2; th = size * 1.1+            nw, vw = tw(e['name'], size), tw(e['net'] or '', size)+            ny0 = pb[3] + 0.12; vy0 = pb[1] - 0.12 - th+            texts = [{'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(pcx - nw / 2, 3), round(ny0, 3), round(pcx + nw / 2, 3), round(ny0 + th, 3)], 'origin': [round(pcx - nw / 2, 3), round(ny0, 3)]}]+            if e['net']:+                texts.append({'ref': e['name'], 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R0', 'box': [round(pcx - vw / 2, 3), round(vy0, 3), round(pcx + vw / 2, 3), round(vy0 + th, 3)], 'origin': [round(pcx - vw / 2, 3), round(vy0, 3)]})+            span = max(nw, vw, pb[2] - pb[0]) / 2+            if left_col: x0 = outline[0] + edge + width / 2; x1 = rail if rail else pcx + span + 0.15+            else: x1 = outline[2] - edge - width / 2; x0 = rail if rail else pcx - span - 0.15+            if left_col and x1 < pcx + span + 0.15 - 0.01: return None+            if not left_col and x0 > pcx - span - 0.15 + 0.01: return None+            if pitch:+                y0, y1 = cy - (pitch - 0.2) / 2, cy + (pitch - 0.2) / 2+            else:+                y0, y1 = vy0 - 0.15, ny0 + th + 0.15+            if y0 > vy0 - 0.08 or y1 < ny0 + th + 0.08: return None+            fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)+            return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts+        nw, vw, th = tw(e['name'], size), tw(e['net'] or '', size), size * 1.1+        need = max(nw, vw)+        if left_col:+            tx0 = pb[2] + gap; x0 = outline[0] + edge + width / 2; x1 = rail if rail else tx0 + need + gap+            if x1 < tx0 + need + gap - 0.01: return None+        else:+            tx1 = pb[0] - gap; x1 = outline[2] - edge - width / 2; x0 = rail if rail else tx1 - need - gap+            if x0 > tx1 - need - gap + 0.01: return None+        half = (pitch - 0.2) / 2 if pitch else max(th + 0.1, (pb[3] - pb[1]) / 2 + 0.15)+        y0, y1 = cy - half, cy + half+        if y1 - y0 < 2 * th + 0.3 or y0 > pb[1] - 0.1 or y1 < pb[3] + 0.1: return None+        texts = []+        for (t, kind, ty) in ((e['name'], 'name', cy + 0.08), (e['net'], 'net', cy - 0.08 - th)):+            if not t: continue+            w = tw(t, size)+            bx0 = tx0 if left_col else tx1 - w+            texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})+        fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)+        return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts+    groups, bad = [], []+    # corner machine pins: one frame shape for all four, mirrored into each corner, so the corners read as a+    # matched set. The frame runs from the board edges past the pin to hold its name over its net beside it+    # (on the side toward the board's middle); the largest text that fits every corner wins.+    corners = [e for e in cs if 'MACHINEPIN' in (e['package'] or '').upper()]+    if corners:+        cy_b = (outline[1] + outline[3]) / 2+        placed_corners = False+        for orient in (corner_orients or ('h', 'v', 'v1')):+          if placed_corners: break+          for size in _sz(0.8, 0.6, 0.5):+              th = size * 1.1+              one = orient == 'v1'  # one line: the net (the reference when the pin is mechanical only)+              lw = max((tw(e['net'] or e['name'], size) if one else max(tw(e['name'], size), tw(e['net'] or '', size))) for e in corners)+              pw = max(pad_box(e)[2] - pad_box(e)[0] for e in corners); ph = max(pad_box(e)[3] - pad_box(e)[1] for e in corners)+              # distance from the edge to the pin's far side, the same for all (the largest)+              dx = max(min(pad_box(e)[2] - outline[0], outline[2] - pad_box(e)[0]) for e in corners)+              dy = max(min(pad_box(e)[3] - outline[1], outline[3] - pad_box(e)[1]) for e in corners)+              if orient == 'h': fw = dx + gap + lw + 0.2; fh = max(dy + 0.2, 2 * th + 0.5)+              else: fw = max(dx + 0.25 - (edge + width / 2), lw + 0.4); fh = dy - (edge + width / 2) + gap + (1 if one else 2) * th + (0.25 if one else 0.3)+              trial = []+              for e in corners:+                  pb = pad_box(e); left = (pb[0] + pb[2]) / 2 < cx_b; low = (pb[1] + pb[3]) / 2 < cy_b+                  x0 = outline[0] + edge + width / 2 if left else outline[2] - edge - width / 2 - fw+                  y0 = outline[1] + edge + width / 2 if low else outline[3] - edge - width / 2 - fh+                  f = (round(x0, 3), round(y0, 3), round(x0 + fw, 3), round(y0 + fh, 3))+                  if orient == 'h':+                      tx = (pb[2] + gap) if left else (pb[0] - gap - lw)+                      tcy = (f[1] + f[3]) / 2+                  else:+                      tx = (pb[0] + pb[2]) / 2 - lw / 2+                      tcy = ((pb[3] + gap + th + 0.05) if low else (pb[1] - gap - th - 0.05)) if not one else ((pb[3] + gap + th / 2) if low else (pb[1] - gap - th / 2))+                  texts = []+                  rows_ = ([(e['net'], 'net', tcy - th / 2)] if e['net'] else [(e['name'], 'name', tcy - th / 2)]) if one else ((e['name'], 'name', tcy + 0.08), (e['net'], 'net', tcy - 0.08 - th))+                  for (t, kind, ty) in rows_:+                      if not t: continue+                      w = tw(t, size); bx0 = tx + (((lw - w) if not left else 0) if orient == 'h' else (lw - w) / 2)+                      texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})+                  outer = (f[0] - width / 2, f[1] - width / 2, f[2] + width / 2, f[3] + width / 2)+                  trial.append((e, f, outer, texts))+              ok_all, gapped = True, {}+              _dbg = os.environ.get('SILK_DEBUG')+              for (e, f, outer, texts) in trial:+                  if fits(e, outer): continue+                  # a frame may break where it would cross a small pad or a via, if the break is short, the+                  # blocker is not shut inside, and nothing else is in the way (aiflow-silkscreen rule)+                  own = pad_box(e)+                  small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]+                  small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]+                  small += [v for v in viab if _ov(outer, v, 0.3)]+                  inside = [b for b in small if f[0] < (b[0] + b[2]) / 2 < f[2] and f[1] < (b[1] + b[3]) / 2 < f[3] and not (b[0] < f[0] or b[2] > f[2] or b[1] < f[1] or b[3] > f[3])]+                  if _dbg: print('corner', orient, size, e['name'], 'small', [round(v,2) for b in small for v in b][:8], 'inside', bool(inside), file=sys.stderr)+                  if inside: ok_all = False; break+                  # without the small blockers, would it fit?+                  saved = list(others); saved_v = list(viab)+                  others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]+                  skip_bodies.update(o['name'] for o in small_owners)+                  clear_rest = fits(e, outer)+                  others[:] = saved; viab[:] = saved_v; skip_bodies.clear()+                  pieces, frac = _ring_gaps(f, small)+                  if _dbg: print('  clear_rest', clear_rest, 'frac', round(frac, 2), 'textclash', any(_ov(tb['box'], b, 0.1) for tb in texts for b in small), file=sys.stderr)+                  if not clear_rest or frac < 0.7 or any(_ov(tb['box'], b, 0.1) for tb in texts for b in small): ok_all = False; break+                  gapped[e['name']] = pieces+              if ok_all:+                  for (e, f, outer, texts) in trial:+                      groups.append({'ref': e['name'], 'frame': f, 'outer': outer, 'texts': texts, 'pieces': gapped.get(e['name'])})+                      placed_outers.append((e['name'], outer))+                  placed_corners = True; break+        if not placed_corners:+            bad += ['%s frame' % e['name'] for e in corners]+        cs = [e for e in cs if e not in corners]+    # columns: contacts on one vertical line share rails and a row height+    cols = []+    for e in cs:+        pb = pad_box(e); x = (pb[0] + pb[2]) / 2+        c = next((c for c in cols if abs(c['x'] - x) < 0.1), None)+        if c: c['m'].append(e)+        else: cols.append({'x': x, 'm': [e]})+    for c in cols:+        m = sorted(c['m'], key=lambda e: pad_box(e)[1])+        ys = [(pad_box(e)[1] + pad_box(e)[3]) / 2 for e in m]+        pitch = min([b - a for a, b in zip(ys, ys[1:]) if b - a > 0.1] or [None]) if len(m) > 1 else None+        if pitch: pitch = min(pitch, 4.0)+        left_col = c['x'] < cx_b+        done = {}+        # rows shrink together (never one alone) until the column clears its neighbours and the corner frames+        for size in _sz(0.8, 0.6, 0.5):+          for mode in ('side', 'stack'):+            for pt in ([pitch - 0.3 * k for k in range(0, 6)] if pitch else [None]):+              if pt is not None and pt < 2 * size * 1.1 + 0.6: break+              # the shared rail: past the widest label of the column at this size+              lays = [layout(e, size, pitch=pt, mode=mode) for e in m]+              if any(l is None for l in lays): continue+              rail = max(l[0][2] for l in lays) if left_col else min(l[0][0] for l in lays)+              shared = [layout(e, size, rail=rail, pitch=pt, mode=mode) for e in m]+              if os.environ.get('SILK_DEBUG'): print('col', [e['name'] for e in m], size, mode, pt, [(e['name'], bool(l), bool(l) and fits(e, l[1]), l and [round(v, 2) for v in l[1]]) for e, l in zip(m, shared)], file=sys.stderr)+              res = [fit_or_gap(e, l[0], l[1], l[2]) if l else (False, None) for e, l in zip(m, shared)]+              if all(r_[0] for r_ in res):+                  for e, l, r_ in zip(m, shared, res): done[e['name']] = l + (r_[1],)+                  break+            if len(done) == len(m): break+          if len(done) == len(m): break+        for e in m:+            if e['name'] in done: continue+            # its own frame: its own width, then shifted off whatever is in the way, then smaller rows+            got = None+            for size in _sz(0.8, 0.6, 0.5):+                for mode in ('side', 'stack'):+                    for pt in ([pitch] if pitch else []) + [None]:+                        for dy in (0.0, -0.3, 0.3, -0.6, 0.6, -0.9, 0.9):+                            l = layout(e, size, dy=dy, pitch=pt, mode=mode)+                            if l:+                                ok_, pc = fit_or_gap(e, l[0], l[1], l[2])+                                if ok_: got = l + (pc,); break+                        if got: break+                    if got: break+                if got: break+            if got: done[e['name']] = got+            else: bad.append('%s frame' % e['name'])+        for e in m:+            if e['name'] in done:+                f, fr, texts, pc = done[e['name']]+                groups.append({'ref': e['name'], 'frame': f, 'outer': fr, 'texts': texts, 'pieces': pc}); placed_outers.append((e['name'], fr))+    # every edge contact's frame the same width (the eye scans the row of tags as one set): the widest that+    # fits all of them, else the narrowest that still holds every label; each keeps its board-edge side+    edge_g = [g for g in groups if 'MACHINEPIN' not in (next(e for e in els if e['name'] == g['ref'])['package'] or '').upper()]+    if len(edge_g) > 1:+        widths = sorted(set(round(g['frame'][2] - g['frame'][0], 3) for g in edge_g), reverse=True)+        def refit(g, nf):+            # the tag's texts, re-sized (0.8, 0.6, 0.5) and re-centred to sit inside the frame nf+            out = []+            for t in g['texts']:+                for size in (t['size'],) + _sz(0.6, 0.5):+                    if size > t['size']: continue+                    w = 0.95 * size * len(t['text']) + 0.1; th = size * 1.1+                    if w <= (nf[2] - nf[0]) - 0.3:+                        cx_ = (t['box'][0] + t['box'][2]) / 2; cy_ = (t['box'][1] + t['box'][3]) / 2+                        cx_ = min(max(cx_, nf[0] + 0.15 + w / 2), nf[2] - 0.15 - w / 2)+                        out.append(dict(t, size=size, box=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3), round(cx_ + w / 2, 3), round(cy_ + th / 2, 3)], origin=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3)]))+                        break+                else:+                    return None+            return out+        for W in widths:+            trial = []+            for g in edge_g:+                f = g['frame']; e = next(x for x in els if x['name'] == g['ref'])+                left = (f[0] + f[2]) / 2 < cx_b+                nf = (f[0], f[1], round(f[0] + W, 3), f[3]) if left else (round(f[2] - W, 3), f[1], f[2], f[3])+                tx = refit(g, nf)+                if tx is None: trial = None; break+                no = (nf[0] - width / 2, nf[1] - width / 2, nf[2] + width / 2, nf[3] + width / 2)+                saved = list(placed_outers); placed_outers[:] = [po for po in placed_outers if po[0] != g['ref']]+                ok_, pc = fit_or_gap(e, nf, no, tx)+                ok_ = ok_ and not any(_ov(t['box'], b, 0.1) for t in tx for b in others if not (e['pads'] and b in e['pads']))+                placed_outers[:] = saved+                if not ok_: trial = None; break+                trial.append((g, nf, no, tx, pc))+            if trial:+                for g, nf, no, tx, pc in trial:+                    g['frame'] = nf; g['outer'] = no; g['texts'] = tx; g['pieces'] = pc+                break+    return groups, bad++def min_text():+    """the fab profile's smallest silkscreen text (mm): 3rdPartyFab 0.5, JLCPCB 0.8 (Adom-tested), else 0.5;+    AIFLOW_MIN_TEXT overrides"""+    if os.environ.get('AIFLOW_MIN_TEXT'): return float(os.environ['AIFLOW_MIN_TEXT'])+    try:+        d = json.loads((RUN / 'run.json').read_text()); slug = str(((d.get('data') or d).get('fabRules') or {}).get('slug') or '')+    except Exception:+        slug = ''+    return 0.8 if 'jlc' in slug.lower() else 0.5++def _sz(*sizes):+    """a text size ladder cut at the fab profile's minimum (never empty: the minimum itself at worst)"""+    m = min_text(); out = tuple(x for x in sizes if x >= m - 1e-9)+    return out or (m,)++def _seg_boxes(p, q, w=0.15, step=0.1):+    """a straight silk line from p to q as small boxes along it (for collision checks)"""+    n = max(1, int(math.hypot(q[0] - p[0], q[1] - p[1]) / step))+    h = w / 2+    return [(p[0] + (q[0] - p[0]) * i / n - h, p[1] + (q[1] - p[1]) * i / n - h, p[0] + (q[0] - p[0]) * i / n + h, p[1] + (q[1] - p[1]) * i / n + h) for i in range(n + 1)]++def _edge_point(b, toward):+    """the point on box b's edge nearest the point toward"""+    return (min(max(toward[0], b[0]), b[2]), min(max(toward[1], b[1]), b[3]))++def plan_leaders(items, els, outline, vias, taken, edge=0.3, gap=0.15, reach=10.0, sizes=None):+    """Labels with no room beside their part, the way the KiCad lane's leaders do it (silkscreen-leaders):+    the label goes where there is room, within reach of the part, and a thin silk line runs from the label+    to the part's edge. items: [{'ref', 'texts': [(text, kind)], 'target': box, 'frame': bool}], a framed+    item being a contact tag (name over net, framed). The label area, its frame and the line keep clear of+    every pad (but the line may end on the target's own outline), via, label and frame; the nearest spot+    whose line is clear wins, at 1.0 mm text where it fits, stepping down to the fab profile's smallest. Returns (placed, unresolved):+    placed = [{'ref', 'texts': [label dicts], 'frame': box or None, 'leader': [(x, y), (x, y)]}]."""+    pads = [(e, b) for e in els for b in e['pads']]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge)+    bodies = [(e, e['body']) for e in els if not e['bottom']]+    out, bad = [], []+    for it in items:+        tgt = it['target']; tcx, tcy = (tgt[0] + tgt[2]) / 2, (tgt[1] + tgt[3]) / 2+        own = next((e for e in els if e['name'] == it['ref']), None)+        best = None+        sizes = sizes or _sz(1.0, 0.8, 0.6, 0.5)+        # a tag that will not fit framed is tried again without its frame (still name over net, still led)+        for framed in ([True, False] if it.get('frame') else [False]):+          if best: break+          for size in sizes:+              th = size * 1.1+              ws = [0.95 * size * len(t) + 0.1 for t, _ in it['texts']]+              w = max(ws); h = th * len(it['texts']) + 0.1 * (len(it['texts']) - 1)+              pad_ = 0.25 if framed else 0.0+              W, H = w + 2 * pad_, h + 2 * pad_+              cands = []+              r = 1.0+              while r <= reach:+                  for k in range(24):+                      ang = 2 * math.pi * k / 24+                      cx, cy = tcx + r * math.cos(ang), tcy + r * math.sin(ang)+                      cands.append((r, cx - W / 2, cy - H / 2))+                  r += 0.25+              for (r, x0, y0) in cands:+                  area = (x0, y0, x0 + W, y0 + H)+                  if not (area[0] >= inner[0] and area[1] >= inner[1] and area[2] <= inner[2] and area[3] <= inner[3]): continue+                  if any(_ov(area, b, 0.12) for (_, b) in pads) or any(_ov(area, v, 0.12) for v in viab): continue+                  if any(_ov(area, t, gap) for t in taken): continue+                  if any(_ov(area, b, 0.05) for (e, b) in bodies): continue+                  a_ = _edge_point(area, (tcx, tcy)); b_ = _edge_point(tgt, (a_[0], a_[1]))+                  if math.hypot(b_[0] - a_[0], b_[1] - a_[1]) < 0.3: continue+                  segs = _seg_boxes(a_, b_)[2:-2]+                  if any(_ov(sb, b, 0.08) for sb in segs for (e, b) in pads): continue+                  if any(_ov(sb, v, 0.08) for sb in segs for v in viab): continue+                  if any(_ov(sb, t, 0.05) for sb in segs for t in taken): continue+                  best = (size, area, pad_, a_, b_, ws, th, framed); break+              if best: break+        if not best: bad.append('%s leader' % it['ref']); continue+        size, area, pad_, a_, b_, ws, th, framed = best+        texts, y = [], area[3] - pad_ - th+        for (t, kind), w in zip(it['texts'], ws):+            x0 = area[0] + pad_ + (area[2] - area[0] - 2 * pad_ - w) / 2+            texts.append({'ref': it['ref'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(x0, 3), round(y, 3), round(x0 + w, 3), round(y + th, 3)], 'origin': [round(x0, 3), round(y, 3)], 'leader': True})+            y -= th + 0.1+        taken.append(area); taken.extend(_seg_boxes(a_, b_))+        out.append({'ref': it['ref'], 'texts': texts, 'frame': tuple(round(v, 3) for v in area) if framed else None,+                    'leader': [(round(a_[0], 3), round(a_[1], 3)), (round(b_[0], 3), round(b_[1], 3))]})+    return out, bad++def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), skip=()):     """Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and     the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;     a smaller size before giving up; whatever cannot fit is named, never dropped silently."""@@ -1107,31 +1491,37 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=()):     viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]     inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None     taken, out, unresolved = list(fixed), [], []+    relax = [False]     def free(bx, own=None):         if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]): return False         if any(_ov(bx, p, 0.1) for p in pads): return False-        if any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False+        if not relax[0] and any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False         if any(_ov(bx, v, 0.05) for v in viab): return False         if any(_ov(bx, t, gap) for t in taken): return False         return True-    order = sorted([e for e in els if not e['bottom']], key=lambda e: -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1])))+    order = sorted([e for e in els if not e['bottom']], key=lambda e: (not is_contact(e), -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1]))))  # the contacts and their nets first: they are what a probe or a person looks for     for e in order:+        if e['name'] in skip: continue  # labelled inside its contact frame         b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2         texts = [(e['name'], 'name')]-        if e['package'] and 'MACHINECONTACT' in e['package'].upper() and e['value']: texts.append((e['value'], 'net'))+        if is_contact(e) and e.get('net'): texts.append((e['net'], 'net'))         for txt, kind in texts:             done = False             if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):                 continue  # printed by an earlier pass: it stays, and it is already an obstacle             # part names keep the library's 0.8 mm (a size change by name does not land in Fusion); the new             # net labels may step down. EAGLE's vector font measured at about 0.95 x size per character.-            for size in ((0.8,) if kind == 'name' else (0.8, 0.6, 0.5)):+            # near first, then small: every size is tried close to the part before any farther ring, since a+            # label beside its part at 0.6 mm reads better than one at 0.8 mm on the far side of a neighbour+            for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))):+              if done: break  # a contact's net stays close: far away it reads as a neighbour's+              for size in (_sz(0.8, 0.6) if kind == 'name' else _sz(0.8, 0.6, 0.5)):                 for rot in ('R0', 'R90'):                     # R0 first (reads left to right); R90 (reads bottom to top) where a part has no room beside it                     tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1                     w, h = (tw, th) if rot == 'R0' else (th, tw)                     cands = []-                    for d in (gap, gap + 0.4, gap + 0.9):+                    for d in ring_:                         for f in (0.0, -0.5, 0.5, -1.0, 1.0):                             sx, sy = f * w, f * h                             cands += [(cx - w / 2 + sx, b[3] + d), (cx - w / 2 + sx, b[1] - d - h), (b[2] + d, cy - h / 2 + sy), (b[0] - d - w, cy - h / 2 + sy)]@@ -1140,7 +1530,13 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=()):                         # the edge side first: the side of the contact nearest the outline                         dists = {'L': cx - outline[0], 'R': outline[2] - cx, 'B': cy - outline[1], 'T': outline[3] - cy}                         side = min(dists, key=dists.get)-                        cands.sort(key=lambda p: {'L': p[0], 'R': -p[0], 'B': p[1], 'T': -p[1]}[side])+                        # nearest the contact first, with the edge side favoured: a label far from its contact+                        # (on the far side of another part) reads as someone else's+                        def k_(p):+                            mx, my = p[0] + w / 2, p[1] + h / 2+                            on_edge = {'L': mx < cx, 'R': mx > cx, 'B': my < cy, 'T': my > cy}[side]+                            return ((mx - cx) ** 2 + (my - cy) ** 2) ** 0.5 - (1.0 if on_edge else 0.0)+                        cands.sort(key=k_)                     for (x0, y0) in cands:                         bx = (x0, y0, x0 + w, y0 + h)                         if free(bx, own=b):@@ -1150,6 +1546,30 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=()):                     if done: break                 if done: break             if not done: unresolved.append('%s %s' % (e['name'], kind))+    if not relax[0] and unresolved:+        # last resort for what is left: a label may sit over another part's outline (not its copper)+        relax[0] = True+        left = [u for u in unresolved]; unresolved.clear()+        for u in left:+            ref, kind = u.rsplit(' ', 1)+            e = next(x for x in order if x['name'] == ref); b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2+            txt = e['name'] if kind == 'name' else e['value']; done = False+            for size in _sz(0.8, 0.6):+                for rot in ('R0', 'R90'):+                    tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1+                    w, h = (tw, th) if rot == 'R0' else (th, tw)+                    cands = []+                    for d in (gap, gap + 0.4, gap + 0.9, gap + 1.5):+                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):+                            cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]+                    cands.sort(key=lambda p: ((p[0] + w / 2 - cx) ** 2 + (p[1] + h / 2 - cy) ** 2))+                    for (x0, y0) in cands:+                        bx = (x0, y0, x0 + w, y0 + h)+                        if free(bx, own=b):+                            taken.append(bx); out.append({'ref': ref, 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]}); done = True; break+                    if done: break+                if done: break+            if not done: unresolved.append(u)     return out, unresolved, taken  def identity_spot(els, outline, vias, lines):@@ -1176,15 +1596,138 @@ def identity_spot(els, outline, vias, lines): def cmd_silkscreen(a):     st = load_state(); name = a.design or st.get('design')     if not name: err('which design? pass --design <name>')-    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.'])+    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)-    fixed = [t['box'] for t in texts if t['layer'] == 21]-    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts)-    if unresolved:-        # a name that cannot move stays where it is: plan the others around it-        stay = [e['nameBox'] for e in els if e['nameBox'] and ('%s name' % e['name']) in unresolved]-        labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed + stay, existing=texts)+    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)+                if any(abs(t['at'][0] - e['x']) < 0.5 and abs(t['at'][1] - e['y']) < 0.5 for e in els): continue+                fresh_cmds += ['GRID MM;', 'DISPLAY NONE 21;', 'DELETE (%.3f %.3f);' % t['at']]+        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['layer'] == 21 and t['text'] in {e.get('net') or e['value'] for e in contacts} and t.get('at') and near(*t['at']))]+        if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(vis or ['1', '16', '17', '18', '20', '21', '25']))]+    # 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]+    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=grouped)+    labels = [t for g in groups for t in g['texts']] + labels+    unresolved = frame_bad + unresolved+    # what has no room beside its part goes further out with a leader line (contacts as framed tags)+    taken_now = list(fixed) + [L['box'] for L in labels]+    items = []+    for u in list(unresolved):+        ref, kind = u.rsplit(' ', 1)+        e = next((x for x in els if x['name'] == ref), None)+        if not e: continue+        if kind == 'frame':+            items.append({'ref': ref, 'texts': [(e['name'], 'name'), (e['net'], 'net')] if e.get('net') else [(e['name'], 'name')], 'target': e['body'], 'frame': True})+        elif kind == 'name':+            items.append({'ref': ref, 'texts': [(e['name'], 'name')], 'target': e['body'], 'frame': False})+    framed_refs = {it['ref'] for it in items if it['frame']}+    taken_now = list(fixed) + [L['box'] for L in labels]+    leaders, leader_bad = plan_leaders(items, els, outline, vias, taken_now)+    led = {l['ref'] for l in leaders}+    labels = [L for L in labels if not (L['ref'] in framed_refs and L['ref'] in led)]  # a placed tag replaces its outside labels+    done_refs = {(l['ref']) for l in leaders}+    unresolved = [u for u in unresolved if u.rsplit(' ', 1)[0] not in done_refs] + leader_bad+    labels = labels + [t for l in leaders for t in l['texts']]+    # a contact that got neither a frame nor a led tag: its name straight above its pad and its net straight+    # below (or the other way round), each centred on the pad, so the pair reads as that contact's and no+    # other's. Only when both fit; otherwise its earlier spots stay.+    padsall = [(o, b) for o in els for b in o['pads']]+    viab_ = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    for u in [u for u in unresolved if u.endswith(' frame')]:+        ref = u.rsplit(' ', 1)[0]; e = next((x for x in els if x['name'] == ref), None)+        if not e or ref in {l['ref'] for l in leaders}: continue+        pb = (min(q[0] for q in e['pads']), min(q[1] for q in e['pads']), max(q[2] for q in e['pads']), max(q[3] for q in e['pads']))+        pcx = (pb[0] + pb[2]) / 2+        others_l = [L['box'] for L in labels if L['ref'] != ref] + [g['outer'] for g in groups]+        got = None+        for size in _sz(0.8, 0.6, 0.5):+            th = size * 1.1+            for flip in (False, True):+                rows = [(e['name'], 'name'), (e.get('net'), 'net')]+                if flip: rows = rows[::-1]+                cand = []+                for (t, kind), y0 in zip(rows, (pb[3] + 0.12, pb[1] - 0.12 - th)):+                    if not t: continue+                    w = 0.95 * size * len(t) + 0.1+                    cand.append({'ref': ref, 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(pcx - w / 2, 3), round(y0, 3), round(pcx + w / 2, 3), round(y0 + th, 3)], 'origin': [round(pcx - w / 2, 3), round(y0, 3)]})+                def clear(b):+                    if outline and not (b[0] >= outline[0] + 0.3 and b[1] >= outline[1] + 0.3 and b[2] <= outline[2] - 0.3 and b[3] <= outline[3] - 0.3): return False+                    if any(_ov(b, q, 0.1) for (o, q) in padsall): return False+                    if any(_ov(b, v, 0.1) for v in viab_): return False+                    if any(_ov(b, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): return False+                    return not any(_ov(b, x, 0.12) for x in others_l)+                if cand and all(clear(c['box']) for c in cand): got = cand; break+            if got: break+        if got:+            labels = [L for L in labels if L['ref'] != ref] + got+            continue+        # the name straight above the pad (a name below an unframed contact sits next to its neighbour's net)+        nm = next((L for L in labels if L['ref'] == ref and L['kind'] == 'name'), None)+        if nm and nm['box'][1] < pb[3]:+            others_n = [L['box'] for L in labels if L is not nm] + [g['outer'] for g in groups]+            for size in _sz(0.8, 0.6, 0.5):+                th = size * 1.1; w = 0.95 * size * len(ref) + 0.1+                c = (pcx - w / 2, pb[3] + 0.12, pcx + w / 2, pb[3] + 0.12 + th)+                if outline and c[3] > outline[3] - 0.3: break+                if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue+                if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue+                if any(_ov(c, x, 0.12) for x in others_n): continue+                labels = [L for L in labels if L is not nm] + [{'ref': ref, 'kind': 'name', 'text': ref, 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in c], 'origin': [round(c[0], 3), round(c[1], 3)]}]+                break+        # the net alone, turned 90 degrees in the strip between the pad and the nearest board edge, when its+        # current spot is far from the pad (more than 1 mm off it reads as a neighbour's)+        nl = next((L for L in labels if L['ref'] == ref and L['kind'] == 'net'), None)+        if not nl or not outline: continue+        bx = nl['box']; far = max(pb[0] - bx[2], bx[0] - pb[2], pb[1] - bx[3], bx[1] - pb[3]) > 1.0+        if not far: continue+        for size in _sz(0.8, 0.6, 0.5):+            th = size * 1.1; w = 0.95 * size * len(e.get('net') or '') + 0.1; pcy = (pb[1] + pb[3]) / 2+            cands = []+            if outline[2] - pb[2] < pb[0] - outline[0]: cands.append((pb[2] + 0.12, pcy - w / 2, pb[2] + 0.12 + th, pcy + w / 2))+            else: cands.append((pb[0] - 0.12 - th, pcy - w / 2, pb[0] - 0.12, pcy + w / 2))+            others_l = [L['box'] for L in labels if L is not nl] + [g['outer'] for g in groups]+            ok_ = None+            for c in cands:+                if c[0] < outline[0] + 0.3 or c[2] > outline[2] - 0.3 or c[1] < outline[1] + 0.3 or c[3] > outline[3] - 0.3: continue+                if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue+                if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue+                # a contact's net outranks a test pad's or a small part's name: those give way (off the print)+                yield_ = [L for L in labels if L is not nl and _ov(c, L['box'], 0.12)]+                if any(L['kind'] != 'name' or is_contact(next((x for x in els if x['name'] == L['ref']), e)) for L in yield_): continue+                if any(_ov(c, g['outer'], 0.12) for g in groups): continue+                ok_ = c; break+            if ok_:+                for L in yield_: unresolved.append('%s name' % L['ref'])+                labels = [L for L in labels if L not in yield_]+                labels = [L for L in labels if L is not nl] + [{'ref': ref, 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R90', 'box': [round(v, 3) for v in ok_], 'origin': [round(ok_[0], 3), round(ok_[1], 3)]}]+                break+    frames = frames + [(l['ref'], l['frame']) for l in leaders if l['frame']]     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)@@ -1201,36 +1744,114 @@ def cmd_silkscreen(a):             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-    cmds = ['GRID MM;']+    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']+    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)]             cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)]+            if L['size'] != 0.8:+                # a size change by name does not land in Fusion: pick the text at its origin, with only the+                # names layer shown so nothing else is under the point+                cmds += ['DISPLAY NONE 25;', 'CHANGE SIZE %s (%.3f %.3f);' % (L['size'], ax, ay), 'DISPLAY NONE %s;' % (a.display or ' '.join(vis_now))]         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 += ['CHANGE LAYER 22;', 'CHANGE SIZE %s;' % spot[2], 'CHANGE RATIO 15;', 'CHANGE ALIGN center;']+        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))-    plan = {'labels': labels, 'unresolved': unresolved, 'identity': {'lines': lines, 'at': spot}, 'commands': cmds}+        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+        if any(abs(nx - o['x']) < 0.5 and abs(ny - o['y']) < 0.5 for o in els): continue+        # deleting a smashed NAME removes only that printed text, the part stays (tested 2026-10-08)+        steps.append(('%s name off print' % g['ref'], ['GRID MM;', 'DISPLAY NONE 25;', 'DELETE (%.3f %.3f);' % (nx, ny), 'DISPLAY NONE %s;' % (a.display or ' '.join(vis_now))]))+    # 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)+        if any(abs(nx - o['x']) < 0.5 and abs(ny - o['y']) < 0.5 for o in els): continue+        steps.append(('%s name off print' % ref, ['GRID MM;', 'DISPLAY NONE 25;', 'DELETE (%.3f %.3f);' % (nx, ny), 'DISPLAY NONE %s;' % (a.display or ' '.join(vis_now))]))+    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}     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 labels, %d unresolved%s; %s' % (len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved else '', STATE / 'silkscreen-plan.json'))-    ab('fusion_show_2d_board', {'documentName': name})-    # one script file run by Fusion's SCRIPT command: the bridge refuses a bare MOVE (it is interactive-    # without coordinates), and a script lands every label as the editor's own commands, undoable+        ok('silkscreen plan (not landed): %d contact frames, %d labels, %d unresolved%s; %s' % (len(frames), len(labels), len(unresolved), (' (' + ', '.join(unresolved) + ')') if unresolved 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 = []-    scr = STATE / 'silkscreen.scr'; scr.write_text('\n'.join(cmds) + '\n')+    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([scr], dest)-    r = ab('fusion_electron_run', {'command': "SCRIPT '%s/silkscreen.scr';" % dest})-    if r.get('success') is False: failed.append('SCRIPT -> %s' % str(r.get('error'))[:160])+    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+    # 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, []     for L in labels:         if L['kind'] != 'name': continue@@ -1240,12 +1861,15 @@ def cmd_silkscreen(a):     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', {'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot)})+    prove('silkscreen', {'frames': len(frames), 'labels': len(labels), 'moved': moved, 'unresolved': unresolved, 'failed': len(failed), 'identity': bool(spot)})     if failed or off:         err('silkscreen landed in part: %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 part names placed readable and clear of copper, %d contact net labels on the edge side%s%s, board identity on the bottom%s' % (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`.'])+    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@@ -1412,6 +2036,11 @@ def main():     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('--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)