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
Fusion lane: write the project library one element per line; keep the import and schematic_run replies in the run (#114)
3 files changed
+119−5
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()