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.68
7 files changed
+129−28
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.67"+version = "0.1.68" dependencies = [ "serde", "serde_json",@@ -33,7 +33,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.67"+version = "0.1.68" dependencies = [ "roxmltree", "serde",@@ -42,7 +42,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "serde",@@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "serde",@@ -70,7 +70,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "serde",@@ -79,7 +79,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.67"+version = "0.1.68" dependencies = [ "aiflow-board", "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.67"+version = "0.1.68" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.67"+version = "0.1.68" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
docs/release-0.1.68.mdadded+10@@ -0,0 +1,10 @@+# AI Flow 0.1.68++Component values on the Fusion silkscreen, and two more ways a pass could damage a board, closed.++- **Values, small, beside their reference.** Every resistor, capacitor and inductor gets its value at the fab profile's minimum text (0.5 mm on 3rdPartyFab) right after or under its reference (0.8 mm), placed as one unit; only where no unit fits do they go separately, still close to the part. Values print the way boards print them: 10uF 25V as 10u, 100nF as 100n, 15uH as 15u, 5.6k as 5k6. ICs and LEDs carry none. This was already the aiflow-silkscreen rule ("the reference has priority and is larger; the value is mandatory and visibly smaller"); the Fusion lane now does it.+- **A smaller reference prints at its real size.** CHANGE SIZE does not land in Fusion, so a reference planned below 0.8 mm printed at 0.8 and could collide. Now the smashed name comes off and the reference is plain text at the planned size.+- **Names come off without picking a part.** A DELETE where a name stood picked the part itself (J4). A name is now moved by name to an empty spot off the board and deleted there. An earlier pass's plain text is deleted only where no part's outline or pad is within 0.3 mm.+- The readback check no longer reports the plain-text references as misplaced.++Tested on the TPS54202 buck in Fusion (ConfRoomROG): 16 values placed, all 38 parts kept through the pass.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.1.67",+ "version": "0.1.68", "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.67",+ "version": "0.1.68", "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.",
tools/fusion-project.py+106−15@@ -1411,6 +1411,18 @@ def _sz(*sizes): m = min_text(); out = tuple(x for x in sizes if x >= m - 1e-9) return out or (m,) +def silk_value(v, ref):+ """the value as silkscreen prints it: 10uF 25V -> 10u, 100nF -> 100n, 15uH -> 15u, 68pF C0G -> 68p,+ 5.6k -> 5k6, 1M -> 1M; an IC or LED gets none (its reference and the BOM say it)"""+ if not v or ref.startswith(('U', 'IC', 'Q', 'D', 'LED')): return None+ m = re.match(r'^\s*([\d.]+)\s*([pnumkKMG\u00b5]?)\s*(?:F|H|R|ohm|\u03a9)?\b', v)+ if not m: return v.split()[0][:6]+ num, unit = m.group(1), m.group(2).replace('\u00b5', 'u')+ if ref.startswith('R'):+ unit = unit.replace('K', 'k') or 'R'+ return num.replace('.', unit) if '.' in num else num + ('' if unit == 'R' else unit)+ return num + unit+ def _seg_boxes(p, q, w=0.15, step=0.1): """a straight silk line from p to q as small boxes along it (for collision checks)""" n = max(1, int(math.hypot(q[0] - p[0], q[1] - p[1]) / step))@@ -1505,6 +1517,41 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), s b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2 texts = [(e['name'], 'name')] if is_contact(e) and e.get('net'): texts.append((e['net'], 'net'))+ # every component's value, small, right next to its reference (aiflow-silkscreen: the reference is+ # larger, the value mandatory and visibly smaller); test pads and machine parts carry none+ pk_ = (e['package'] or '').upper()+ sv = silk_value(e['value'], e['name']) if not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None+ if sv: texts.append((sv, 'value'))+ # the reference and its value as one unit first (aiflow-silkscreen: the pair is atomic): the value+ # inline after the reference, or stacked under it, the unit placed where a name would go+ vt = next((t for t, k in texts if k == 'value'), None)+ if vt and not is_contact(e):+ vs = min_text(); pair = None+ for size in _sz(0.8, 0.6):+ nw, nh = 0.95 * size * len(e['name']) + 0.1, size * 1.1+ vw, vh = 0.95 * vs * len(vt) + 0.1, vs * 1.1+ for layout_ in ('inline', 'stack'):+ w = nw + 0.12 + vw if layout_ == 'inline' else max(nw, vw)+ h = nh if layout_ == 'inline' else nh + 0.08 + vh+ cands = []+ for d in (gap, gap + 0.4, gap + 0.9):+ for f in (0.0, -0.5, 0.5):+ cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]+ for (x0, y0) in cands:+ if layout_ == 'inline':+ nb = (x0, y0, x0 + nw, y0 + nh); vb = (x0 + nw + 0.12, y0, x0 + nw + 0.12 + vw, y0 + vh)+ else:+ nb = (x0, y0 + vh + 0.08, x0 + nw, y0 + h); vb = (x0, y0, x0 + vw, y0 + vh)+ if free(nb, own=b) and free(vb, own=b):+ pair = (size, nb, vb); break+ if pair: break+ if pair: break+ if pair:+ size, nb, vb = pair+ taken += [nb, vb]+ out.append({'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in nb], 'origin': [round(nb[0], 3), round(nb[1], 3)]})+ out.append({'ref': e['name'], 'kind': 'value', 'text': vt, 'size': vs, 'rot': 'R0', 'box': [round(v, 3) for v in vb], 'origin': [round(vb[0], 3), round(vb[1], 3)]})+ continue for txt, kind in texts: done = False if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):@@ -1513,6 +1560,38 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), s # net labels may step down. EAGLE's vector font measured at about 0.95 x size per character. # near first, then small: every size is tried close to the part before any farther ring, since a # label beside its part at 0.6 mm reads better than one at 0.8 mm on the far side of a neighbour+ if kind == 'value':+ # beside its reference: right after it, under it, over it, before it, at the fab minimum+ nb = next((L['box'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), None)+ vs = min_text(); vh = vs * 1.1; vw = 0.95 * vs * len(txt) + 0.1+ if nb:+ nrot = next((L['rot'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), 'R0')+ if nrot == 'R0':+ cands_v = [(nb[2] + 0.1, nb[1], 'R0'), (nb[0], nb[1] - 0.08 - vh, 'R0'), (nb[0], nb[3] + 0.08, 'R0'), (nb[0] - 0.1 - vw, nb[1], 'R0')]+ else:+ cands_v = [(nb[2] + 0.08, nb[1], 'R90'), (nb[0] - 0.08 - vh, nb[1], 'R90'), (nb[0], nb[3] + 0.1, 'R90'), (nb[0], nb[1] - 0.1 - vw, 'R90')]+ for (x0, y0, rr) in cands_v:+ w, h = (vw, vh) if rr == 'R0' else (vh, vw)+ bx = (x0, y0, x0 + w, y0 + h)+ if free(bx, own=b):+ taken.append(bx)+ out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+ done = True; break+ if not done:+ # anywhere close round the part, at the same small size+ for d in (gap, gap + 0.4, gap + 0.9):+ for rr in ('R0', 'R90'):+ w, h = (vw, vh) if rr == 'R0' else (vh, vw)+ for (x0, y0) in ((cx - w / 2, b[3] + d), (cx - w / 2, b[1] - d - h), (b[2] + d, cy - h / 2), (b[0] - d - w, cy - h / 2)):+ bx = (x0, y0, x0 + w, y0 + h)+ if free(bx, own=b):+ taken.append(bx)+ out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+ done = True; break+ if done: break+ if done: break+ if not done: unresolved.append('%s value' % e['name'])+ continue for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))): if done: break # a contact's net stays close: far away it reads as a neighbour's for size in (_sz(0.8, 0.6) if kind == 'name' else _sz(0.8, 0.6, 0.5)):@@ -1553,8 +1632,9 @@ def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), s 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):+ txt = e['name'] if kind == 'name' else (e.get('net') if kind == 'net' else silk_value(e['value'], e['name'])); done = False+ if not txt: continue+ for size in ((min_text(),) if kind == 'value' else _sz(0.8, 0.6)): for rot in ('R0', 'R90'): tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1 w, h = (tw, th) if rot == 'R0' else (th, tw)@@ -1617,13 +1697,15 @@ def cmd_silkscreen(a): 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+ pt = (t['at'][0] - 0.3, t['at'][1] - 0.3, t['at'][0] + 0.3, t['at'][1] + 0.3)+ if any(_ov(pt, e['body'], 0.0) or any(_ov(pt, q, 0.0) for q in e['pads']) for e in els): continue fresh_cmds += ['GRID MM;', 'DISPLAY NONE 21;', 'DELETE (%.3f %.3f);' % t['at']]+ t['_gone'] = True dels = [t for t in texts if t['layer'] == 'wire21'] if dels: err('this board already carries %d silkscreen lines from an earlier pass; --fresh will not delete them (a DELETE by point can remove a whole part)' % len(dels), ['Restore the board version from before the earlier pass: `fusion_aps_versions {query}` lists them; promote the clean one (Fusion API DataFile.versions[n].promote()), close the open copy without saving, reopen, then run this again.'])- texts = [t for t in texts if t['layer'] != 'wire21' and not (t['layer'] == 21 and t['text'] in {e.get('net') or e['value'] for e in contacts} and t.get('at') and near(*t['at']))]+ texts = [t for t in texts if t['layer'] != 'wire21' and not t.get('_gone')] if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(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@@ -1753,6 +1835,14 @@ def cmd_silkscreen(a): 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']+ parked = [0]+ def remove_name(ref):+ """a smashed name off the print, safely: MOVE it by name (lands) to an empty spot off the board, then+ DELETE it there, where nothing else can be under the point (a DELETE where the name stood can pick+ the part itself: J4, 2026-10-08)"""+ parked[0] += 1+ px = (outline[2] if outline else 0) + 6.0; py = (outline[3] if outline else 0) + 2.0 * parked[0]+ return ["SMASH %s;" % ref, "MOVE '%s>NAME' (%.3f %.3f);" % (ref, px, py), 'DISPLAY NONE 25;', 'DELETE (%.3f %.3f);' % (px, py), 'DISPLAY NONE %s;' % (a.display or ' '.join(vis_now))] steps = [] # one entry per thing drawn: (what, [commands]); each lands as its own script, so it is seen pieces_of = {g['ref']: g.get('pieces') for g in groups} for (ref, f) in frames:@@ -1767,11 +1857,15 @@ def cmd_silkscreen(a): 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))]+ if abs(L['size'] - 0.8) < 1e-6:+ cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)]+ else:+ # CHANGE SIZE does not land in Fusion (by point or by name): the smashed name comes off at its+ # own origin (deleting a smashed name removes only that text) and the reference is printed as+ # plain text at the planned size+ cmds += remove_name(L['ref'])+ tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])+ cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)] else: tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1]) cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]@@ -1788,9 +1882,7 @@ def cmd_silkscreen(a): 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))]))+ steps.append(('%s name off print' % g['ref'], ['GRID MM;'] + remove_name(g['ref']))) # a name with no clear spot, even with a leader, goes to the documentation layer (51, not printed) # rather than sitting over copper or another label; the unresolved list still names it off = set()@@ -1802,8 +1894,7 @@ def cmd_silkscreen(a): 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))]))+ steps.append(('%s name off print' % ref, ['GRID MM;'] + remove_name(ref))) for l in leaders: (x1, y1), (x2, y2) = l['leader'] steps.append(('%s leader' % l['ref'], ['GRID MM;', 'CHANGE LAYER 21;', 'WIRE 0.15 (%.3f %.3f) (%.3f %.3f);' % (x1, y1, x2, y2)]))@@ -1854,7 +1945,7 @@ def cmd_silkscreen(a): ['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+ if L['kind'] != 'name' or abs(L['size'] - 0.8) > 1e-6: continue # smaller names are printed as plain text m = re.search(r'<element name="%s"[^>]*>.*?<attribute name="NAME"([^>]*)>' % re.escape(L['ref']), t2, re.S) if m and abs((_num(m.group(1), 'x', 0) or 0) - L['anchor'][0]) < 0.05 and abs((_num(m.group(1), 'y', 0) or 0) - L['anchor'][1]) < 0.05: moved += 1 else: off.append(L['ref'])