app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Publish 0.1.67
9 files changed
+715−49
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 [^>]*>>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)