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.
← All Pull Requests
Fusion lane fixes from #74: finish reads the silkscreen proof, silkscreen layer restore, wired schematics, thermal vias clear pad corners #38
Merges
fusion-lane-issue-74 → mainFixes for adom-aiflow #74 (Muzzer's full Fusion run of the buck molecule, 2026-10-18). Base: 66fec806d44a4f291fd5f7a27aadff4638a32d17 (0.2.2). Three files, 84 insertions, 6 deletions; every deletion is a line replaced in place.
What changes
finishon the Fusion lane reads the silkscreen proof.board_now()is the KiCad copy converted from the Fusion.brd, and the converter carries no silkscreen text, so every Fusion board failed with "0 visible labels". When the active EDA is Fusion,finishtakes the label count from<run>/proofs/silkscreen.json(written byfusion silkscreen), reports a proof that recorded failed steps, and asks forfusion silkscreenwhen there is no proof. The KiCad lane is unchanged.fusion silkscreenkeeps its drawing layers visible. The restore after each name removal used the board's visible set at plan time; with only 23 24 25 visible, every laterCHANGE LAYER 21failed after the NAME was already deleted. The restore is now that set plus 17 18 20 21 22 25 (same for the--freshrestore).- Muzzer's patch, unchanged: a smashed part whose NAME is gone prints its reference as plain text, so an interrupted pass can be re-run; and
fusion schematic --layout wired.jsondraws a hand-placed, pin-to-pin wired sheet instead of the planner's labelled stubs. Muzzer used it live on this run (40 parts, 5 groups). - Thermal vias clear pad corners. The pad check used a circle of half the long side, so a via could sit 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused. It now uses the pad's bounding circle (
hypot(half_long, half_short)).
Not in this PR, already handled elsewhere
- Rounded and custom outlines: the blocker was the fusion-bridge route preflight (rectangle only). Fixed in fusion-bridge 1.11.43 (any closed outline of lines, arcs and circles, cutouts included); 0.2.2 already converts outline arcs to real arcs.
- Vias reported as
readback_mismatchafter landing: fixed in fusion-bridge 1.11.43 (plans and readback snap to Fusion's 1/320000 mm grid). Verified live in Fusion.
Checks
tools/pr_preflight.pyagainst the base above: no problems. Every hunk ofreview.diffread.cargo check --workspace --bins: OK.cargo test(workspace): all pass (127 in adom-aiflow).- Not run natively: a full Fusion lane run with this build. Items 1 and 2 need one; Muzzer's run is the natural acceptance.
🤖 Generated with Claude Code
Diff Skip to comments
crates/adom-aiflow/src/main.rs+14−1@@ -3607,7 +3607,20 @@ fn main() { if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); } } {- let (labels, fps) = provenance::silk_labels(&board_now());+ let (mut labels, fps) = provenance::silk_labels(&board_now());+ // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,+ // and the converter carries no silkscreen text (issue #74: 44 texts in the .brd, 0 in the copy),+ // so every Fusion board failed. There the count comes from `fusion silkscreen`'s own proof.+ if aiflow_bridge::eda::active().id() == "fusion" {+ match std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ Some(p) => {+ labels = labels.max(p["data"]["labels"].as_u64().unwrap_or(0) as usize);+ let failed = p["data"]["failed"].as_u64().unwrap_or(0);+ if failed > 0 { missing.push(format!("silkscreen: {failed} step(s) of `adom-aiflow fusion silkscreen` failed; run it again (skill aiflow-silkscreen)")); }+ }+ None => missing.push("silkscreen: not drawn yet on the Fusion board; run `adom-aiflow fusion silkscreen` (skill aiflow-silkscreen)".to_string()),+ }+ } if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 { missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT)); }
crates/adom-aiflow/src/thermalvias.rs+3−1@@ -127,7 +127,9 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a let on = b.outline.len(); if on > 0 && (!point_in(&b.outline, c) || (0..on).any(|k| seg_dist(c, b.outline[k], b.outline[(k + 1) % on]) < rr + edge)) { continue; } // clear of other nets' pads and tracks, of every via, and of every part's pads (no via in a pad)- if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long + rr + clearance) { continue; }+ // a pad reaches its corners, not just half its long side: the half_long circle let a via sit+ // 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused (issue #74)+ if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long.hypot(q.half_short) + rr + clearance) { continue; } if tracks.iter().any(|t| t.net != net && seg_dist(c, t.a, t.b) < t.width / 2.0 + rr + clearance) { continue; } if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; } if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; }
tools/fusion-project.py+67−4@@ -795,6 +795,55 @@ def cmd_reference(a): # ---------------------------------------------------------------- schematic +def wired_groups(layout, lib, by_ref, values, libname, fixed_refs):+ """A wired sheet from a hand layout: every part where the layout puts it and every net drawn as wires+ from pin to pin, so the sheet reads as a circuit rather than a list of parts with net names.+ layout: {"groups": [{"name", "text": [x, y]?, "parts": {ref: [x, y, rot]}, "nets": {net: [[point, ...], ...]},+ "labels": [[net, x, y]]}]}; a point is "REF.PIN" (pad or pin name) or [x, y] in mm. The fixed parts the+ seed drew are reached by coordinates (their stub ends). Points are checked: every "REF.PIN" must exist."""+ syms = {s.get('name'): s for s in lib.iter('symbol')}+ pins_of = {} # deviceset -> {pad or pin name: (x, y)}+ for d in lib.iter('deviceset'):+ g = next(d.iter('gate')); s = syms[g.get('symbol')]+ pin_xy = {p.get('name'): (float(p.get('x')), float(p.get('y'))) for p in s.iter('pin')}+ m = dict(pin_xy)+ for dv in d.iter('device'):+ for c in dv.iter('connect'):+ for pad in c.get('pad', '').split(): m.setdefault(pad, pin_xy[c.get('pin')])+ pins_of[d.get('name').upper()] = m+ placed = {}+ for g in layout['groups']:+ for r, (x, y, rot) in g.get('parts', {}).items(): placed[r] = (float(x), float(y), rot or 'R0')+ def pt(p):+ if isinstance(p, (list, tuple)): return (float(p[0]), float(p[1]))+ r, pin = p.split('.', 1)+ if r not in placed: err('layout: %s is not placed (only the parts in "parts" can be reached by pin)' % p)+ x, y, rot = placed[r]+ px, py = pins_of[by_ref[r].upper()].get(pin) or err('layout: %s has no pin %s' % (by_ref[r], pin))+ k = int(rot.lstrip('MR') or 0) // 90 % 4+ for _ in range(k): px, py = -py, px+ if rot.startswith('M'): px = -px+ return (round(x + px, 3), round(y + py, 3))+ f = lambda q: '(%.3fmm %.3fmm)' % q+ missing = [r for r in by_ref if r not in placed and r not in fixed_refs]+ if missing: err('layout: no place for %s' % ', '.join(missing), ['Every part that is not fixed needs an entry in a group\'s "parts".'])+ groups = []+ for g in layout['groups']:+ cmds = []+ if g.get('text'):+ cmds += ['LAYER 97;', 'CHANGE SIZE 2.54mm;', "TEXT '%s' R0 %s;" % (g['name'], f(tuple(g['text']))), 'CHANGE SIZE 1.778mm;']+ cmds.append('LAYER 91;')+ for r, (x, y, rot) in g.get('parts', {}).items():+ cmds += ["ADD '%s@%s' '%s' %s %s;" % (by_ref[r], libname, r, rot or 'R0', f((float(x), float(y)))), "VALUE '%s' '%s';" % (r, values.get(r) or by_ref[r])]+ for n, paths in g.get('nets', {}).items():+ for path in paths:+ cmds.append("NET '%s' %s;" % (n, ' '.join(f(pt(p)) for p in path)))+ for n, x, y in g.get('labels', []):+ cmds.append('LABEL %s %s;' % (f((x, y)), f((x, y))))+ groups.append({'group': g['name'], 'parts': list(g.get('parts', {})), 'commands': cmds})+ return groups++ def cmd_schematic(a): st = load_state() if not st.get('libraryPath'): err('no project library yet', ['Run `adom-aiflow fusion library --parts <parts>` first.'])@@ -843,9 +892,12 @@ def cmd_schematic(a): ab('fusion_schematic', {'action': 'show'}); time.sleep(3) fixed_refs = set(fx['fixed']) parts = [{'ref': c['ref'], 'deviceset': by_ref[c['ref']], 'value': c.get('value'), 'block': c.get('block')} for c in nl['components'] if c['ref'] not in fixed_refs]- plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}})+ if a.layout:+ plan = {'groups': wired_groups(json.loads(Path(a.layout).read_text()), lib, by_ref, {c['ref']: c.get('value') for c in nl['components']}, Path(st['libraryPath'].replace('\\', '/')).stem, fixed_refs)}+ else:+ plan = ab('fusion_schematic_plan', {'libraryPath': st['libraryPath'], 'parts': parts, 'nets': {n: [m for m in ms if m.split('.')[0] not in fixed_refs] for n, ms in nets.items()}}) if not plan.get('groups'): err('schematic plan: %s' % plan.get('error'))- if fixed_refs:+ if fixed_refs and not a.layout: # the seed draws the fixed parts (a molecule's pins and contacts) as a column at the sheet's left edge; # the planner starts its groups at that same x, so move every group clear of the column shift = 45.72@@ -1109,6 +1161,7 @@ def cmd_silkscreen(a): src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or '' if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.']) els, outline, vias, texts = board_model(text)+ nameless = {m.group(1) for m in re.finditer(r'<element name="([^"]+)"[^>]*smashed="yes"[^>]*>(.*?)</element>', text, re.S) if 'name="NAME"' not in m.group(2)} contacts = [e for e in els if is_contact(e)] fresh_cmds = [] if a.fresh:@@ -1133,7 +1186,7 @@ def cmd_silkscreen(a): err('this board already carries %d silkscreen lines from an earlier pass; --fresh will not delete them (a DELETE by point can remove a whole part)' % len(dels), ['Restore the board version from before the earlier pass: `fusion_aps_versions {query}` lists them; promote the clean one (Fusion API DataFile.versions[n].promote()), close the open copy without saving, reopen, then run this again.']) texts = [t for t in texts if t['layer'] != 'wire21' and not t.get('_gone')]- if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(vis or ['1', '16', '17', '18', '20', '21', '25']))]+ if fresh_cmds: fresh_cmds += ['DISPLAY NONE %s;' % (a.display or ' '.join(sorted(set(vis or ['1', '16']) | {'17', '18', '20', '21', '22', '25'}, key=int)))] # the corner pins' shape is chosen with the columns: each corner variant in turn (beside the pin, two # lines over or under it, one line), keeping whichever frames the most contacts tries = [plan_frames(els, outline, vias, corner_orients=(o,)) for o in ('h', 'v', 'v1')]@@ -1262,6 +1315,10 @@ 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']+ # the restore after each name removal must keep every layer this step draws on visible: with an odd set+ # (only 23 24 25 on, issue #74) each later CHANGE LAYER 21 failed 'Unavailable layer' after the NAME was+ # already deleted. So: the board's own visible set plus pads, vias, outline, tPlace, bPlace and tNames.+ vis_now = sorted(set(vis_now) | {'17', '18', '20', '21', '22', '25'}, key=int) parked = [0] def remove_name(ref): """a smashed name off the print, safely: MOVE it by name (lands) to an empty spot off the board, then@@ -1284,7 +1341,12 @@ 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)]- if abs(L['size'] - 0.8) < 1e-6:+ if L['ref'] in nameless:+ # a smashed part whose NAME text is gone (an earlier, interrupted pass deleted it): there is no+ # name to move or remove, so the reference is printed as plain text at the planned spot+ tx, ty = (x0, y0) if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])+ cmds += ['CHANGE LAYER 21;', 'CHANGE SIZE %s;' % L['size'], 'CHANGE RATIO 15;', 'CHANGE ALIGN bottom-left;', "TEXT '%s' %s (%.3f %.3f);" % (L['text'], L.get('rot', 'R0'), tx, ty)]+ elif abs(L['size'] - 0.8) < 1e-6: cmds += ["SMASH %s;" % L['ref'], "ROTATE =%s '%s>NAME';" % (L.get('rot', 'R0'), L['ref']), "MOVE '%s>NAME' (%.3f %.3f);" % (L['ref'], ax, ay)] else: # CHANGE SIZE does not land in Fusion (by point or by name): the smashed name comes off at its@@ -1550,6 +1612,7 @@ def main(): p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float) p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', default='Main'); p.add_argument('--folder', default='Adom AI Flow') p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)+ p.add_argument('--layout', help='wired.json: parts placed by hand and every net drawn as wires pin to pin (a sheet an engineer can read), instead of the planner\'s labelled stubs') p = sub.add_parser('reset'); p.add_argument('--design') p = sub.add_parser('export'); p.add_argument('--design'); p.add_argument('--out-dir', default=str(RUN / 'manufacturing')) p = sub.add_parser('view3d', help='the board in 3D: a still to check and a live orbit'); p.add_argument('--design'); p.add_argument('--steps', type=int, default=24); p.add_argument('--pause', type=float, default=0.6); p.add_argument('--settle', type=float, default=8)
Comments
Log in to comment.
No comments yet.