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
Merge PR #259: Fusion lane fixes from #74: finish reads the silkscreen proof, silkscreen layer restore, wired schematics, thermal vias clear pad corners (fusion-lane-issue-74 -> main) merge
Showing changes introduced by the merge (against its first parent).
3 files changed
+84−6
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)