app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
← Commit history
dashboard 1.9.76
7 files changed
+158−25
CHANGELOG.md+20@@ -3,6 +3,26 @@ 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.9.76 - 2026-08-18 (ralph, dashboard)++- **"Fusion is opening right now" LED (John's ask).** A launch takes 30-90s and the board used to+ show a bare red dot the whole time, so the user could not tell whether anything was happening.+ New pulsing-amber `starting` state, marked SYNCHRONOUSLY when the launch is requested (so the+ very next status already says it, no red flash), with the sentence "Opening Fusion right now -+ launching in the background and waiting for its bridge add-in."+- **A failed launch explains itself** instead of silently reverting to red: the LED carries why+ (add-in never came up / the attempt did not take - seat busy elsewhere or a licensing dialog).+- **The tour finally SHOWS its screenshots.** The dashboard only counted them in a toast; it now+ renders a live gallery, streaming each step's capture as it completes via the bridge's+ stepShot + sinceShot contract (the same one built for ab), click to open full size.+- **Close Fusion auto-escalates and verifies.** When the graceful close is blocked ONLY by the+ bridge's own generated test docs (bridgeDocsOnly), it force-closes for the user, then confirms+ by readiness rather than trusting fusion_kill's flag (which reports failure on closes that+ actually worked).+- **Import part survives a busy add-in**: the open is retried for ~2.5 minutes when another+ session's long verb holds the main thread, instead of returning a rejection.+- **`force:true` now really forces** an auto-green run (the debounce was still swallowing it).+ ## 1.9.74 - 2026-08-18 (ralph, dashboard) - **The board no longer lies while the bridge is busy.** Driving the guided tour from the UI
bin/fusion-dashboard⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.74"+version = "1.9.76" edition = "2021" [dependencies]
dashboard/src/main.rs+101−10@@ -79,6 +79,10 @@ fn default_target() -> String { /// The LED payload: one call per facet, each with state + an AI hint on how to act. fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {+ gather_status_ex(ai_thread, target, false)+}++fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_json::Value { // PARALLEL, not sequential: each verb is a relay round-trip, and six of them in series took // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole // time. Fan them out and the wall clock is the slowest single call.@@ -124,7 +128,12 @@ fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value { "bridgeVersion": rd["bridgeVersion"], "leds": { "fusionRunning": {"on": running, "label": "Fusion running",- "_hint": if running {"Fusion is up."} else {"fusion_start launches it in the BACKGROUND (never steals focus)."}},+ // A launch takes ~30-90s. Saying nothing during it (or showing red) leaves the+ // user wondering whether anything is happening at all - so say it plainly.+ "state": if running {"on"} else if launching {"starting"} else {"off"},+ "_hint": if running {"Fusion is up.".to_string()}+ else if launching {"Opening Fusion right now - launching in the background and waiting for its bridge add-in. This usually takes under a minute.".to_string()}+ else {"fusion_start launches it in the BACKGROUND (never steals focus).".to_string()}}, "addinReady": {"on": ready, "label": "Bridge add-in", "version": addin, // BUSY is not BROKEN (ralph, 2026-08-18): while a long verb runs - a tour, a 3D // build - readiness answers not-ready because the add-in's main thread is@@ -171,7 +180,25 @@ fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) - let a = args.to_string(); match name { "start" => bridge(ai_thread, target, "fusion_start", "{}"),- "stop" => bridge(ai_thread, target, "fusion_stop", "{\"reason\":\"dashboard: user clicked Close Fusion\"}"),+ "stop" => {+ let r = unwrap_data(&bridge(ai_thread, target, "fusion_stop",+ "{\"reason\":\"dashboard: user clicked Close Fusion\"}"));+ if r["success"].as_bool() == Some(true) { return r; }+ // DOCTRINE: when the ONLY things blocking the close are the bridge's own throwaway+ // test docs, escalate for the user instead of handing them a failure to resolve.+ if r["bridgeDocsOnly"].as_bool() == Some(true) {+ let _k = unwrap_data(&bridge(ai_thread, target, "fusion_kill", "{}"));+ // VERIFY the outcome, do not trust the verb's flag: fusion_kill reports+ // success:false in shapes where Fusion did in fact close (ralph, 2026-08-18).+ std::thread::sleep(Duration::from_secs(4));+ let gone = !unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"))["running"]+ .as_bool().unwrap_or(false);+ return serde_json::json!({"success": gone,+ "escalated": true, "gracefulError": r["error"].clone(),+ "_hint": "The graceful close was blocked only by the bridge's own generated test documents, so it was force-closed for you - nothing of yours was at risk."});+ }+ r+ } "signin" => bridge(ai_thread, target, "fusion_signin", &a), "tour" => bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}"), "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a),@@ -210,8 +237,21 @@ fn import_basic_part(ai_thread: &str, target: &str, args: &serde_json::Value) -> let send = bridge(ai_thread, target, "send_files", &serde_json::json!({"filePaths": [lbr.to_string_lossy()], "dest": dest}).to_string()); let name = lbr.file_name().unwrap().to_string_lossy().to_string();- let open = bridge(ai_thread, target, "fusion_open_lbr",+ // BUSY-RETRY (ralph, 2026-08-18): the add-in serializes on its main thread, so an open+ // issued while another session's long verb is running is REJECTED outright. Waiting is the+ // whole fix - never hand that back to the user as a failure.+ let mut open = bridge(ai_thread, target, "fusion_open_lbr", &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());+ for _ in 0..10 {+ let o = unwrap_data(&open);+ let err = o["error"].as_str().unwrap_or("").to_lowercase();+ let busy = o["errorCode"].as_str() == Some("main_thread_busy")+ || err.contains("main thread busy") || err.contains("in progress from another");+ if !busy { break; }+ std::thread::sleep(Duration::from_secs(15));+ open = bridge(ai_thread, target, "fusion_open_lbr",+ &serde_json::json!({"filePath": format!("{}/{}", dest, name)}).to_string());+ } serde_json::json!({"page": page, "sent": unwrap_data(&send), "opened": unwrap_data(&open), "_hint": "Wiki part -> Fusion Library Manager, all background. Next steps an AI can run: fusion_show_symbol / fusion_show_footprint {name}, fusion_bind_library_3d for the 3D, fusion_make_test_project for a solo demo board. Render any of them with fusion_state."}) }@@ -256,6 +296,12 @@ struct App { // calls time out and every field reads false - which used to paint the whole board RED // while the tour was visibly running (ralph, 2026-08-18). Unknowable is not broken. last_good: Mutex<std::collections::HashMap<String, serde_json::Value>>,+ // Boxes we are actively launching Fusion on, so the LED can say "opening right now"+ // instead of leaving the user guessing (John, 2026-08-18).+ launching: Mutex<std::collections::HashMap<String, Instant>>,+ // Why the last launch attempt ended, so a failed launch explains itself on the LED instead+ // of silently reverting to a bare red "off" (ralph, 2026-08-18).+ last_launch_note: Mutex<std::collections::HashMap<String, String>>, } // ── Dashboard-own settings (container-side; bridge prefs stay in the bridge) ──────────────@@ -308,16 +354,31 @@ impl App { // step it can do WITHOUT the human, narrates each one as a toast on the live channel, and // stops honestly at any wall that genuinely needs a person (a password, an unconfigured APS // client id) rather than pretending. All of it background - never steals focus.-fn auto_green(app: &Arc<App>, target: &str) -> bool {+fn auto_green_forced(app: &Arc<App>, target: &str, force: bool) -> bool { { // single-flight + 3-minute debounce, PER TARGET: several tabs/reloads must not stack // launches on the SAME box, but a run for one box must never block another. let mut running = app.auto_green_running.lock().unwrap();- if running.contains(target) { return false; }- if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {- if t.elapsed() < Duration::from_secs(180) { return false; }+ if running.contains(target) { return false; } // a run is genuinely in flight+ if !force {+ if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {+ if t.elapsed() < Duration::from_secs(180) { return false; }+ } } running.insert(target.to_string()); }+ // Mark "launching" SYNCHRONOUSLY, before the worker thread starts: the endpoint returns+ // immediately and the page's very next status must already say "opening Fusion", not "off"+ // (ralph, 2026-08-18 - a 3-second window where the user saw a red LED after clicking).+ {+ let ai0 = app.ai_thread.clone();+ let t0 = target.to_string();+ let running_now = unwrap_data(&bridge(&ai0, &t0, "fusion_readiness", "{}"))["running"]+ .as_bool().unwrap_or(false);+ if !running_now {+ app.launching.lock().unwrap().insert(t0.clone(), Instant::now());+ app.last_launch_note.lock().unwrap().remove(&t0);+ }+ } let t = target.to_string(); let ai = app.ai_thread.clone(); let app2 = app.clone();@@ -341,6 +402,8 @@ fn auto_green(app: &Arc<App>, target: &str) -> bool { // 1. Fusion itself. if !led(&st, "fusionRunning") {+ app2.launching.lock().unwrap().insert(t.clone(), Instant::now());+ app2.hub.broadcast("state", &gather_status_ex(&ai, &t, true)); bridge(&ai, &t, "fusion_start", "{}"); for _ in 0..30 { std::thread::sleep(Duration::from_secs(10));@@ -350,7 +413,19 @@ fn auto_green(app: &Arc<App>, target: &str) -> bool { if led(&st, "fusionRunning") && st["leds"]["signedIn"]["on"].as_bool() == Some(false) && st["leds"]["signedIn"]["state"].as_str() == Some("off") { break; } }- if led(&st, "addinReady") { toast("Fusion is up and the bridge add-in is serving", "ok"); }+ app2.launching.lock().unwrap().remove(&t);+ if led(&st, "addinReady") {+ app2.last_launch_note.lock().unwrap().remove(&t);+ toast("Fusion is up and the bridge add-in is serving", "ok");+ } else {+ let why = if led(&st, "fusionRunning") {+ "Fusion launched but its bridge add-in never came up - press Start Fusion to retry, or check for a sign-in wall."+ } else {+ "The launch attempt did not take (Autodesk seat busy elsewhere, or a licensing dialog). Press Start Fusion to try again."+ };+ app2.last_launch_note.lock().unwrap().insert(t.clone(), why.to_string());+ toast(why, "err");+ } } // 2. Fusion is up but the add-in is not serving. TWO causes, both fixable without@@ -450,10 +525,12 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { std::thread::sleep(Duration::from_secs(4)); bridge(&ai, target, "fusion_kill", "{}"); std::thread::sleep(Duration::from_secs(3));+ app.launching.lock().unwrap().insert(target.to_string(), Instant::now()); bridge(&ai, target, "fusion_start", "{}"); } } if is_ready() {+ app.launching.lock().unwrap().remove(target); app.hub.broadcast("toast", &serde_json::json!({"message": "Fusion is ready - continuing", "kind": "ok"})); app.hub.broadcast("state", &gather_status(&ai, target)); return true;@@ -467,6 +544,8 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { false } +fn auto_green(app: &Arc<App>, target: &str) -> bool { auto_green_forced(app, target, false) }+ /// The doctrine entry point: make the box ready, never tell the user to do it. fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { let ai = app.ai_thread.clone();@@ -744,7 +823,17 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { ("GET", "/api/status") | ("GET", "/state") => { let t = qs_target.clone().unwrap_or_else(|| app.tgt()); app.log(format!("status target={}", t));- let mut st = gather_status(&app.ai_thread, &t);+ let launching = {+ let mut g = app.launching.lock().unwrap();+ g.retain(|_, v| v.elapsed() < Duration::from_secs(240)); // self-expiring+ g.contains_key(&t)+ };+ let mut st = gather_status_ex(&app.ai_thread, &t, launching);+ if !launching && st["leds"]["fusionRunning"]["on"].as_bool() == Some(false) {+ if let Some(note) = app.last_launch_note.lock().unwrap().get(&t) {+ st["leds"]["fusionRunning"]["_hint"] = serde_json::json!(note);+ }+ } if st["answered"].as_bool() == Some(true) { app.last_good.lock().unwrap().insert(t.clone(), st.clone()); } else if let Some(prev) = app.last_good.lock().unwrap().get(&t).cloned() {@@ -803,7 +892,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { let t = qs_target.clone().unwrap_or_else(|| app.tgt()); if read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true) || body["force"].as_bool().unwrap_or(false) {- let started = auto_green(app, &t);+ let started = auto_green_forced(app, &t, body["force"].as_bool().unwrap_or(false)); respond_json(&mut stream, &serde_json::json!({"ok": true, "started": started, "target": t, "_hint": if started {"Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."} else {"A run for THIS box is already in flight (or finished in the last 3 minutes) - joined it instead of stacking a second launch."}}));@@ -878,6 +967,8 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) { auto_green_last: Mutex::new(std::collections::HashMap::new()), staged: Mutex::new(std::collections::HashMap::new()), last_good: Mutex::new(std::collections::HashMap::new()),+ launching: Mutex::new(std::collections::HashMap::new()),+ last_launch_note: Mutex::new(std::collections::HashMap::new()), }); if print_url { // Hydrogen dock await:url contract - the proxied URL a webview can reach.
dashboard/ui/index.html+24−5@@ -44,6 +44,8 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma .led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)} .led.off{background:var(--red)} .led.warn{background:var(--yellow)}+.led.starting{background:var(--yellow);animation:ledpulse 1.1s ease-in-out infinite}+@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(210,153,34,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(210,153,34,.35)}} .led-label{font-weight:500;color:var(--text)} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px} .btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}@@ -172,6 +174,7 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} <div class="btnrow"><button class="btn primary" id="btnTour" data-tip="Walk every surface of a sample part - library, symbol, footprint, 3D chip, and a generated solo board in 2D and 3D - with a live progress bar">Run the tour</button></div> <div class="bar" id="tourBar"><div></div></div> <div class="barlabel" id="tourLabel"></div>+ <div class="sgrid" id="tourGallery" style="margin-top:12px"></div> </div> <div class="card" style="margin-top:12px"> <div class="led-label">Import a wiki part into Fusion</div>@@ -296,7 +299,7 @@ function renderLeds(st){ const keys=LED_ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!LED_ORDER.includes(k))); for(const key of keys){ const l=leds[key];- const cls=l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off'));+ const cls=l.state==='starting'?'starting':(l.pending?'warn':((l.state==='dormant'||l.state==='busy')?'warn':(l.on?'on':'off'))); const el=document.createElement('div');el.className='card'; el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label}</span></div><div class="led-sub">${l._hint||''}</div>`; g.appendChild(el);@@ -317,12 +320,28 @@ $('#btnTour').onclick=async()=>{ const s=await act('tour');const d=s.data||s;const id=d.demoId; if(!id){toast('tour failed to start: '+(d.error||'unknown'),'err');return;} $('#tourBar').style.display='block';+ const gal=$('#tourGallery');gal.innerHTML='';+ let since=0, shown=0;+ const addShot=(label,b64)=>{+ const c=document.createElement('div');c.className='scard';+ c.innerHTML='<div class="sstate ok" style="margin:0 0 6px">'+(label||'step')+'</div>'++ '<img class="sshot show" alt="'+(label||'')+'" src="data:image/png;base64,'+b64+'">';+ c.querySelector('img').onclick=e=>window.open(e.target.src,'_blank');+ gal.appendChild(c);shown++;+ }; const poll=setInterval(async()=>{- const p=await act('tour_progress',{demoId:id,imagesAs:'paths'});const q=p.data||p;+ // Stream the EVIDENCE as it happens: stepShot carries one ~640px capture per completed+ // step, and sinceShot windows the gallery array so nothing is re-sent (bridge contract).+ const p=await act('tour_progress',{demoId:id,imagesAs:'base64',sinceShot:since});const q=p.data||p; $('#tourBar>div').style.width=(q.percent||0)+'%';- $('#tourLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');- if(q.done){clearInterval(poll);toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+(q.shots||[]).length+' views captured',q.failed?'err':'ok');}- },2000);+ $('#tourLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'')+(shown?(' - '+shown+' views captured'):'');+ if(q.stepShot&&q.stepShot.image)addShot(q.stepShot.step||q.stepShot.title,q.stepShot.image);+ else (q.shots||[]).forEach(s=>{if(s.image)addShot(s.label||s.title,s.image);});+ if(typeof q.shotCount==='number')since=q.shotCount;+ if(q.done){clearInterval(poll);+ $('#tourLabel').textContent=(q.failed?'tour stopped: '+(q.error||''):'tour complete')+' - '+shown+' views captured';+ toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+shown+' views captured',q.failed?'err':'ok');}+ },2500); }; $('#btnImportPart').onclick=async()=>{ const page=$('#partPage').value.trim();
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.74",+ "version": "1.9.76", "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,16 +1,20 @@ { "slug": "fusion-bridge",- "version": "1.9.74",+ "version": "1.9.76", "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": {- "adom/adom-desktop": "*"+ "adom/adom-desktop": "^1.9.0" }, "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }+ ] }