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 #381: AI Flow 0.2.35: choose a Fusion cloud project the account has (aiflow-lane-0.2.35 -> main) merge
Showing changes introduced by the merge (against its first parent).
8 files changed
+162−24
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.34"+version = "0.2.35" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.34"+version = "0.2.35" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.34"+version = "0.2.35" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.34"+version = "0.2.35" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
docs/fusion-lane.md+2−2@@ -53,9 +53,9 @@ behind the flow's back is refused (`stale_board`), never edited blind. | step | what it does | |---|---|-| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. `--project`/`--folder` set the run's cloud home, asked of the bridge for the 3D models too (a bridge that does not take it yet puts them under the project active in Fusion's data panel, and the step says so). |+| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. `--project`/`--folder` set the run's cloud home, asked of the bridge for the 3D models too (a bridge that does not take it yet puts them under the project active in Fusion's data panel, and the step says so). With no `--project`, the run uses Main if the account has one (a single-hub account only), else the project open in the data panel, else the only project; with several and no way to tell, it stops, names them and asks. The name is checked before any work starts, and a remembered project the account lacks is chosen again. | | `fusion reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Fusion board's frame: `moves.json`, `fixed.json`, `spec.json`. |-| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. The save is the new design's first save into `--project`/`--folder` (the run's, else Main / Adom AI Flow); it is refused when the design is already in the cloud (the bridge's save is Save As, which forks an already-saved design's board into an unlinked .fbrd) or when the folder already holds a design of the same name. |+| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. The save is the new design's first save into `--project`/`--folder` (the run's, chosen as for `fusion library`, folder Adom AI Flow by default); it is refused when the design is already in the cloud (the bridge's save is Save As, which forks an already-saved design's board into an unlinked .fbrd) or when the folder already holds a design of the same name. | | `fusion view3d` | The board in Fusion's 3D PCB: a still from Fusion's own renderer saved in the run to check every part sits flat on its pads, then a slow live orbit for the recording. | | `fusion reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. | | `fusion export` | Gerbers (the JLCPCB CAM job), pick and place, and the BOM with every distributor number, in `<run>/manufacturing/`. Only when the human asks; answer with each file's path. |
docs/release-0.2.35.mdadded+6@@ -0,0 +1,6 @@+# AI Flow 0.2.35 (2026-11-01)++Fusion lane:++- **The cloud project no longer assumes "Main" (fusion-bridge#136).** On an account with no project called Main, `fusion library` failed every part's 3D binding with "No project named Main in this Autodesk hub". With no `--project`, a run now uses Main if the account has one, else the project open in Fusion's data panel, else the only project. With several to choose from and no way to tell, it stops, names them and asks for `--project`. The name is checked before any work starts, and `fusion schematic` checks it before drawing anything, not at the save. A run that had remembered a project the account doesn't have chooses again, so a rerun of a failed run works without starting over.+- **A failed 3D build says why.** Each part's failure now appears in the reply, and a build refused at the start stops at once instead of waiting out its timeout. Parts without 3D still add to the schematic.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.34",+ "version": "0.2.35", "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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.2.34",+ "version": "0.2.35", "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, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); 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+60−5@@ -191,11 +191,59 @@ def prove(key, data=None): # ---------------------------------------------------------------- cloud home +_PROJECTS = [] # the one fusion_list_cloud_projects answer of this command, in a list once read+++def cloud_projects():+ """The user's Autodesk projects, {'hubs': [[names]], 'active': name or None}; None when the list cannot+ be trusted (no reply, or it timed out part way, so a missing name proves nothing)."""+ if not _PROJECTS:+ r = ab('fusion_list_cloud_projects', {})+ x = r.get('data') if isinstance(r.get('data'), dict) else r+ hubs = x.get('hubs')+ info = None+ if isinstance(hubs, list) and not x.get('listTimedOut'):+ info = {'hubs': [[str(p.get('name')) for p in (h.get('projects') or [])] for h in hubs], 'active': x.get('activeProject')}+ _PROJECTS.append(info)+ return _PROJECTS[0]+++def _has_project(names, name):+ return any(n.lower() == str(name).lower() for n in names)+++def default_project(info):+ """The project a run with no --project uses: Main when this account has one (a single hub only: the 3D+ build looks in every hub, but the design's save only in the active one), else the project open in Fusion's+ data panel, else the only project. Several to choose from and no way to tell is the user's call."""+ names = [n for h in info['hubs'] for n in h]+ if len(info['hubs']) == 1 and _has_project(names, 'Main'): return next(n for n in names if n.lower() == 'main')+ if info.get('active') and _has_project(names, info['active']): return info['active']+ if len(names) == 1: return names[0]+ err('which Fusion cloud project should this run save in? This account has no default AI Flow can pick: %s' % (', '.join(names) or 'no projects'),+ ['Ask the user, then run this again with --project "<name>" (the 3D models and the design go there, in the folder --folder, default "Adom AI Flow"). Or open that project in Fusion\'s data panel.'])++ def cloud_place(a, st):- """The run's Fusion cloud home (project, folder): --project/--folder, else what this run used before,- else Main / Adom AI Flow. Kept in the run so the library's 3D models and the design land together."""+ """The run's Fusion cloud home (project, folder), kept in the run so the library's 3D models and the+ design land together: --project/--folder, else what this run used before, else default_project and+ Adom AI Flow. A name the account does not have is caught here, before any work, not per part: a user's+ hub need not have a project called Main (fusion-bridge#136), and the bridge never picks another.""" c = st.get('cloud') or {}- return (getattr(a, 'project', None) or c.get('project') or 'Main', getattr(a, 'folder', None) or c.get('folder') or 'Adom AI Flow')+ asked = getattr(a, 'project', None)+ project = asked or c.get('project')+ folder = getattr(a, 'folder', None) or c.get('folder') or 'Adom AI Flow'+ info = cloud_projects()+ if project and info and not _has_project([n for h in info['hubs'] for n in h], project):+ if asked: err('no Fusion cloud project named %s on this account' % asked, ['Its projects: %s. Pass one as --project.' % ', '.join(n for h in info['hubs'] for n in h)])+ say('this run was set to save in the Fusion project %s, which this account does not have; choosing again' % project)+ project = None+ if not project:+ if not info: err('could not read the Fusion cloud projects to choose one', ['Run this again with --project "<name>" (ask the user which). Check that Fusion is running and signed in (fusion_list_cloud_projects).'])+ project = default_project(info)+ say('saving in the Fusion project %s, folder %s (--project and --folder choose another)' % (project, folder))+ st['cloud'] = {'project': project, 'folder': folder}+ return project, folder def save_refusal(app_state, name):@@ -811,6 +859,7 @@ def cmd_library(a): todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')] bdir = box_dir() + '/library' models_home = ''+ why3d = {} # each distinct failure of the 3D build, with the parts it stopped if todo: stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); (stage / 'lib').mkdir(parents=True); (stage / 'models').mkdir() for m in todo:@@ -822,6 +871,9 @@ def cmd_library(a): project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}; save_state(st) r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True, 'projectName': project, 'folderPath': folder, 'parts': [{'package': m.upper(), 'lbrPath': bdir + '/lib/' + m + '.lbr', 'modelPath': bdir + '/models/' + m + '.step'} for m in todo]}, timeout=300)+ if r.get('success') is False: # refused at the start: nothing is building, so there is nothing to wait for+ (STATE / 'library' / 'build3d.json').write_text(json.dumps({'start': r}, indent=1))+ err('the 3D build did not start: %s' % clip(str(r.get('error') or r), 400), [str(r.get('_hint') or ''), 'The reply is in <run>/fusion/library/build3d.json. Parts already bound are kept; run this again when it is fixed.']) t0 = time.time(); res = {} while time.time() - t0 < 60 * (5 + 3 * len(todo)): time.sleep(20)@@ -839,6 +891,7 @@ def cmd_library(a): back = {m.upper(): m for m in st['parts']} for x in res.get('parts', []): if x.get('success') and x.get('wip_urn'): st['parts'][back.get(x['package'].upper(), x['package'])]['urn'] = x['wip_urn']+ elif not x.get('success') and x.get('error'): why3d.setdefault(clip(str(x['error']), 240), []).append(x.get('package') or '?') save_state(st) # inject every binding (this build's and earlier ones) into the library, then put it on the box urns = {m.upper(): p['urn'] for m, p in st['parts'].items() if p.get('urn')} # keyed by Fusion's (uppercase) package name@@ -858,6 +911,7 @@ def cmd_library(a): ok('library %s: %d part(s) (%d added, %d kept), %d with real 3D, one library at %s' % (name, len(st['parts']), len(added), len(kept), len(urns), st['libraryPath']), ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `fusion library` again when the services are back, or add the numbers to the part\'s wiki page (component.distributor_links).' if unverified else '', '3D not bound yet: %s; run this again to retry them' % ', '.join(unbound) if unbound else '',+ ('Why: ' + '; '.join('%s (%s)' % (w, ', '.join(ps)) for w, ps in why3d.items()) + '. These parts still add to the schematic, with flat placeholders in 3D. The replies are in <run>/fusion/library/build3d.json.') if why3d else '', ('3D turned upright before binding: ' + '; '.join('%s (%s)' % (m, n) for m, n in turned.items()) + '.') if turned else '', 'Check the 3D yourself before going on: a model can be authored Y-up while Fusion and the board are Z-up, and a part bound that way lies on its side (an electrolytic can on its flank, an inductor on edge). ' 'This step already applied each footprint\'s own model transform and checked every model against its footprint, but look anyway: once the board exists, '@@ -964,6 +1018,7 @@ def wired_groups(layout, lib, by_ref, values, libname, fixed_refs): 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.'])+ cloud_place(a, st); save_state(st) # settled now: the design's save comes after the whole sheet is drawn nl = json.loads(Path(a.netlist).read_text()); spec = json.loads(Path(a.spec).read_text()) if a.spec else {} fx = json.loads(Path(a.fixed).read_text()) if a.fixed else {'outlineMm': a.outline, 'fixed': {}} by_ref = {}@@ -1802,10 +1857,10 @@ def main(): ap = argparse.ArgumentParser(prog='adom-aiflow fusion') sub = ap.add_subparsers(dest='cmd', required=True) p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json, parts.json [{mpn, page?, footprint?}] or a BOM .csv'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} of numbers already checked')- p.add_argument('--project', help='Fusion cloud project for the run (the 3D models and the design); default: the run\'s, else Main'); p.add_argument('--folder', help='folder in that project; default: the run\'s, else "Adom AI Flow"')+ p.add_argument('--project', help='Fusion cloud project for the run (the 3D models and the design); default: the run\'s, else Main if this account has one, else the project open in the data panel; it asks when it cannot tell'); p.add_argument('--folder', help='folder in that project; default: the run\'s, else "Adom AI Flow"') p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'fusion')) 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', help='Fusion cloud project (default: the run\'s, else Main)'); p.add_argument('--folder', help='folder in that project (default: the run\'s, else "Adom AI Flow")')+ p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', help='Fusion cloud project (default: the run\'s, else Main if this account has one, else the project open in the data panel; it asks when it cannot tell)'); p.add_argument('--folder', help='folder in that project (default: the run\'s, else "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')
tools/test_fusion_project.py+81−4@@ -85,10 +85,15 @@ class CloudSave(unittest.TestCase): def test_cloud_home(self): class A: project = None; folder = None- self.assertEqual(fp.cloud_place(A(), {}), ('Main', 'Adom AI Flow'))- self.assertEqual(fp.cloud_place(A(), {'cloud': {'project': 'Claude AI', 'folder': 'Adom AI Flow'}}), ('Claude AI', 'Adom AI Flow'))- a = A(); a.project = 'P'- self.assertEqual(fp.cloud_place(a, {'cloud': {'project': 'Q', 'folder': 'F'}}), ('P', 'F'))+ real = fp.ab; fp._PROJECTS.clear()+ fp.ab = lambda verb, args, timeout=600: {'hubs': [{'projects': [{'name': n} for n in ('Main', 'Claude AI', 'P', 'Q')]}]}+ try:+ self.assertEqual(fp.cloud_place(A(), {}), ('Main', 'Adom AI Flow'))+ self.assertEqual(fp.cloud_place(A(), {'cloud': {'project': 'Claude AI', 'folder': 'Adom AI Flow'}}), ('Claude AI', 'Adom AI Flow'))+ a = A(); a.project = 'P'+ self.assertEqual(fp.cloud_place(a, {'cloud': {'project': 'Q', 'folder': 'F'}}), ('P', 'F'))+ finally:+ fp.ab = real; fp._PROJECTS.clear() class LibraryLineBreaks(unittest.TestCase):@@ -146,5 +151,77 @@ class SchematicFailures(unittest.TestCase): fp.STATE = old +class CloudHome(unittest.TestCase):+ """fusion-bridge#136: a user's hub need not have a project called Main."""+ def run_place(self, reply, project=None, folder=None, remembered=None):+ import io, contextlib, types+ real = fp.ab; calls = []+ fp.ab = lambda verb, args, timeout=600: calls.append(verb) or reply+ fp._PROJECTS.clear()+ st = {'cloud': remembered} if remembered else {}+ buf = io.StringIO()+ try:+ with contextlib.redirect_stdout(buf):+ out = fp.cloud_place(types.SimpleNamespace(project=project, folder=folder), st)+ return out, st, buf.getvalue(), calls+ except SystemExit:+ return None, st, buf.getvalue(), calls+ finally:+ fp.ab = real; fp._PROJECTS.clear()++ @staticmethod+ def hubs(*names, active='(lookup failed)', timed_out=False):+ return {'data': {'activeProject': active, 'listTimedOut': timed_out, 'hubs': [{'name': 'H', 'projects': [{'name': n} for n in names]}]}}++ def test_main_stays_the_default_where_it_exists(self):+ out, st, _, _ = self.run_place(self.hubs('Personal', 'MAIN'))+ self.assertEqual(out, ('MAIN', 'Adom AI Flow'))+ self.assertEqual(st['cloud'], {'project': 'MAIN', 'folder': 'Adom AI Flow'})++ def test_no_main_takes_the_open_project_or_the_only_one(self):+ self.assertEqual(self.run_place(self.hubs('A', 'B', active='B'))[0][0], 'B')+ self.assertEqual(self.run_place(self.hubs('Only'))[0][0], 'Only')++ def test_no_way_to_tell_asks_and_names_them(self):+ out, _, text, _ = self.run_place(self.hubs('A', 'B'))+ self.assertIsNone(out)+ self.assertIn('A, B', text); self.assertIn('--project', text)++ def test_a_remembered_project_the_account_lacks_is_chosen_again(self):+ # Andy's rerun: the failed first run had saved Main in its state+ out, st, text, _ = self.run_place(self.hubs('Only'), remembered={'project': 'Main', 'folder': 'F'})+ self.assertEqual(out, ('Only', 'F'))+ self.assertIn('does not have', text)++ def test_an_asked_project_the_account_lacks_is_refused_with_the_list(self):+ out, _, text, _ = self.run_place(self.hubs('A', 'B', 'Main'), project='Nope')+ self.assertIsNone(out)+ self.assertIn('Nope', text); self.assertIn('A, B, Main', text)++ def test_an_asked_or_remembered_name_stands_when_the_list_cannot_be_read(self):+ self.assertEqual(self.run_place({'success': False, 'error': 'busy'}, project='X')[0][0], 'X')+ self.assertEqual(self.run_place(self.hubs('A', timed_out=True), remembered={'project': 'Y', 'folder': 'F'})[0][0], 'Y')+ out, _, text, _ = self.run_place({'success': False, 'error': 'busy'})+ self.assertIsNone(out); self.assertIn('--project', text)++ def test_several_hubs_never_guess_main(self):+ two = {'hubs': [{'name': 'A', 'projects': [{'name': 'Main'}]}, {'name': 'B', 'projects': [{'name': 'Mine'}]}], 'activeProject': '(lookup failed)'}+ self.assertIsNone(self.run_place(two)[0])+ two['activeProject'] = 'Mine'+ self.assertEqual(self.run_place(two)[0][0], 'Mine')++ def test_the_list_is_read_once_per_command(self):+ import types+ real = fp.ab; calls = []+ fp.ab = lambda verb, args, timeout=600: calls.append(verb) or self.hubs('Main')+ fp._PROJECTS.clear()+ try:+ st = {}; a = types.SimpleNamespace(project=None, folder=None)+ fp.cloud_place(a, st); fp.cloud_place(a, st)+ self.assertEqual(calls, ['fusion_list_cloud_projects'])+ finally:+ fp.ab = real; fp._PROJECTS.clear()++ if __name__ == '__main__': unittest.main()