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.
AI Flow 0.2.45: a big schematic block stays on Fusion's sheet (#133) #60
aiflow-lane-0.2.45 → mainFor #133 (stays open until it's drawn live, and for the Untitled document item).
fusion_schematic_plan puts a block's parts in one row, so an 80-part block alone passes Fusion's 1950 mm limit. fusion schematic now cuts a block too wide or too long into frames before planning, and plans and checks the whole sheet before any document opens. The planner answers before the bridge reads any document, so moving it earlier is safe. Blocks that fit are unchanged.
Base: ea4efc2e. Tested: 540 Rust tests and the Python suites pass; no CLI command is missing against 0.2.44. An 80-part block plus 9 small groups through the bridge's own planner code: x max 3035 mm with 33 parts off the sheet before, 1177 mm with none after. Not drawn live in Fusion: the test laptop is in use.
🤖 Generated with Claude Code
Diff Skip to comments
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.44"+version = "0.2.45" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.44"+version = "0.2.45" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.44"+version = "0.2.45" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.44"+version = "0.2.45" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
docs/fusion-lane.md+2@@ -90,6 +90,8 @@ or `adom-aiflow fusion view3d`, and every part must sit on its pads as its datas Fusion 2706 raises its internal error `PlacePartWorker.cpp, Line 23. Condition: assetAdded` on every ADD from a `.lbr` whose XML elements share one line, even for parts with no 3D package (fusion-bridge#135, found by fusion-lane on 2026-10-31). The same library broken into lines adds cleanly. `fusion library` therefore writes the project library with a line break between elements. Only whitespace changes, and only between elements: never inside an element's own text, never inside an attribute value. Fusion bridge 1.13.47+ also rewrites a compact library before loading it, so either side alone is enough. +`fusion schematic` plans the whole sheet before it opens a document. A block too wide or too long for one frame is drawn as several (`<block> 1 of 4`), in netlist order, so it stays inside Fusion's 1950 mm limit and each group stays under the bridge's command limit. Anything still past the limit, such as a position in your `--layout`, stops there in words, before a document opens.+ `fusion schematic` keeps every bridge reply whole in `<run>/fusion/schematic/`: the import's `runId`, start reply and final status (`import.json`), and each `fusion_schematic_run` group (`run-NN-<group>.json`). An error names its file. `add_failed` and an `assetAdded` assertion each come with this cause and the fix in words. A failing `analyze` prints `why:` lines with the cause of each field-screen neck and the spec change that
docs/release-0.2.45.mdadded+9@@ -0,0 +1,9 @@+# AI Flow 0.2.45 (2026-11-03)++Fusion lane (#133):++- **A big block no longer runs off Fusion's sheet.** The planner puts a block's parts in one row, so an 80-part block was about 2 m wide on its own, past Fusion's 1950 mm limit. `fusion schematic` now draws a block that's too wide as several frames (`Power 1 of 4`), in netlist order. Blocks that already fit are drawn as before.+- **A block with too many commands is split the same way,** so it works on Fusion bridges that still stop at 400 commands in one call.+- **The sheet is checked before anything opens.** The plan now runs before the design starts, so a layout that can't fit, such as a far-out position in `--layout`, stops in words with no document opened and nothing half drawn.++Checked with an 80-part block plus 9 small groups through the Fusion bridge's own planner code: before, parts reached x = 3035 mm with 33 off the sheet; now the widest is 1177 mm with none off. Not yet drawn live in Fusion. Each run still opens a new Untitled document.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.44",+ "version": "0.2.45", "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.44",+ "version": "0.2.45", "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+79−14@@ -1063,6 +1063,64 @@ def wired_groups(layout, lib, by_ref, values, libname, fixed_refs): return groups +# fusion_schematic_run refuses an ADD past this (off_the_sheet): past about 2 m Fusion drops the part silently+SHEET_LIMIT_MM = 1950.0+# fusion_schematic_plan puts a block's parts in one row and up to 3 frames in a row of the sheet: 3 frames this wide+# stay well inside the limit, and a frame stays small enough to read+FRAME_MAX_MM = 400.0+# below the bridge's limit on one fusion_schematic_run (400 before fusion-bridge#137)+GROUP_MAX_COMMANDS = 380+FRAME_COMMANDS = 10+++def symbol_sizes(lib):+ """{DEVICESET: (symbol width mm, pins)}, measured the way fusion_schematic_plan does (its first gate's symbol)."""+ syms = {s.get('name'): s for s in lib.iter('symbol')}+ sizes = {}+ for d in lib.iter('deviceset'):+ g = d.find('gates/gate')+ s = syms.get(g.get('symbol')) if g is not None else None+ if s is None: continue+ xs = [0.0] + [float(p.get('x')) for p in s.iter('pin')]+ for w in s.iter('wire'): xs += [float(w.get('x1')), float(w.get('x2'))]+ sizes[d.get('name').upper()] = (max(xs) - min(xs), sum(1 for _ in s.iter('pin')))+ return sizes+++def split_blocks(parts, sizes):+ """Cut a block too wide or too long for one frame into several (issue #133), in netlist order. Each part takes+ its symbol's width plus the planner's 25.4 mm of label room, and at most ADD, VALUE and NET + LABEL per pin.+ Returns (parts with their block renamed where it was cut, [(block, parts, frames)]). A block that fits is untouched."""+ order = []+ for q in parts:+ if (q.get('block') or 'parts') not in order: order.append(q.get('block') or 'parts')+ out, split = {}, []+ for b in order:+ chunks, cur, width, cmds = [], [], 7.62, FRAME_COMMANDS+ for q in [q for q in parts if (q.get('block') or 'parts') == b]:+ w, pins = sizes.get(str(q.get('deviceset')).upper(), (25.4, 8))+ pw, pc = w + 25.4, 2 + 2 * pins+ if cur and (width + pw > FRAME_MAX_MM or cmds + pc > GROUP_MAX_COMMANDS):+ chunks.append(cur); cur, width, cmds = [], 7.62, FRAME_COMMANDS+ cur.append(q); width += pw; cmds += pc+ chunks.append(cur)+ if len(chunks) > 1: split.append((b, sum(len(c) for c in chunks), len(chunks)))+ for i, c in enumerate(chunks):+ for q in c: out[q['ref']] = dict(q, block=b if len(chunks) == 1 else '%s %d of %d' % (b, i + 1, len(chunks)))+ return [out[q['ref']] for q in parts], split+++def off_sheet(groups):+ """Every planned ADD past Fusion's limit, as (ref, x, y, group). The bridge would refuse the first mid-sheet."""+ bad = []+ for g in groups:+ for c in g['commands']:+ m = re.match(r"\s*ADD\s+'[^']*'\s+'([^']*)'.*\(\s*(-?[\d.]+)mm\s+(-?[\d.]+)mm\s*\)", c)+ if m and max(abs(float(m.group(2))), abs(float(m.group(3)))) > SHEET_LIMIT_MM:+ bad.append((m.group(1), float(m.group(2)), float(m.group(3)), g['group']))+ return bad++ 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.'])@@ -1092,6 +1150,27 @@ def cmd_schematic(a): fixed.append({'ref': r, 'deviceset': by_ref[r], 'value': next((n for k, n in pin_net.items() if k.split('.')[0] == r), ''), 'x': p['x'], 'y': p['y'], 'rot': ('R%d' % int(p.get('rot', 0))) if p.get('rot') else None, 'nets': {k.split('.', 1)[1]: n for k, n in pin_net.items() if k.split('.')[0] == r}})+ # the whole sheet is planned and checked first: a layout Fusion can't draw stops here, before any document opens+ 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]+ 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:+ parts, split = split_blocks(parts, symbol_sizes(lib))+ for b, n, k in split: say('block %s has %d parts: drawing it in %d frames, so it stays on the sheet' % (b, n, k))+ 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 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+ mv = lambda m: '(%.3fmm %smm)' % (float(m.group(1)) + shift, m.group(2))+ for g in plan['groups']:+ g['commands'] = [re.sub(r'\(([-\d.]+)mm ([-\d.]+)mm\)', mv, c) for c in g['commands']]+ off = off_sheet(plan['groups'])+ if off:+ err('%d part(s) would land past Fusion\'s %g mm sheet limit, e.g. %s. Nothing was drawn.' % (len(off), SHEET_LIMIT_MM, ', '.join('%s at (%g, %g) in %s' % o for o in off[:3])),+ ['Move those parts in your --layout file.' if a.layout else 'A single part this wide is the likely cause: check its symbol in the project library, or lay the sheet out yourself with --layout.']) bdir = box_dir() + '/design' say('starting the design with the fab rules (clearance %.2f mm, edge %.2f mm, 0.15 mm drill) and %d fixed part(s)' % (rules['clearance'], rules['edge'], len(fixed))) r = ab('fusion_board_seed', {'libraryPath': st['libraryPath'], 'outDir': bdir, 'name': 'Seed', 'title': nl.get('title', name), 'outlineMm': fx['outlineMm'], 'fixed': fixed, 'rules': rules})@@ -1111,20 +1190,6 @@ def cmd_schematic(a): keep_reply('import', {'runId': rid, 'start': r, 'status': s}) if s.get('stage') != 'done': err('the design import (runId %s) did not finish: %s' % (rid, str((s.get('result') or {}).get('error'))[:300]), ['Start from a clean Fusion session (save and close half-finished designs) and run this again. The import\'s replies are in <run>/fusion/schematic/.']) 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]- 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 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- mv = lambda m: '(%.3fmm %smm)' % (float(m.group(1)) + shift, m.group(2))- for g in plan['groups']:- g['commands'] = [re.sub(r'\(([-\d.]+)mm ([-\d.]+)mm\)', mv, c) for c in g['commands']] sheet = {} for g in plan['groups']: say('drawing %s: %s' % (g['group'], ', '.join(g['parts'])))
tools/test_fusion_project.py+46@@ -275,5 +275,51 @@ class SharedFootprint3D(unittest.TestCase): self.assertEqual(fp.library_3d_problems(ET.tostring(sch, encoding='unicode')), []) +class SheetLimit(unittest.TestCase):+ """adom-aiflow#133: a big block stays inside Fusion's sheet."""+ SIZES = {'R': (15.0, 2), 'BIG': (60.0, 150)}++ def parts(self, n, block='power', ds='R', start=1):+ return [{'ref': 'R%d' % (start + i), 'deviceset': ds, 'block': block} for i in range(n)]++ def test_a_block_that_fits_is_untouched(self):+ ps = self.parts(5) + self.parts(4, 'io', start=6)+ got, split = fp.split_blocks(ps, self.SIZES)+ self.assertEqual(got, ps); self.assertEqual(split, [])++ def test_a_wide_block_becomes_frames_that_fit_in_netlist_order(self):+ ps = self.parts(80, None)+ got, split = fp.split_blocks(ps, self.SIZES)+ self.assertEqual([q['ref'] for q in got], [q['ref'] for q in ps], 'nothing lost or reordered')+ self.assertEqual(split[0][:2], ('parts', 80)); frames = split[0][2]; self.assertGreater(frames, 1)+ names = list(dict.fromkeys(q['block'] for q in got))+ self.assertEqual(names, ['parts %d of %d' % (i + 1, frames) for i in range(frames)])+ for b in names:+ w = 7.62 + sum(self.SIZES['R'][0] + 25.4 for q in got if q['block'] == b)+ self.assertLessEqual(w, fp.FRAME_MAX_MM)+ # the planner's row: 3 of the widest frames, its gaps, its start and the fixed-parts shift+ self.assertLess(20.32 + 45.72 + 3 * fp.FRAME_MAX_MM + 2 * 10.16, fp.SHEET_LIMIT_MM)++ def test_a_block_with_too_many_commands_is_cut_even_when_narrow(self):+ got, split = fp.split_blocks(self.parts(4, 'mcu', 'BIG'), self.SIZES)+ self.assertEqual(split, [('mcu', 4, 4)], 'each part has 302 commands at most: two would pass the limit')++ def test_one_part_alone_never_loops(self):+ got, split = fp.split_blocks(self.parts(1, 'x', 'BIG'), {'BIG': (900.0, 400)})+ self.assertEqual(split, []); self.assertEqual(got[0]['block'], 'x')++ def test_an_unknown_symbol_still_splits(self):+ self.assertTrue(fp.split_blocks(self.parts(40, 'x', 'NOPE'), {})[1])++ def test_sizes_measure_pins_and_body(self):+ lib = ET.fromstring("""<library><symbols><symbol name="S"><wire x1="-5" y1="0" x2="5" y2="0"/><pin name="1" x="-10" y="0"/><pin name="2" x="10" y="0"/></symbol></symbols>+ <devicesets><deviceset name="Dev"><gates><gate symbol="S"/></gates></deviceset></devicesets></library>""")+ self.assertEqual(fp.symbol_sizes(lib), {'DEV': (20.0, 2)})++ def test_off_sheet_names_each_part_past_the_limit(self):+ groups = [{'group': 'g', 'commands': ['LAYER 91;', "ADD 'R@L' 'R1' R0 (100.000mm 139.700mm);", "ADD 'R@L' 'R2' R0 (1980.946mm 139.700mm);", "ADD 'R@L' 'R3' R0 (10.000mm -2000.000mm);", "NET 'A' (3000.000mm 0.000mm) (1.000mm 1.000mm);"]}]+ self.assertEqual(fp.off_sheet(groups), [('R2', 1980.946, 139.7, 'g'), ('R3', 10.0, -2000.0, 'g')])++ if __name__ == '__main__': unittest.main()
Comments
Log in to comment.
No comments yet.