← Commit history

Merge PR #373: Fusion lane: one element per line in the project library; keep the bridge replies (#114) (aiflow-lane-114 -> main) merge

Colby Knox ·0b54177120 · ·parents 1c3a691, 6e212f9

Showing changes introduced by the merge (against its first parent).

3 files changed +125−7
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+64−7
@@ -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'@@ -810,8 +830,12 @@ def cmd_library(a):                 res = pr; break             if pr.get('stage'): say('  3D: %s' % pr.get('stage'))         if not res: err('the 3D build did not finish in time', ['Read fusion_prove_result; run `adom-aiflow fusion library` again: parts already bound are kept and only the rest are built.'])-        if (res.get('projectName') or r.get('projectName')) != project:-            models_home = 'The 3D models went to the project open in Fusion\'s data panel (Adom AI Workspace/%s there), not %s/%s: this Fusion bridge does not take a destination for them yet. Make %s the active project in the data panel before `fusion library` to keep them with the design.' % (name, project, folder, project)+        (STATE / 'library' / 'build3d.json').write_text(json.dumps({'start': r, 'result': res}, indent=1))+        # only a project the bridge names counts: its build result does not echo one (1.13.47 saved the+        # models in --project/--folder and said nothing, and this used to claim they went elsewhere)+        went = res.get('projectName') or r.get('projectName')+        if went and went != project:+            models_home = 'The 3D models went to %s, not %s/%s: this Fusion bridge does not take a destination for them. Update the Fusion bridge (1.12.0+ saves them in --project/--folder), or make %s the active project in the data panel before `fusion library`. The build\'s replies are in <run>/fusion/library/build3d.json.' % (went, project, folder, project)         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']@@ -827,7 +851,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 +1000,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 +1026,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 +1073,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 +1164,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 &lt;b&gt;0402&lt;/b&gt; 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">&gt;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">&gt;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()