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.
Fusion lane: one element per line in the project library; keep the bridge replies (#114) #50
aiflow-lane-114 → mainAI Flow side of #114 / fusion-bridge#135. Draft until the end-to-end Fusion run on the test laptop passes.
Base: 1c3a69112abe81f762c43130d947b31fb7ab29a5 (0.2.28 published HEAD).
Cause, found by fusion-lane: Fusion 2706 raises PlacePartWorker assetAdded on every ADD from a .lbr whose XML elements share one line. The cause is not 3D. Fusion bridge 1.13.47 rewrites a compact library before loading it; this PR makes AI Flow's own library line-broken as well, so either side alone is enough.
Scope (tools/fusion-project.py):
break_lines(): puts a line break between elements, before an element that opens or after one that closes. It never adds whitespace inside an element's own text or inside an attribute value. It runs on the project library afteradom-lbr assembleand again after the 3D binding injection, which used to write</packages><packages3d>…on one line.keep_reply(): saves the import's runId, start reply and final status, each fullfusion_schematic_runreply, and the 3D-restore import in<run>/fusion/schematic/. Andy asked for these on fusion-bridge#133.schematic_failure():add_failed,fusion_assert(with assetAdded) andno_3d_package(1.13.46 only) each become a plain-words error naming the cause, the fix and the saved reply.- docs/fusion-lane.md has a short section. Not included: the earlier 3D stand-in and rebinding work on branch aiflow-lane-114-3d, which isn't needed for #135.
Checks:
cargo check --workspace --bins: clean.cargo test --workspace: 501 passed.tools/test_fusion_project.py: 11 OK; 4 tests are new.- AdomBasicParts_full.lbr (2618 lines) squeezed onto one line and broken again parses to the same tree, with leaf text compared exactly.
- pr_preflight: no problems; every deletion reviewed.
Native acceptance: pending; I'll post the end-to-end fusion library + fusion schematic run on DESKTOP-BAR6S0F (fusion-bridge 1.13.47) on #114.
🤖 Generated with Claude Code
Diff Skip to comments
docs/fusion-lane.md+6@@ -81,6 +81,12 @@ its flank and the 5 x 5 mm inductor stood on edge, both Y-up models with a `rota The check is a backstop, not a proof. Look at the board in 3D before calling it done: `fusion_show_3d_board`, or `adom-aiflow fusion view3d`, and every part must sit on its pads as its datasheet drawing shows. +### The project library is written one element per line (issue #114)++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` 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 fixes it: a via whose copper ring is the narrowest copper (`viaPower`), or another net's track cutting a patch pour off from its pin (`planeUnder`).
tools/fusion-project.py+58−5@@ -671,6 +671,26 @@ def silk_text(t): return re.sub(r'<package [^>]*>.*?</package>', pkg, t, flags=re.S) +_TAG = re.compile(r'<(?:!--.*?--|!\[CDATA\[.*?\]\]|[?!][^>]*|(?:[^>"\']|"[^"]*"|\'[^\']*\')*)>', re.S)++def break_lines(text):+ """The library with a line break between elements: Fusion 2706 raises PlacePartWorker assetAdded on+ every ADD from a .lbr whose elements share one line (fusion-bridge#135, adom-aiflow#114). Whitespace+ only, and only where it means nothing: before an element that opens, after one that closes, never+ inside an element's own text (an empty <text></text> stays empty), never inside an attribute value."""+ out, last, prev = [], 0, None+ for m in _TAG.finditer(text):+ gap = text[last:m.start()]+ tag = m.group(0)+ if prev is not None and gap == '':+ opens = not tag.startswith('</')+ closed = prev.startswith('</') or prev.endswith('/>') or prev.startswith('<?') or prev.startswith('<!')+ if opens or closed: gap = '\n'+ out.append(gap); out.append(tag); last = m.end(); prev = tag+ out.append(text[last:])+ return ''.join(out)++ def cmd_library(a): for tool, pkg in (('adom-lbr', 'adom/adom-lbr'), ('adom-wiki', 'adom/adom-wiki')): if not shutil.which(tool): err('%s is not installed (it turns each part page into Fusion library parts)' % tool, ['adom-wiki pkg install %s, then run this again.' % pkg])@@ -786,7 +806,7 @@ def cmd_library(a): t = combined.read_text() t = re.sub(r'<deviceset ((?:(?!uservalue)[^>])*)>', lambda m: '<deviceset %s uservalue="yes">' % m.group(1), t) t = silk_text(t)- combined.write_text(t)+ combined.write_text(break_lines(t)) # 3D: build only the parts that have no binding yet, then carry every binding into the library todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')] bdir = box_dir() + '/library'@@ -827,7 +847,7 @@ def cmd_library(a): u = urns.get(pk.group(1).upper()) if pk else None return d.replace('</connects>', '</connects><package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances>' % u, 1) if u else d t = re.sub(r'(?s)<device [^>]*package="[^"]+"[^>]*>.*?</device>', dev, t)- combined.write_text(t); send([combined], bdir)+ combined.write_text(break_lines(t)); send([combined], bdir) st['libraryPath'] = bdir + '/' + combined.name; save_state(st) unbound = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')] prove('library', {'name': name, 'parts': len(st['parts'])})@@ -976,12 +996,13 @@ def cmd_schematic(a): if d_.get('productType') == 'EcadDesignProductType' and not d_.get('busyCommand'): break time.sleep(3) r = ab('fusion_new_electronics_from_eagle', {'schPath': r['schPath'], 'brdPath': r['brdPath']})- rid = r.get('runId')+ rid = r.get('runId'); s = {} for _ in range(60): time.sleep(6) s = ab('fusion_eagle_import_status', {'runId': rid}) if s.get('stage') in ('done', 'failed'): break- if s.get('stage') != 'done': err('the design import did not finish: %s' % str((s.get('result') or {}).get('error'))[:300], ['Start from a clean Fusion session (save and close half-finished designs) and run this again.'])+ 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]@@ -1001,7 +1022,8 @@ def cmd_schematic(a): for g in plan['groups']: say('drawing %s: %s' % (g['group'], ', '.join(g['parts']))) r = ab('fusion_schematic_run', {'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms}, timeout=180)- if not r.get('success'): err('schematic group %s: %s' % (g['group'], r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.'])+ saved = keep_reply('run-%02d-%s' % (plan['groups'].index(g) + 1, g['group']), {'group': g['group'], 'parts': g['parts'], 'commands': g['commands'], 'reply': r})+ if not r.get('success'): schematic_failure(g, r, saved) sheet = r.get('nets') or sheet # pin by pin against the netlist (pads and pin names both count) pad2pin = {}@@ -1047,6 +1069,36 @@ def cmd_schematic(a): 'Then the board: adom-aiflow start --board %s --spec <spec.json> --target %s --remote-board fusion:%s --engine <you> --prompt-time <paste time>' % (out, TARGET or '<box>', name)]) +def keep_reply(key, data):+ """A bridge reply kept whole in the run (<run>/fusion/schematic/<key>.json), for the next report."""+ d = STATE / 'schematic'; d.mkdir(parents=True, exist_ok=True)+ f = d / (re.sub(r'[^A-Za-z0-9._-]+', '-', key) + '.json')+ f.write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), **data}, indent=1))+ return f+++ASSET_ADDED = ('Fusion 2706 raises "PlacePartWorker.cpp ... Condition: assetAdded" on an ADD from a library whose XML elements '+ 'share one line (fusion-bridge#135): this step writes the library with line breaks, and Fusion bridge 1.13.47+ '+ 'rewrites a compact one before loading it. Update the Fusion bridge if it is older, then run this step again.')+++def schematic_failure(g, r, saved):+ """A failed fusion_schematic_run group, said in words, with the full reply's path."""+ code = r.get('errorCode') or ''+ where = 'The whole reply is in %s.' % saved+ retry = 'Close the design without saving (fusion_close_document {save:false}) before you retry.'+ if code == 'add_failed':+ err('schematic group %s: the ADD of %s did not land (command %s of the group). %s' % (g['group'], r.get('part'), r.get('failedAt'), r.get('error')),+ [ASSET_ADDED, 'The commands before it ran. ' + retry, where])+ if code == 'fusion_assert':+ err('schematic group %s: Fusion raised an internal error during the run: %s' % (g['group'], '; '.join(str(x) for x in (r.get('fusionAsserts') or [r.get('error')]))),+ [ASSET_ADDED if 'assetAdded' in json.dumps(r) else 'Read fusion_check_dialogs and the reply below.', retry, where])+ if code == 'no_3d_package':+ err('schematic group %s: %s' % (g['group'], r.get('error')),+ ['Fusion bridge 1.13.46 refused parts without a 3D package; that refusal was wrong (the cause of fusion-bridge#135 is the library\'s layout, not 3D) and 1.13.47 drops it. Update the Fusion bridge, then run this step again.', where])+ err('schematic group %s: %s%s' % (g['group'], ('[%s] ' % code) if code else '', r.get('error')), ['Read fusion_check_dialogs; nothing after this group was drawn.', where])++ def restore_3d(st, name, project, folder): """Fusion drops a part's 3D binding when the part is added from a library file (the 3D PCB then draws a flat placeholder box). The design is exported, every schematic part and board element of a package that@@ -1108,6 +1160,7 @@ def restore_3d(st, name, project, folder): time.sleep(6) s_ = ab('fusion_eagle_import_status', {'runId': rid}) if s_.get('stage') in ('done', 'failed'): break+ keep_reply('restore3d-import', {'runId': rid, 'start': r, 'status': s_}) if s_.get('stage') != 'done': err('3D restore: the import did not finish: %s' % str((s_.get('result') or {}).get('error'))[:300], ['The design before the restore is on the box: %s/%s-src.sch and .brd.' % (bdir, name)]) return sorted(restored)
tools/test_fusion_project.py+55@@ -91,5 +91,60 @@ class CloudSave(unittest.TestCase): self.assertEqual(fp.cloud_place(a, {'cloud': {'project': 'Q', 'folder': 'F'}}), ('P', 'F')) +class LibraryLineBreaks(unittest.TestCase):+ """adom-aiflow#114 / fusion-bridge#135: Fusion 2706 cannot ADD from a .lbr whose elements share one line."""+ COMPACT = ('<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE eagle SYSTEM "eagle.dtd">\n<eagle version="9.7"><drawing><library name="P">'+ '<packages><package name="R0402"><description>a <b>0402</b> resistor</description>'+ '<smd name="1" x="-0.5" y="0" dx="0.5" dy="0.6" layer="1"/><text x="0" y="1" size="0.8" layer="25">>NAME</text>'+ '<text x="0" y="-1" size="0.8" layer="27"></text></package></packages>'+ '<packages3d><package3d name="R0402" urn="" wip_urn="u" locally_modified="yes" type="model"><packageinstances><packageinstance name="R0402"/></packageinstances></package3d></packages3d>'+ '<devicesets><deviceset name="R" prefix="R"><gates><gate name="G$1" symbol="R" x="0" y="0"/></gates>'+ '<devices><device name="" package="R0402"><connects><connect gate="G$1" pin="1" pad="1"/></connects>'+ '<technologies><technology name=""><attribute name="NOTE" value="a > b"/></technology></technologies></device></devices></deviceset></devicesets>'+ '</library></drawing></eagle>')++ @staticmethod+ def canon(text):+ def walk(e):+ return (e.tag, sorted(e.attrib.items()), (e.text or '').strip() if len(e) else (e.text or ''), [walk(c) for c in e])+ return walk(ET.fromstring(text.split('?>', 1)[1].split('>', 1)[1] if text.startswith('<?xml') else text))++ def test_every_element_on_its_own_line_and_nothing_else_changes(self):+ out = fp.break_lines(self.COMPACT)+ self.assertNotIn('><', out.replace('></text>', '')) # the only adjacent pair left is an empty element's own open and close+ self.assertIn('<text x="0" y="-1" size="0.8" layer="27"></text>', out) # empty text stays empty+ self.assertIn('layer="25">>NAME</text>', out)+ self.assertIn('value="a > b"', out)+ self.assertTrue(out.startswith('<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE eagle SYSTEM "eagle.dtd">\n<eagle'))+ self.assertEqual(self.canon(out), self.canon(self.COMPACT))+ self.assertEqual(fp.break_lines(out), out) # idempotent: a library already broken is left as it is++ def test_a_raw_gt_in_text_is_not_split(self):+ t = '<a><text>></text><b/></a>'+ self.assertEqual(fp.break_lines(t), '<a>\n<text>></text>\n<b/>\n</a>')+++class SchematicFailures(unittest.TestCase):+ def test_failure_codes_name_the_part_the_cause_and_the_reply(self):+ import io, contextlib+ for code, extra, want in (('add_failed', {'part': 'R7', 'failedAt': 3}, 'ADD of R7'),+ ('fusion_assert', {'fusionAsserts': ['PlacePartWorker.cpp, Line 23. Condition: assetAdded']}, '1.13.47'),+ ('no_3d_package', {}, '1.13.47')):+ buf = io.StringIO()+ with contextlib.redirect_stdout(buf), self.assertRaises(SystemExit):+ fp.schematic_failure({'group': 'power', 'parts': []}, {'success': False, 'errorCode': code, 'error': 'x', **extra}, Path('/run/fusion/schematic/run-01-power.json'))+ self.assertIn(want, buf.getvalue()); self.assertIn('run-01-power.json', buf.getvalue())++ def test_replies_are_kept_whole_in_the_run(self):+ d = Path(tempfile.mkdtemp()); old = fp.STATE; fp.STATE = d+ try:+ f = fp.keep_reply('run-01-power supply', {'reply': {'success': False, 'errorCode': 'add_failed'}})+ self.assertEqual(f.name, 'run-01-power-supply.json')+ import json as _j+ self.assertEqual(_j.loads(f.read_text())['reply']['errorCode'], 'add_failed')+ finally:+ fp.STATE = old++ if __name__ == '__main__': unittest.main()
Comments
Log in to comment.
No comments yet.