← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.59\ No newline at end of file+1.13.60\ No newline at end of file
CHANGELOG.md+5
@@ -3,6 +3,11 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.13.60 (2026-11-02)++Library names: a library name already in use from another path is refused (`library_name_in_use`, naming both paths), because loading+it too would make the next ADD open Fusion's Select Library picker, a modal that blocks Fusion. Found when a drill with real libraries hung on it.+ ## 1.13.59 (2026-11-02)  `fusion_edit_tracks` moves a track's end only along its own line. At an angle Fusion's MOVE bends the track into two segments, whatever
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.59",+  "version": "1.13.60",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.59"+version = "1.13.60" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.59"+version = "1.13.60" edition = "2021"  [dependencies]
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.59"+DASH_VERSION="1.13.60" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.59",+  "version": "1.13.60",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.59",+  "version": "1.13.60",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.59"+version = "1.13.60" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.59"+version = "1.13.60" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.59"+version = "1.13.60" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.59"+version = "1.13.60" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.59"+version = "1.13.60" dependencies = [  "fusion-core",  "windows-sys 0.59.0",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.59"+version = "1.13.60" edition = "2021" license = "MIT" 
rust/bridge/src/lbr_use.py+9
@@ -94,6 +94,14 @@ def use_library(app, path, removed=None):     Fusion 2706 could not read it (see above). Returns True when the file was rewritten; the 3D entries     it dropped go on `removed`."""     from pathlib import Path as _P+    import sys as _sys+    # two in-use libraries with one name at different paths make the next ADD open Fusion's Select Library+    # picker, a modal that blocks it, and a library can't be taken out of use from a command. So the second+    # is refused. The record lives in this Fusion process and sees only what the bridge loaded.+    used = _sys.__dict__.setdefault('_adom_lbr_used', {})+    full, name = _P(path).as_posix(), _P(path).stem.lower()+    if used.get(name, full).lower() != full.lower():+        raise RuntimeError('library_name_in_use: a library named %s is already in use from %s, and loading %s too would make Fusion ask which one to take (a dialog that blocks it). Use that path, give this file another name, or restart Fusion (fusion_restart_for_addin)' % (_P(path).stem, used[name], full))     try:         changed = normalize_lbr(path, removed)     except PermissionError:@@ -101,4 +109,5 @@ def use_library(app, path, removed=None):                            'no line breaks between elements, a byte-order mark or single-quoted attributes), and this machine will not let '                            'the bridge rewrite it. Save it somewhere writable and pass that path' % path)     app.executeTextCommand("Electron.run USE '%s';" % _P(path).as_posix())+    used[name] = full     return changed
rust/bridge/src/routing_script.py+4−3
@@ -692,8 +692,8 @@ def sch_run(app, args):         # (PlacePartWorker assetAdded) on every ADD from one (#135; lbr_use.py)         try: reformatted = use_library(app, lbr, orphans_removed)         except RuntimeError as ex:-            code = 'library_not_writable' if str(ex).startswith('library_not_writable') else 'library_unreadable'-            fail(code, str(ex).split(': ', 1)[-1] if code == 'library_not_writable' else str(ex))+            code = str(ex).split(':', 1)[0] if str(ex).startswith(('library_not_writable', 'library_name_in_use')) else 'library_unreadable'+            fail(code, str(ex).split(': ', 1)[-1] if code != 'library_unreadable' else str(ex))     pause = min(max(float(args.get('pauseMs', 0)), 0), 3000) / 1000.0     for c in cmds:         c = c.strip()@@ -1292,7 +1292,8 @@ def place_parts(app, root, board, meta, args):     if args.get('dryRun'):         return {'success': True, 'mutated': False, **meta, 'results': results, 'placed': placed, 'commands': cmds}     for lbr in sorted(used):-        use_library(app, lbr)+        try: use_library(app, lbr)+        except RuntimeError as ex: fail(str(ex).split(':', 1)[0] if ':' in str(ex)[:24] else 'library_unreadable', str(ex).split(': ', 1)[-1])     for c in cmds:         app.executeTextCommand('Electron.run ' + c)     _, after, meta2 = snapshot(app, args)
rust/bridge/src/sch_script.py+2−2
@@ -1099,7 +1099,7 @@ def sch_place_part(app, args):         return plan_reply(e, **out)     if lpath:         try: use_library(app, lpath)-        except RuntimeError as ex: sfail('library_not_writable', str(ex).split(': ', 1)[-1])+        except RuntimeError as ex: sfail(str(ex).split(':', 1)[0] if str(ex).startswith(('library_not_writable', 'library_name_in_use')) else 'library_unreadable', str(ex).split(': ', 1)[-1])     e.run()     placed = {p_.get('name') for p_ in e.after.iter('part')}     if lpath and placed == parts:@@ -1156,7 +1156,7 @@ def sch_swap_part(app, args):         return plan_reply(e, was={'deviceset': part.get('deviceset'), 'device': part.get('device'), 'library': part.get('library')})     if lpath:         try: use_library(app, lpath)-        except RuntimeError as ex: sfail('library_not_writable', str(ex).split(': ', 1)[-1])+        except RuntimeError as ex: sfail(str(ex).split(':', 1)[0] if str(ex).startswith(('library_not_writable', 'library_name_in_use')) else 'library_unreadable', str(ex).split(': ', 1)[-1])     e.run()     names_before = {p_.get('name') for p_ in e.root.iter('part')}     if not any(p_.get('name') == ref for p_ in e.after.iter('part')):
tests/sch_drill.py+103−5
@@ -6,7 +6,12 @@ fusion_sch_swap_part stayed broken from 1.13.8 to 1.13.53 with only its dry run     python3 tests/sch_drill.py --target DESKTOP-BAR6S0F [--dir C:/Users/<you>/Documents/fusion-lane-tests/drill]  It opens a new design and closes it again, and touches nothing else. Run it when no other lane has this-Fusion in front. Exit 1 when any step fails."""+Fusion in front. Exit 1 when any step fails.++Its own fixture is written the way the bridge expects, so a pass on it alone proves little: #136 passed every+test here and failed on a customer's real library. So it also runs real parts from libraries it did not make:+AdomBasicParts_full.lbr from this repo, and each --foreign library on the machine. Every check compares+before and after, so a verb that answers success and does nothing fails.""" import argparse import json import os@@ -18,6 +23,7 @@ ap = argparse.ArgumentParser() ap.add_argument('--target', required=True) ap.add_argument('--dir', default='C:/Users/olive/Documents/fusion-lane-tests/drill') ap.add_argument('--keep', action='store_true', help='leave the design open')+ap.add_argument('--foreign', action='append', default=[], help='another .lbr ALREADY on the Fusion machine to exercise, e.g. a Fusion export or a customer library. It stays there: nothing of it is copied. Repeatable.') opts = ap.parse_args() A = ['adom-bridge', '--target', opts.target, '--ai-thread', 'fusion-lane'] D = opts.dir@@ -105,13 +111,17 @@ step('sch_connect wire', 'fusion_sch_connect', {'connections': [{'designator': '      lambda _r: expect(net('R4', 'B') == 'N2', 'R4.B is on %s' % net('R4', 'B'))) step('sch_connect_pins', 'fusion_sch_connect_pins', {'pairs': [{'from': 'R5.1', 'to': 'R6.1'}]},      lambda _r: expect(net('R5', 'A') and net('R5', 'A') == net('R6', 'A'), 'R5.A %s, R6.A %s' % (net('R5', 'A'), net('R6', 'A'))))-step('sch_edit_label', 'fusion_sch_edit_label', {'edits': [{'net': 'N1', 'by': [0, 2.54]}]})+label_y = next((l['at'][1] for l in items('labels').get('labels') or [] if l.get('text') == 'N1'), None)+step('sch_edit_label', 'fusion_sch_edit_label', {'edits': [{'net': 'N1', 'by': [0, 2.54]}]},+     lambda _r: expect(any(l.get('text') == 'N1' and abs(l['at'][1] - label_y - 2.54) < 0.01 for l in items('labels').get('labels') or []), 'label did not move')) step('sch_text', 'fusion_sch_text', {'texts': [{'text': 'DRILL NOTE', 'at': [20, 40]}]},      lambda _r: expect('DRILL NOTE' in json.dumps(items('texts')), 'text not on the sheet')) w = (pins('R4').get(('R4', 'B')) or {}).get('at') wires = [x for x in items('wires').get('wires') or []] stub = next((x for x in wires if w and ('%g,%g' % tuple(w)) in str(x.get('id'))), wires[0] if wires else {})-step('sch_split_wire', 'fusion_sch_split_wire', {'id': stub.get('id')})+n_wires = len(wires)+step('sch_split_wire', 'fusion_sch_split_wire', {'id': stub.get('id')},+     lambda _r: expect(len(items('wires').get('wires') or []) == n_wires + 1, 'still %d wires' % n_wires)) junctions = items('junctions').get('junctions') or [] step('sch_split_wire left a junction', 'fusion_sch_items', {'kinds': ['junctions']}, lambda _r: expect(junctions, 'no junction on the sheet')) step('sch_junction remove', 'fusion_sch_junction', {'remove': [(junctions or [{}])[0].get('id')]},@@ -143,8 +153,10 @@ step('sch_move_region', 'fusion_sch_move_region', {'box': [a0[0] - 15, a0[1] - 6      lambda _r: expect(abs(pins('R6')[('R6', 'A')]['at'][0] - a0[0] - 10.16) < 0.01, 'R6 did not move')) step('sch_edit rotate', 'fusion_sch_edit', {'edits': [{'designator': 'R7', 'action': 'rotate', 'degrees': 90}]},      lambda _r: expect((parts()['R7'].get('gates') or [{}])[0].get('rotation') in (90, 90.0), 'R7 not turned'))-step('sch_reset_fields', 'fusion_sch_reset_fields', {'references': ['R7']})-step('sch_fix_connectivity dry', 'fusion_sch_fix_connectivity', {})+step('sch_reset_fields', 'fusion_sch_reset_fields', {'references': ['R7']},+     lambda r: expect(r.get('mutated') is not None and (r.get('reset') or r.get('references') or r.get('commands') or r.get('count')), 'answered with nothing reset: %s' % json.dumps({k: r.get(k) for k in ('mutated', 'reset', 'count')})))+step('sch_fix_connectivity dry', 'fusion_sch_fix_connectivity', {},+     lambda r: expect(r.get('mutated') is False, 'a dry run changed the sheet'))  # ── buses ────────────────────────────────────────────────────────────────────────────────────────── # a long bus under R8, whose pins are on a row above it, so an entry from each pin lands on the bus@@ -178,6 +190,92 @@ def sheet_count(): n = sheet_count() step('sch_create', 'fusion_sch_create', {}, lambda _r: expect(sheet_count() == n + 1, 'sheets %d, was %d' % (sheet_count(), n))) +# ── real parts from libraries this drill did not make ──────────────────────────────────────────────+def foreign(path, on_machine):+    """Place real parts, wire one, swap it, and prove each change on the sheet AND on the board, by package."""+    import re+    import xml.etree.ElementTree as ET+    tag = os.path.basename(path)+    if on_machine:+        got = call('read_file', {'path': path})+        text = got.get('content') or (got.get('data') or {}).get('content')+        if not text:+            results.append(('%s readable' % tag, 'cannot read it on the Fusion machine'))+            print('%-34s FAIL  cannot read it' % tag)+            return+    else:+        text = open(path, encoding='utf-8').read()+        call('send_files', {'filePaths': [path], 'dest': D, 'destIsFolder': True, 'reason': 'sch_drill: a public library from its repo'})+        path = D + '/' + tag+    # a name already in use from another path is refused, by design: that's not this drill's failure to report+    probe = call('fusion_schematic_run', {'libraryPath': path, 'commands': ['WINDOW FIT;']})+    if probe.get('errorCode') == 'library_name_in_use':+        print('%-34s SKIP  %s' % (tag[:34], str(probe.get('error'))[:120]))+        return+    root = ET.fromstring(text.encode('utf-8'))+    sets = [d for d in root.iter('deviceset') if any(v.get('package') for v in d.iter('device')) and len(d.findall('gates/gate')) == 1+            and not any('(' in (v.get('name') or '') for v in d.iter('device'))]+    picks = [sets[i] for i in sorted({0, len(sets) // 2, len(sets) - 1})] if sets else []+    if not picks:+        results.append(('%s has parts' % tag, 'no single-gate part with a footprint'))+        return+    pkg_of = {d.get('name'): next(v.get('package') for v in d.iter('device') if v.get('package')) for d in sets}+    base = 9000 + 500 * len(results)+    for k, d in enumerate(picks):+        name, ref = d.get('name'), 'F%d' % (len(results) + k)+        had = set(parts())+        y = 8000 + 300 * ((len(results) + k) % 20)++        def placed(_r, ref=ref, name=name, had=had):+            p_ = parts()+            if ref not in p_ or ref in had:+                return '%s is not a new part on the sheet' % ref+            if p_[ref].get('libReference', '').split('@')[0].upper() != name.upper():+                return '%s is %s, not %s' % (ref, p_[ref].get('libReference'), name)+            if not pins(ref):+                return '%s has no pins' % ref+            return None+        step('%s: place %s' % (tag[:12], name[:14]), 'fusion_sch_place_part', {'library': path, 'symbol': name, 'designator': ref, 'x': base + 700 * k, 'y': y}, placed)+    ref = None+    rows = parts()+    for d in picks:+        ref = next((r for r, c in rows.items() if r.startswith('F') and c.get('libReference', '').split('@')[0].upper() == d.get('name').upper()), None)+        if ref and pins(ref):+            break+    if not ref:+        return+    first = sorted(pins(ref))[0][1]+    step('%s: connect' % tag[:12], 'fusion_sch_connect', {'connections': [{'designator': ref, 'pin': first, 'net': 'FN%d' % len(results)}]},+         lambda _r: expect(net(ref, first), '%s.%s is on no net' % (ref, first)))+    held = net(ref, first)+    pins_now = {p_[1] for p_ in pins(ref)}+    sym = {s_.get('name'): {q.get('name') for q in s_.iter('pin')} for s_ in root.iter('symbol')}+    same = next((d for d in sets if d.get('name').upper() != rows[ref].get('libReference', '').split('@')[0].upper()+                 and sym.get(d.find('gates/gate').get('symbol')) == pins_now), None)+    if same is not None:+        step('%s: swap to %s' % (tag[:12], same.get('name')[:12]), 'fusion_sch_swap_part', {'library': path, 'symbol': same.get('name'), 'designator': ref},+             lambda _r: expect(parts().get(ref, {}).get('libReference', '').split('@')[0].upper() == same.get('name').upper() and net(ref, first) == held,+                               '%s is %s on net %s, was %s' % (ref, parts().get(ref, {}).get('libReference'), net(ref, first), held)))+    # on the board too, with the footprint that device names: a part with the wrong package or none at all fails here+    call('fusion_show_2d_board', {'expectDocument': 'Untitled'})+    comps = {c.get('ref'): c for c in call('fusion_routing_state', {'expectDocument': 'Untitled'}).get('components') or []}+    call('fusion_show_schematic', {'expectDocument': 'Untitled'})+    wrong = []+    for r_, c in parts().items():+        if not r_.startswith('F'):+            continue+        want = pkg_of.get(c.get('libReference', '').split('@')[0]) or next((v for k_, v in pkg_of.items() if k_.upper() == c.get('libReference', '').split('@')[0].upper()), None)+        have = (comps.get(r_) or {}).get('pattern')+        if not comps.get(r_) or (want and have and have.upper() != want.upper()):+            wrong.append('%s: board has %s, device names %s' % (r_, have, want))+    results.append(('%s: on the board, right package' % tag[:12], expect(not wrong, '; '.join(wrong)[:170])))+    print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL  ' + str(results[-1][1])))+++foreign(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'AdomBasicParts_full.lbr'), False)+for extra in opts.foreign:+    foreign(extra, True)+ # ── the board: tracks, models, add-ins ───────────────────────────────────────────────────────────── call('fusion_show_2d_board', {'expectDocument': 'Untitled'}) step('model_check', 'fusion_model_check', {'expectDocument': 'Untitled'},
tests/test_lbr_use.py+16
@@ -97,6 +97,22 @@ class LbrUse(unittest.TestCase):         self.assertFalse(NS['normalize_lbr'](p))         self.assertIn('GONE', open(p, encoding='utf-8').read()) +    def test_a_second_path_with_the_same_name_is_refused_before_use(self):+        import sys+        sys.__dict__.pop('_adom_lbr_used', None)+        a, b = self.write(BROKEN), self.write(BROKEN)+        seen = []+        class App:+            def executeTextCommand(self, c): seen.append(c)+        NS['use_library'](App(), a)+        NS['use_library'](App(), a)   # the same path again is fine+        self.assertEqual(len(seen), 2)+        with self.assertRaises(RuntimeError) as x:+            NS['use_library'](App(), b)+        self.assertTrue(str(x.exception).startswith('library_name_in_use'))+        self.assertEqual(len(seen), 2)   # no USE sent: Fusion never gets to ask+        sys.__dict__.pop('_adom_lbr_used', None)+     def test_use_library_runs_use_after_the_rewrite(self):         p = self.write(COMPACT)         seen = []