← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.70\ No newline at end of file+1.13.71\ No newline at end of file
CHANGELOG.md+1−1
@@ -3,7 +3,7 @@ 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` -## Next release+## 1.13.71 (2026-11-04)  From Glenn Jones's reports on a 4-layer board: 
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.70",+  "version": "1.13.71",   "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.",
contracts/describe.json+24−1
@@ -1131,6 +1131,29 @@       "summary": "Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. Each pour is named by net, layer and outline, picked on the midpoint of its longest edge, then RATSNEST refills. The readback proves each pour took its new width/isolate and that no track or via changed, and reports the filled area before and after.",       "timeoutSeconds": 120     },+    {+      "example": {},+      "input": {+        "boardId": "optional",+        "dryRun": "bool",+        "expectDocument": "the board",+        "expectedRevision": "optional",+        "filter": "{drill?, diameter?, net?}: every via matching, with set",+        "set": "{drill?, diameter?} mm",+        "vias": "[{x, y, drill?, diameter?}] mm: the vias at these points"+      },+      "longRunning": false,+      "name": "fusion_change_vias",+      "output": {+        "changes": "[{id, before, after}]",+        "results": "[ok|err:...]",+        "revision": "str",+        "verified": "bool"+      },+      "statusVerb": "fusion_routing_state",+      "summary": "Change existing vias, and nothing but vias: drill and diameter, by position or by a filter on what they are now. Only the Vias layer is displayed while they're picked, so a track end or pad at the same point can't be taken instead, and the board's layer display goes back after. Read back: only the asked vias may differ, or every change is undone.",+      "timeoutSeconds": 120+    },     {       "example": {         "mode": "courtyard",@@ -2009,7 +2032,7 @@         "verified": "bool"       },       "statusVerb": "fusion_routing_state",-      "summary": "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. An end moves only along the track's own line: at an angle Fusion bends the track into two segments, so that is undone and refused (end_move_bends). Change a track's layer and its ends in two calls.",+      "summary": "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. An end moved at an angle draws the track again and deletes the old one, as Fusion's MOVE would bend it into two segments. Change a track's layer and its ends in two calls.",       "timeoutSeconds": 120     },     {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.70"+version = "1.13.71" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.70"+version = "1.13.71" 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.70"+DASH_VERSION="1.13.71" 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.70",+  "version": "1.13.71",   "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.70",+  "version": "1.13.71",   "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.70"+version = "1.13.71" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.70"+version = "1.13.71" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.70"+version = "1.13.71" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.70"+version = "1.13.71" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.70"+version = "1.13.71" 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.70"+version = "1.13.71" edition = "2021" license = "MIT" 
tests/glenn_drill.pyadded+115
@@ -0,0 +1,115 @@+#!/usr/bin/env python3+"""Live drill of Glenn Jones's reports (#139, #141, #142, #143, #144, #145) on new test designs, on a machine with Fusion and its seat.+  python3 tests/glenn_drill.py --target Colby-ROG2 --dir C:/Users/<you>/Documents/fusion-lane-tests/glenn --unc /mnt/c/Users/<you>/Documents/fusion-lane-tests/glenn+"""+import argparse, json, os, subprocess, sys, time, zipfile+ap = argparse.ArgumentParser(); ap.add_argument('--target', required=True); ap.add_argument('--dir', required=True); ap.add_argument('--unc', required=True, help='the same folder as this machine sees it')+ap.add_argument('--only', default=''); a = ap.parse_args(); A = ['adom-bridge', '--target', a.target, '--ai-thread', 'fusion-lane']; D = a.dir+FIX = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'fixtures', 'sch_drill.lbr'); LBR = D + '/sch_drill.lbr'+def call(v, args=None, t=400):+    try: o = subprocess.run(A + [v, json.dumps(args or {})], capture_output=True, text=True, timeout=t).stdout+    except subprocess.TimeoutExpired: return {'success': False, 'error': 'drill timeout'}+    try: return json.loads(o)+    except Exception: return {'error': o[:300], 'success': False}+res = []+def check(n, ok, why=''):+    res.append(bool(ok)); print('%-58s %s %s' % (n, 'PASS' if ok else 'FAIL', '' if ok else str(why)[:300]), flush=True)+def active(): return call('fusion_get_app_state').get('data', {}).get('activeDocument')+def docs(): d = call('fusion_get_app_state'); d = d.get('data', d); return [x.get('name') for x in d.get('documents') or []]+def nodialog(t): n = call('fusion_check_dialogs'); check('no dialog ' + t, not n.get('count'), [(x.get('title'), (x.get('text') or '')[:90]) for x in n.get('dialogs') or []])+def script(s): r = call('fusion_run_modeling_script', {'script': s}); return (r.get('data') or {}).get('result'), r.get('error')+def closeall():+    for _ in range(8):+        ds = [n for n in docs()]+        if len(ds) <= 1: break+        call('fusion_close_document', {'name': ds[-1], 'save': False})+FIXED = [{'ref': 'R1', 'deviceset': 'RES', 'value': '1k', 'x': 12, 'y': 20, 'nets': {'A': 'MID', 'B': 'NB'}}, {'ref': 'R2', 'deviceset': 'RES', 'value': '1k', 'x': 28, 'y': 20, 'nets': {'A': 'MID', 'B': 'NC'}}]+def seed(name, n=FIXED):+    r = call('fusion_board_seed', {'libraryPath': LBR, 'outDir': D, 'name': name, 'title': 'glenn', 'outlineMm': [50, 40], 'fixed': n}); check('seed ' + name, r.get('success'), r.get('error'))+def board(name, n=FIXED):+    seed(name, n); r = call('fusion_open_board', {'filePath': '%s/%s.brd' % (D, name)}); check('open ' + name, r.get('success'), r.get('error')); time.sleep(4); return active()+def st(doc): return call('fusion_routing_state', {'expectDocument': doc})+def want(x): return not a.only or x in a.only.split(',')+os.makedirs(a.unc, exist_ok=True)+print('send lib', call('send_files', {'filePaths': [FIX], 'dest': D, 'destIsFolder': True, 'reason': 'glenn_drill: its test library'}).get('success'))+r = call('fusion_start', {'reason': 'glenn_drill'}, 300); print('start', r.get('success'), (r.get('error') or '')[:120])+for i in range(40):+    x = call('fusion_readiness', {'noAutoStart': True})+    if x.get('ready'): break+    time.sleep(5)+print('ready', x.get('ready'), x.get('bridgeVersion'), x.get('addinVersion'), flush=True)++if want('139'):+    print('--- #139 read-back after an EAGLE import')+    seed('GImport')+    run = call('fusion_new_electronics_from_eagle', {'schPath': D + '/GImport.sch', 'brdPath': D + '/GImport.brd'}).get('runId')+    for _ in range(40):+        time.sleep(4); s = call('fusion_eagle_import_status', {'runId': run})+        if s.get('stage') in ('done', 'failed'): break+    r = s.get('result') or {}+    check('import counts every part', s.get('stage') == 'done' and r.get('partsPlaced') == 2 and not r.get('errorCode'), {k: r.get(k) for k in ('partsPlaced', 'partsExpected', 'errorCode', 'error')}); nodialog('after the import'); closeall()++if want('142') or want('143'):+    print('--- #142 gerbers on a 4-layer board whose inner layers are only pours')+    doc = board('GGerb'); r = call('fusion_set_layer_count', {'expectDocument': doc, 'count': 4}); check('4 layers', r.get('success'), r.get('error')); time.sleep(2)+    s = st(doc); r = call('fusion_apply_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'In1.Cu', 'points': [[1, 1], [49, 1], [49, 39], [1, 39]]}, {'net': 'NB', 'layer': 'In2.Cu', 'points': [[1, 1], [49, 1], [49, 39], [1, 39]]}]}); check('inner pours', r.get('success'), r.get('error') or r.get('missing'))+    out = D + '/g4'; r = call('fusion_export_gerbers', {'outputDir': out}, 300); d = r.get('data') or r+    check('exports 4 layers with no layers argument', r.get('success') and d.get('layerCount') == 4, {k: d.get(k) for k in ('layerCount', 'detectionMethod', 'error')}); print('   ', r.get('layerSource'), d.get('detectionMethod'), d.get('boardCopperLayers'))+    z = (d.get('zipPath') or '').replace(D, a.unc); names = zipfile.ZipFile(z).namelist() if z and os.path.exists(z) else []+    print('    zip', len(names), sorted(os.path.splitext(n)[1].upper() for n in names)[:14])+    check('the zip has the inner layers', any(n.upper().endswith(('.G1', '.G2', '.GP1', '.GP2')) for n in names), names)+    if want('143'):+        print('--- #143 the panel')+        def panels():+            w = call('desktop_list_windows'); ws = (w.get('data') or w).get('windows') or []+            return [x.get('title') for x in ws if 'driving Fusion' in (x.get('title') or '') or 'ADOM' in (x.get('title') or '')]+        print('    right after the export:', panels()); time.sleep(10); left = panels(); check('no panel 10 s after the verb answered', not left, left)+        call('fusion_export_gerbers', {'outputDir': out + 'b'}, 300); call('fusion_export_gerbers', {'outputDir': out + 'c'}, 300); time.sleep(10); left = panels(); check('nor after two exports in a row', not left, left)+    closeall()+    doc = board('GGerb2'); r = call('fusion_export_gerbers', {'outputDir': D + '/g2'}, 300); d = r.get('data') or r; check('a 2-layer board still exports 2', r.get('success') and d.get('layerCount') == 2, {k: d.get(k) for k in ('layerCount', 'error')}); closeall()++if want('141'):+    print('--- #141 a document with no tab')+    print('   ', script("import adsk.core\nd=app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType,False)\nresult={'visible':d.isVisible,'n':app.documents.count}"))+    doc = board('GTab'); time.sleep(2)+    print('   ', script("result=[(app.documents.item(i).name,app.documents.item(i).isVisible) for i in range(app.documents.count)]"))+    r = st(doc); check('routing_state reads the right board', r.get('success') is not False and r.get('documentName') == doc and len(r.get('elements') or []) == 2, {k: r.get(k) for k in ('errorCode', 'error', 'documentName')})+    r = call('fusion_board_source', {'expectDocument': doc}); check('board_source too', r.get('success') is not False, r.get('error'))+    print('   ', script("import adsk.core\nfor i in range(app.documents.count):\n    d=app.documents.item(i)\n    if not d.isVisible:\n        d.close(False)\n        break\nresult=app.documents.count")); closeall()++if want('145'):+    print('--- #145 fusion_change_vias')+    doc = board('GVia'); s = st(doc)+    r = call('fusion_place_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'net': 'MID', 'x': 5, 'y': 5, 'drill': 0.3, 'diameter': 0.7}, {'net': 'MID', 'x': 10, 'y': 5, 'drill': 0.3, 'diameter': 0.7}, {'net': 'NB', 'x': 15, 'y': 5, 'drill': 0.4, 'diameter': 0.8}]}); check('place 3', r.get('success'), r.get('error'))+    s = st(doc); call('fusion_execute_text_command', {'command': "Electron.run LAYER 1; SET WIRE_BEND 2; LINE 'MID' 0.3mm (5mm 5mm) (10mm 5mm);"})+    s = st(doc); tr0 = len(s.get('tracks') or [])+    vz = lambda: {tuple(v['at']): (round(v['drill'], 3), round(v['diameter'], 3)) for v in st(doc).get('vias') or []}+    r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'filter': {'drill': 0.3}, 'set': {'drill': 0.35}, 'dryRun': True}); check('dry run lists two and changes none', r.get('success') and len(r.get('changes') or []) == 2 and vz()[(5.0, 5.0)][0] == 0.3, r.get('error'))+    s = st(doc); r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'filter': {'drill': 0.3}, 'set': {'drill': 0.35}})+    v = vz(); check('filter changes both, leaves the third', r.get('verified') and v[(5.0, 5.0)][0] == 0.35 and v[(10.0, 5.0)][0] == 0.35 and v[(15.0, 5.0)][0] == 0.4, (r.get('errorCode'), r.get('error'), v))+    s = st(doc); r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'x': 5, 'y': 5, 'diameter': 0.9}]})+    v = vz(); check('by position, on a track end, changes the via', r.get('verified') and v[(5.0, 5.0)][1] == 0.9 and v[(10.0, 5.0)][1] == 0.7, (r.get('errorCode'), r.get('error'), v)); check('tracks untouched', len(st(doc).get('tracks') or []) == tr0, tr0)+    vis = script("import xml\nresult=1")+    s = st(doc); r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'x': 40, 'y': 40, 'drill': 0.3}]}); check('no via there: nothing changes', r.get('errorCode') == 'nothing_to_do', r.get('errorCode')); nodialog('after the vias'); closeall()++if want('144'):+    print('--- #144 hiding rotated names')+    parts = [dict(FIXED[0], ref='R1', x=10, y=10), dict(FIXED[1], ref='R2', x=25, y=10), {'ref': 'R3', 'deviceset': 'RES', 'value': '1k', 'x': 40, 'y': 10, 'nets': {'A': 'MID', 'B': 'ND'}}]+    doc = board('GSilk', parts); s = st(doc)+    r = call('fusion_move_parts', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'parts': [{'ref': 'R2', 'x': 25, 'y': 10, 'rotation': 90}, {'ref': 'R3', 'x': 40, 'y': 10, 'rotation': 270}]}); check('rotate two parts', r.get('success'), r.get('error'))+    silk = call('fusion_silk_state', {'expectDocument': doc}); comps = {c['ref']: c for c in silk.get('components') or []}+    print('    names', {k: {x: v['name'].get(x) for x in ('x', 'y', 'rotation', 'shown')} for k, v in comps.items()})+    r = call('fusion_apply_silkscreen', {'expectDocument': doc, 'names': [{'ref': k, 'x': comps[k]['name']['x'], 'y': comps[k]['name']['y'] + 2, 'show': False} for k in ('R1', 'R2', 'R3')], 'keepPartial': True})+    print('    results', r.get('results'), r.get('errorCode'), (r.get('error') or '')[:140])+    check('hides unrotated and rotated names', r.get('results') == ['ok', 'ok', 'ok'], r.get('results'))+    json.dump({'doc': doc}, open(os.path.join(a.unc, 'silk.json'), 'w'))+    print('--- #144 a failed batch is undone')+    s0 = call('fusion_silk_state', {'expectDocument': doc}); t0 = len(s0.get('texts') or [])+    r = call('fusion_apply_silkscreen', {'expectDocument': doc, 'names': [{'ref': 'R1', 'x': 1000, 'y': 1000, 'size': -1}] if False else [{'ref': 'R1', 'x': comps['R1']['name']['x'], 'y': comps['R1']['name']['y'], 'rotation': 33.3}], 'texts': [{'text': 'T%d' % i, 'x': 5 + 3 * i, 'y': 30} for i in range(4)]})+    s1 = call('fusion_silk_state', {'expectDocument': doc}); print('   ', r.get('errorCode'), (r.get('error') or '')[:120], 'texts', t0, '->', len(s1.get('texts') or []), r.get('results'))+    if r.get('errorCode'):+        check('after a failure the new texts are gone', len(s1.get('texts') or []) == t0 and not r.get('mutated'), (t0, len(s1.get('texts') or []), r.get('mutated')))+    else:+        print('    (that batch landed, so the failure path was not exercised)')+    nodialog('after the silkscreen'); closeall()+print('%d PASS, %d FAIL' % (sum(res), len(res) - sum(res))); sys.exit(0 if all(res) else 1)