← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.272\ No newline at end of file+1.9.273\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.272",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.272/adom-bridge-fusion-v1.9.272.zip",-  "sha256": "15febf85d61a57d30d842f42540e564925050fdf324c9f954892e09d2a1dfe0d",-  "size": 564192,+  "version": "1.9.273",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.273/adom-bridge-fusion-v1.9.273.zip",+  "sha256": "c8f3f88f83cf41257a5a1cbd750d317cff8fe55b0077f90753d70d24e74ad44b",+  "size": 566331,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.272",+  "version": "1.9.273",   "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.9.272"+version = "1.9.273" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.272"+version = "1.9.273" edition = "2021"  [dependencies]
dashboard/ui/quick-actions.html+4−2
@@ -165,6 +165,7 @@ input[type=text]{font:inherit;color:var(--text);background:var(--surface);border     <div class="row">       <button class="btn primary" id="btnImport" data-action="import_bundle" disabled>Create Fusion library</button>       <button class="btn" id="btnAttach" data-action="attach_run" title="Attach to a run already in flight">Attach to running import</button>+      <button class="btn danger" id="btnCancel" data-action="cancel_run" title="Stop the running import at its next part boundary; parts already bound keep their 3D, the library stays open" hidden>Cancel import</button>       <span class="mini" id="importNote">resolve a part first</span>     </div>     <div class="live" id="live" hidden>@@ -271,9 +272,10 @@ function loadImg(src){return new Promise(function(res){if(!src)return res(null); function drawLive(main,popups){var cv=$('liveImg');return loadImg(main.src).then(function(mi){if(!mi)return false;var r=main.rect||{};var W=r.width||mi.naturalWidth,H=r.height||mi.naturalHeight;cv.width=mi.naturalWidth;cv.height=mi.naturalHeight;var sx=cv.width/W,sy=cv.height/H;var ctx=cv.getContext('2d');ctx.drawImage(mi,0,0);return Promise.all(popups.map(function(p){return loadImg(p.src).then(function(pi){if(!pi)return;var pr=p.rect||{};var x=(pr.x!=null?pr.x:(pr.left||0))-(r.left||0),y=(pr.y!=null?pr.y:(pr.top||0))-(r.top||0),w=pr.w||pr.width||pi.naturalWidth,h=pr.h||pr.height||pi.naturalHeight;if(w<3||h<3)return;ctx.drawImage(pi,x*sx,y*sy,w*sx,h*sy);ctx.strokeStyle='rgba(0,184,177,.6)';ctx.lineWidth=2;ctx.strokeRect(x*sx,y*sy,w*sx,h*sy)})})).then(function(){return true})})} function liveTick(){if(!live.on)return;cmd('fusion_live_feed',{action:'frames',since:live.since},{timeoutMs:20000}).then(function(d){var all=d.frames||[];var fr=all.filter(function(f){return f.kind==='main'});if(d.healthy===false&&!fr.length){$('liveLed').className='live-led';$('liveNote').textContent=(d.noHwnd&&!d.captured)?'waiting for the Fusion window (Fusion is launching)':(d._honesty||'feed is not capturing');}if(fr.length){var f=fr[fr.length-1];var pops=all.filter(function(p){return p.kind==='popup'&&p.tick===f.tick});live.since=Math.max(live.since,f.tick);return frameSrc(f).then(function(src){if(!src||!live.on)return;return Promise.all(pops.map(function(p){return frameSrc(p).then(function(ps){return{src:ps,rect:p.rect,title:p.title}})})).then(function(ps){return drawLive({src:src,rect:f.rect},ps.filter(function(p){return p.src}))}).then(function(ok){if(ok&&live.on){$('liveImg').hidden=false;$('liveLed').className='live-led on';var names=pops.map(function(p){return p.title}).filter(function(t){return t&&!/MessageTray/.test(t)});$('liveTick').textContent='frame '+f.tick+' · '+(names.length?names.length+' panel(s)/dialog(s) over it':'no dialogs');$('liveNote').textContent=f.title||''}})})}}).catch(function(e){$('liveNote').textContent='feed: '+e.message}).then(function(){if(live.on)live.timer=setTimeout(liveTick,1500)})} function liveStop(why){if(!live.on)return;live.on=false;clearTimeout(live.timer);$('liveLed').className='live-led';$('liveLabel').textContent='live view ended · '+(why||'');cmd('fusion_live_feed',{action:'stop'}).catch(function(){})}-function poll(id){runId=id;clearTimeout(pollTimer);var tick=0;liveStart();(function loop(){tick++;cmd('fusion_prove_result',{runId:id}).then(function(d){var r=d.result||d;var pct=typeof r.percent==='number'?r.percent:null;if(r.stage==='failed'){liveStop('failed');setProg(100,'fail');status($('importStatus'),r.errorCode==='bridge_restarted_mid_run'?'bridge restarted mid-run':'failed','bad');renderFrames(r.evidence);if(r.errorCode==='bridge_restarted_mid_run'){showRestartedMidRun(r);return}setStage('<b>failed</b> '+esc(r.error||(r.bind&&r.bind.error)||r.errorCode||''));toast('Import failed: '+(r.error||(r.bind&&r.bind.error)||'see the log'),'err');return}if(r.stage==='done'){liveStop('done');setProg(100,'done');var pv=r.prove||{};setStage('<b>done</b> verdict: '+esc(r.verdict||(pv.proven?'proven':'not proven'))+' · '+(pv.partsProven||0)+'/'+(pv.partsTotal||r.partsTotal||0)+' parts proven · '+(r.evidenceCount||0)+' frames');status($('importStatus'),r.verdict==='proven'||pv.proven?'proven':'finished, not proven',r.verdict==='proven'||pv.proven?'ok':'warn');if(r.partMetadata)renderLint(r.partMetadata);toast('Library created in Fusion: '+(pv.partsProven||0)+'/'+(pv.partsTotal||0)+' parts proven','ok');return cmd('fusion_prove_result',{runId:id,inlineFrames:true},{timeoutMs:120000}).then(function(d2){var r2=d2.result||d2;renderFrames(r2.evidence||r.evidence)}).catch(function(){renderFrames(r.evidence)})}if(pct!==null)setProg(pct);setStage('<b>'+esc(r.stage||'working')+'</b>'+(r.stageLabel?' · '+esc(r.stageLabel):'')+(pct!==null?' · '+Math.round(pct)+'%':'')+(typeof r.etaSec==='number'?' · ~'+Math.round(r.etaSec)+'s left':''));if(r.evidence&&r.evidence.length)renderFrames(r.evidence);if(r.errorCode==='addin_restart_required'){if(!document.querySelector('[data-ask]'))showAsk(r);liveStop('waiting for your answer')}pollTimer=setTimeout(loop,tick<3?5000:10000)}).catch(function(e){var notYet=e.code==='run_not_found'||e.code==='unknown_run_id'||/no prove run/i.test(e.message||'');var transient=notYet||/error sending request|unreachable|HTTP 0|HTTP 5\d\d|timed out|main_thread_busy/i.test(e.message||'');if(transient&&tick<12){setStage('<b>'+(notYet?'starting (the run is not on file yet)':'bridge not answering, retrying')+'</b> · '+esc((e.message||'').slice(0,90))+' (try '+tick+' of 12)');pollTimer=setTimeout(loop,8000);return}liveStop('poll failed');setProg(100,'fail');setStage('<b>poll failed</b> '+esc(e.message));status($('importStatus'),'poll failed','bad')})})()}+function poll(id){runId=id;clearTimeout(pollTimer);var tick=0;liveStart();$('btnCancel').hidden=false;$('btnCancel').disabled=false;$('btnCancel').textContent='Cancel import';(function loop(){tick++;cmd('fusion_prove_result',{runId:id}).then(function(d){var r=d.result||d;var pct=typeof r.percent==='number'?r.percent:null;if(r.stage==='cancelled'){$('btnCancel').hidden=true;liveStop('cancelled');setProg(100,'fail');setStage('<b>cancelled</b> · '+esc(r.error||('stopped while '+(r.cancelledAt||''))));status($('importStatus'),'cancelled','warn');renderFrames(r.evidence);toast('Import cancelled: '+esc(r.error||''),'ok');return}if(r.stage==='done'||r.stage==='failed')$('btnCancel').hidden=true;if(r.cancelRequested&&r.stage!=='cancelled'){$('btnCancel').disabled=true;$('btnCancel').textContent='stopping at the next part...'}if(r.stage==='failed'){liveStop('failed');setProg(100,'fail');status($('importStatus'),r.errorCode==='bridge_restarted_mid_run'?'bridge restarted mid-run':'failed','bad');renderFrames(r.evidence);if(r.errorCode==='bridge_restarted_mid_run'){showRestartedMidRun(r);return}setStage('<b>failed</b> '+esc(r.error||(r.bind&&r.bind.error)||r.errorCode||''));toast('Import failed: '+(r.error||(r.bind&&r.bind.error)||'see the log'),'err');return}if(r.stage==='done'){liveStop('done');setProg(100,'done');var pv=r.prove||{};setStage('<b>done</b> verdict: '+esc(r.verdict||(pv.proven?'proven':'not proven'))+' · '+(pv.partsProven||0)+'/'+(pv.partsTotal||r.partsTotal||0)+' parts proven · '+(r.evidenceCount||0)+' frames');status($('importStatus'),r.verdict==='proven'||pv.proven?'proven':'finished, not proven',r.verdict==='proven'||pv.proven?'ok':'warn');if(r.partMetadata)renderLint(r.partMetadata);toast('Library created in Fusion: '+(pv.partsProven||0)+'/'+(pv.partsTotal||0)+' parts proven','ok');return cmd('fusion_prove_result',{runId:id,inlineFrames:true},{timeoutMs:120000}).then(function(d2){var r2=d2.result||d2;renderFrames(r2.evidence||r.evidence)}).catch(function(){renderFrames(r.evidence)})}if(pct!==null)setProg(pct);setStage('<b>'+esc(r.stage||'working')+'</b>'+(r.stageLabel?' · '+esc(r.stageLabel):'')+(pct!==null?' · '+Math.round(pct)+'%':'')+(typeof r.etaSec==='number'?' · ~'+Math.round(r.etaSec)+'s left':''));if(r.evidence&&r.evidence.length)renderFrames(r.evidence);if(r.errorCode==='addin_restart_required'){if(!document.querySelector('[data-ask]'))showAsk(r);liveStop('waiting for your answer')}pollTimer=setTimeout(loop,tick<3?5000:10000)}).catch(function(e){var notYet=e.code==='run_not_found'||e.code==='unknown_run_id'||/no prove run/i.test(e.message||'');var transient=notYet||/error sending request|unreachable|HTTP 0|HTTP 5\d\d|timed out|main_thread_busy/i.test(e.message||'');if(transient&&tick<12){setStage('<b>'+(notYet?'starting (the run is not on file yet)':'bridge not answering, retrying')+'</b> · '+esc((e.message||'').slice(0,90))+' (try '+tick+' of 12)');pollTimer=setTimeout(loop,8000);return}liveStop('poll failed');setProg(100,'fail');setStage('<b>poll failed</b> '+esc(e.message));status($('importStatus'),'poll failed','bad')})})()} function showRestartedMidRun(r){$('stage').innerHTML='<b>the bridge restarted under this run</b> '+esc(r.error||'')+' <button class="btn" data-action="start_again" style="min-height:32px;padding:4px 10px;margin-left:8px">Start again</button>';$('stage').querySelector('[data-action=start_again]').onclick=importBundle;toast('The bridge restarted mid-run (an update or a crash). Press Start again.','err')} function importBundle(){if(!part)return;probe().then(function(b){if(b)bridge=b});var b=$('btnImport');b.disabled=true;status($('importStatus'),'working');$('gallery').innerHTML='';$('receipt').innerHTML='';$('lint').innerHTML='';setProg(0);var ps=part.parts||[part];setStage('fetching '+ps.length+' librar'+(ps.length===1?'y':'ies')+(part.steps.length?' and '+part.steps.length+' model(s)':'')+' from the wiki...');var lbrFetches=ps.map(function(p){return fetch(p.lbr.url).then(function(r){return r.text()})});var stepFetches=part.steps.map(function(s){return fetch(s.url).then(function(r){return r.arrayBuffer()})});Promise.all([Promise.all(lbrFetches),Promise.all(stepFetches)]).then(function(res){var texts=res[0],bufs=res[1];setStage((ps.length>1?'merging '+ps.length+' libraries into one and ':'')+'zipping in the browser...');var fills=[];texts=texts.map(function(t,i){return fillPartMeta(t,ps[i],fills)});part.metaFills=fills;var merged=mergeLbrs(texts);var name=part.name.replace(/[^A-Za-z0-9._-]+/g,'_');var entries=[{name:name+'.lbr',data:new TextEncoder().encode(merged).buffer}].concat(part.steps.map(function(s,i){return{name:s.name,data:bufs[i]}}));var zip=makeZip(entries);return stage('~/adom-wiki-libs/'+name+'.zip',zip,'staging the bundle on this machine (approve in Adom Bridge)').then(function(p){staged=p;setStage('installing the bundle (one call)...');return cmd('fusion_install_library_bundle',{zipPath:p,libraryName:name})})}).then(function(d){renderReceipt(d);if(d.errorCode==='nothing_to_bind'||d.started===false){setProg(100,'done');setStage('<b>installed, unbound</b> · '+esc(d._hint||'nothing to bind'));status($('importStatus'),'installed, unbound','warn');toast('Library placed at '+d.libraryPath+' with no 3D bound','ok');b.disabled=false;return}if(d.attached)toast('An import is already running; attached to '+d.runId);else toast('Installed and started run '+d.runId,'ok');poll(d.runId)}).catch(function(e){setProg(100,'fail');setStage('<b>'+esc(e.code||'failed')+'</b> '+esc(e.message));status($('importStatus'),'failed','bad');if(e.payload&&e.payload.errorCode==='addin_restart_required')showAsk(e.payload);if(e.code==='web_denied'){setStage('<b>not allowed</b> Adom Bridge asked you to allow this page and did not get a click in time. Press the button again and click Allow (1 hr) in the Adom Bridge toast, or grant this page in ab Settings > Web Control.');}toast((e.code==='web_denied'?'Adom Bridge did not get your Allow click. ':'')+e.message,'err')}).finally(function(){b.disabled=false})}+function cancelRun(){if(!runId){toast('No run to cancel','err');return}var b=$('btnCancel');b.disabled=true;b.textContent='stopping at the next part...';cmd('fusion_cancel_run',{runId:runId}).then(function(d){var r=d.result||d;if(r.alreadyFinished){toast('That run had already ended ('+r.stage+')');b.hidden=true;return}toast('Stopping the import at the next part boundary (a STEP import in progress finishes first)')}).catch(function(e){b.disabled=false;b.textContent='Cancel import';toast(e.message,'err')})} function attachRun(){cmd('fusion_prove_result',{}).then(function(d){var r=d.result||d;if(!r.runId){toast('No run to attach to','err');return}toast('Attached to run '+r.runId);poll(r.runId)}).catch(function(e){toast(e.message,'err')})}  // ---- the restart ask: always a choice --------------------------------------------------@@ -292,7 +294,7 @@ function renderCatalog(){var f=$('catFilter').value.toLowerCase();$('catalog').i  // ---- wire up --------------------------------------------------------------------------------- $('btnConnect').onclick=connect;$('btnResolve').onclick=function(){delete $('partRef').dataset.multiStart;resolvePart()};$('tglNoModel').onclick=function(){skipModel=!skipModel;this.className='toggle'+(skipModel?' active':'');this.setAttribute('aria-checked',String(skipModel));if(part)resolvePart()};$('btnShuffle').onclick=shuffle;$('partRef').addEventListener('keydown',function(e){if(e.key==='Enter')resolvePart()});-$('btnFusion').onclick=fusionState;$('btnPlan').onclick=plan;$('btnImport').onclick=importBundle;$('btnAttach').onclick=attachRun;+$('btnFusion').onclick=fusionState;$('btnPlan').onclick=plan;$('btnImport').onclick=importBundle;$('btnAttach').onclick=attachRun;$('btnCancel').onclick=cancelRun; $('btnFeed').onclick=function(){feed('status')};$('btnFeedStart').onclick=function(){feed('start').then(function(){liveStart()})};$('btnFeedStop').onclick=function(){liveStop('stopped by you');feed('stop')}; $('btnBannerShow').onclick=function(){banner(true)};$('btnBannerHide').onclick=function(){banner(false)}; $('btnDescribe').onclick=describe;$('catFilter').addEventListener('input',renderCatalog);
describe.py+4
@@ -255,6 +255,10 @@ _T = [      {"runId": "optional str (omit for the latest run)", "inlineFrames": "optional bool", "shots": "optional int", "sinceShot": "optional int"},      {"stage": "str", "verdict": "str", "evidence": "[]", "percent": "float", "etaSec": "float"},      20, None, True, {"runId": "a3f1c2d4e5b6"}),+    ("fusion_cancel_run", "STOP a library import that is in flight (fusion_install_library_bundle / fusion_create_library) - the user's escape hatch from a long run, wired to a Cancel button in web control. Cooperative, not a kill: the run finishes the part it is on (a STEP import cannot be interrupted mid-command without leaving a half-made design, up to ~60s), keeps every 3D package already minted and binds them into the library, closes its scratch documents, and leaves the library open in Fusion (not saved to the cloud). The run then reports stage:cancelled, verdict:cancelled, errorCode cancelled_by_user, cancelledAt (which stage) and how many parts were done, on the same fusion_prove_result poll the caller already makes; cancelRequested:true shows on the very next poll before the boundary is reached. Omit runId to cancel the latest run still in flight. Idempotent; a run that already ended answers alreadyFinished.",+     {"runId": "optional str (omit for the latest run in flight)"},+     {"cancelRequested": "bool", "runId": "str", "stage": "str at the time of the request", "statusVerb": "fusion_prove_result"},+     10, "fusion_prove_result", True, {"runId": "a3f1c2d4e5b6"}),     ("fusion_addin_status", "Is the AdomBridge add-in loaded and answering inside Fusion? Reports addinReady, port and version, plus busy when a long command holds the main thread. If Fusion is running but this says not ready, the add-in was installed while Fusion was open - Fusion loads add-ins only at launch - so call fusion_restart_for_addin (ask the user first when one is present).",      {}, {"addinReady": "bool", "port": "int", "version": "str", "busy": "bool"}, 15, None, True, {}),     ("fusion_activate_document", "Make a named open document the ACTIVE one before driving it. EDIT and most modelling verbs act on whatever is active, so in a batch with several documents open this is how you stop a verb landing in the wrong file (page issue #47). Prefer passing expectDocument on the verb itself, which fails closed instead of merely re-targeting.",
docs/MULTIPART_LIBRARY_IMPORT.md+2
@@ -146,6 +146,8 @@ import, it bubbles up so someone can fix the data upstream. | Fusion open, add-in silent | the chain **asks** (toast + `addin_restart_required` in the run) and waits; "Save and restart" continues the same run, "I'll handle it" stops it with `user_owns_restart` | | The bridge restarts mid-run | `bridge_restarted_mid_run`, honest and terminal; scratch documents are swept before the next run | | Same library name already in `Adom Libraries` | reported in `cloudLibrary.olderSameName`; retired only with `replaceExisting:true` |+| Several devicesets on one package (symbol variants, value clones) | one mint per package; every device on it gets the urn; listed in `shared[]`, not `unbound[]` |+| The user presses Cancel (`fusion_cancel_run {runId}`) | the run stops at its next part boundary: parts already minted keep their 3D and are bound, scratch is closed, the library stays open (not saved to the cloud); `stage: cancelled`, `cancelledAt` says which stage |  ## Skills 
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.9.272"+DASH_VERSION="1.9.273" 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.9.272",+  "version": "1.9.273",   "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+11−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.272",+  "version": "1.9.273",   "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": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,9 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
server.py+110−1
@@ -2996,6 +2996,16 @@ _HUNG_SINCE = [0.0]  # first time is_fusion_hung() reported hung; 0 = not curren # While this registry shows an op in flight (younger than the 900s ceiling), a non-pumping # main thread is EXPECTED and _confirmed_hung must answer not-hung. _LONG_OP = {"name": None, "since": 0.0}+# CANCEL A RUN (John, 2026-08-29: "you need a way to cancel a large run. that's a bad+# experience. a user will need cancel from the web controls"). A 31-part import had no way+# out short of killing the bridge. fusion_cancel_run marks the run here; the bind loop and+# the prove loop check between parts and stop at the next part boundary, keeping every urn+# already minted, closing scratch, and leaving the library open. runId -> {at, by}.+_CANCEL_REQUESTS = {}+++def _cancel_requested(run_id: str):+    return _CANCEL_REQUESTS.get(str(run_id or "")) if run_id else None   def _confirmed_hung() -> dict:@@ -4519,9 +4529,15 @@ def _build_library_3d_core(args: dict) -> dict:     # straight to the cloud through the add-in and only needs APS as its upload fallback     # (adom/adom-bridge#93). folder_id=None flows down and the mint gates itself.     results = []; bindings = []; shots = []+    cancelled = None     for p in parts:         pkg = p.get("package")         per_lbr = (p.get("lbrPath") or "").replace("\\", "/")+        cancelled = _cancel_requested(args.get("_cancelKey"))+        if cancelled:+            # Stop HERE, at a part boundary: the parts already minted keep their urns and+            # are bound below; nothing half-done is left in Fusion.+            break         # CLEAN UP PER PART, NOT AT THE END (John, 2026-08-29: "be better about clean up as you         # progress"). Snapshot the open documents now; everything this part opens (its         # generator Untitled, its <pkg>_3d design, its temporary library) is closed the moment@@ -4595,6 +4611,7 @@ def _build_library_3d_core(args: dict) -> dict:     n_ok = sum(1 for r in results if r.get("success"))     return {         "success": n_ok > 0,+        "cancelled": bool(cancelled),         "boundLbr": bound,         "partsBound": n_ok,         "partsTotal": len(parts),@@ -12225,6 +12242,84 @@ COMMAND_HANDLERS["import_kicad"] = _handle_import_kicad _PROVE_LAST = [None]  +def _mark_cancelled(state: dict, run_id: str, at_stage: str, detail: str):+    """Terminal state for a cancelled run: honest about what finished before the stop."""+    _req = _cancel_requested(run_id) or {}+    state.update(stage="cancelled", success=False, verdict="cancelled",+                 errorCode="cancelled_by_user", cancelRequested=True,+                 cancelledAt=at_stage, cancelledBy=_req.get("by") or "user",+                 error="Cancelled by %s while %s. %s" % (_req.get("by") or "the user", at_stage, detail),+                 stageLabel="cancelled while %s" % at_stage)+    try:+        _banner("update", title="Import cancelled", detail=detail[:120], percent=None,+                eta_sec=0, tone="failed")+        import threading as _bt+        _bt.Timer(20.0, lambda: _banner("hide")).start()+    except Exception:+        pass+++def _handle_cancel_run(fusion_info: dict, args: dict) -> dict:+    """Stop a library import at its next part boundary (John, 2026-08-29).++    Cancel is cooperative, not a kill: the bind loop finishes the part it is on (a STEP+    import cannot be interrupted mid-command without leaving a half-made design), keeps+    every urn already minted, binds them into the library, closes the scratch documents and+    leaves the library open. The run ends stage:cancelled / verdict:cancelled with+    cancelledAt telling which stage and how many parts were done. Idempotent.+    """+    import tempfile as _tf+    import time as _ct+    _rid = str(args.get("runId") or "").strip()+    _runs = {}+    try:+        _runs = json.loads(open(os.path.join(_tf.gettempdir(), "fusion-prove-runs.json"),+                                encoding="utf-8").read())+    except Exception:+        _runs = {}+    _terminal = ("done", "failed", "cancelled")+    if not _rid:+        # the latest run still in flight (single-caller convenience, same rule as prove_result)+        _live = [r for r in _runs.values() if r.get("stage") not in _terminal]+        if _live:+            _rid = sorted(_live, key=lambda r: r.get("startedAt") or 0)[-1].get("runId") or ""+    if not _rid:+        return {"success": False, "errorCode": "no_run_in_flight",+                "error": "No import is running on this box, so there is nothing to cancel.",+                "_hint": "fusion_prove_result {} shows the latest run and its stage."}+    _st = _runs.get(_rid) or (dict(_PROVE_LAST[0]) if _PROVE_LAST[0] and _PROVE_LAST[0].get("runId") == _rid else None)+    if _st is None:+        return {"success": False, "errorCode": "unknown_run_id",+                "error": "no run with runId %r on this box" % _rid}+    if _st.get("stage") in _terminal:+        return {"success": True, "runId": _rid, "alreadyFinished": True, "stage": _st.get("stage"),+                "_hint": "That run had already ended (%s); nothing to cancel." % _st.get("stage")}+    _by = str(((args.get("caller") or {}).get("aiThread")) or args.get("by") or "user")[:60]+    _CANCEL_REQUESTS[_rid] = {"at": _ct.time(), "by": _by, "stage": _st.get("stage")}+    # let pollers see the request on their very next tick, before the chain reaches a boundary+    try:+        _st["cancelRequested"] = True+        _st["stageLabel"] = "stopping at the next part boundary (%s)" % (_st.get("stageLabel") or _st.get("stage") or "")+        _runs[_rid] = _st+        open(os.path.join(_tf.gettempdir(), "fusion-prove-runs.json"), "w",+             encoding="utf-8").write(json.dumps(_runs))+        if _PROVE_LAST[0] and _PROVE_LAST[0].get("runId") == _rid:+            _PROVE_LAST[0]["cancelRequested"] = True+    except Exception:+        pass+    try:+        _banner("update", title="Stopping the import", detail="Finishing the current part, then stopping",+                percent=None, eta_sec=None, tone="working")+    except Exception:+        pass+    return {"success": True, "runId": _rid, "cancelRequested": True, "stage": _st.get("stage"),+            "statusVerb": "fusion_prove_result",+            "_hint": "The run stops at its next part boundary (a STEP import in progress finishes "+                     "first, up to ~60s). Poll fusion_prove_result {runId}: stage becomes 'cancelled' "+                     "with cancelledAt and what was completed. Parts already bound keep their 3D; "+                     "the library stays open in Fusion, unsaved to the cloud."}++ def _handle_prove_result(fusion_info: dict, args: dict) -> dict:     """Fetch the LAST completed fusion_prove_library run. @@ -12360,6 +12455,7 @@ def _handle_prove_result(fusion_info: dict, args: dict) -> dict:   COMMAND_HANDLERS["prove_result"] = _handle_prove_result+COMMAND_HANDLERS["cancel_run"] = _handle_cancel_run   @@ -12812,7 +12908,8 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:                 for _bt_try in range(40):                     bind = dispatch_command("build_library_3d",                                             {"lbrPath": target_lbr, "parts": _parts,-                                             "outLbrPath": target_lbr, "async": False})+                                             "outLbrPath": target_lbr, "async": False,+                                             "_cancelKey": run_id})                     _berr = str(bind.get("error") or "") + str(bind.get("errorCode") or "")                     if bind.get("success") or "main thread busy" not in _berr.lower() \                             and "main_thread_busy" not in _berr:@@ -12822,6 +12919,12 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:                                  ("success", "error", "wip_urn", "reopened",                                   "reopenError")}                 state["bind"]["parts"] = bind.get("results") or bind.get("parts")+                state["bind"]["partsBound"] = bind.get("partsBound")+                if bind.get("cancelled") or _cancel_requested(run_id):+                    _mark_cancelled(state, run_id, "binding 3D",+                                    "%s of %s parts had their 3D bound before the stop"+                                    % (bind.get("partsBound") or 0, len(_parts)))+                    _save(); return                 if not bind.get("success"):                     # NEVER surface a failure with error:null (#49, John 2026-08-23). If the                     # bind handler somehow returns no reason, synthesize one from whatever it@@ -12890,6 +12993,12 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict:             state["provedParts"] = []             _prove_results, _all_ok = [], True             for _pi, _dev in enumerate(_targets, 1):+                if _cancel_requested(run_id):+                    state["provedParts"] = _prove_results+                    _mark_cancelled(state, run_id, "proving",+                                    "%d of %d parts proved before the stop; the 3D is bound "+                                    "for every part" % (_pi - 1, len(_targets)))+                    _save(); return                 state["stageLabel"] = ("proving %s (part %d of %d)"                                        % (_dev or "library", _pi, len(_targets)))                 state["partIndex"] = _pi
skills/fusion-web-control/SKILL.md+9
@@ -136,6 +136,15 @@ assembly order: **symbol → footprint → 3d → ganged**. The ganged view is l the closing proof. A 3D build additionally reports `minting <part> (3/7)`, `partsDone`, `partsTotal` and `parts[]` with each `wip_urn`. +**A user can stop a run.** Render a Cancel button while you poll and wire it to+`fusion_cancel_run {runId}` (bridge 1.9.273+). It is cooperative: the run finishes the part+it is on (a STEP import in progress cannot be interrupted, up to ~60s), keeps every 3D+package already minted and binds them, closes its scratch, and leaves the library open in+Fusion (not saved to the cloud). Your next poll shows `cancelRequested: true`, then+`stage: "cancelled"`, `verdict: "cancelled"`, `cancelledAt` (which stage) and how many parts+were done. Never kill the bridge to stop a run: that is `bridge_restarted_mid_run`, and it+leaves the scratch documents and an unsaved library behind.+ **`stage: "done"` does NOT mean it worked.** It means the chain finished. The score is the top-level `verdict` (`proven` / `not_proven`). For a multi-part build, `done` means the run completed, not that every part bound — **check `parts[]` for any `success: false`** before