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
Update 15 file(s)
11 files changed
+485−36
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.190+1.9.191
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.190",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.190/adom-bridge-fusion-v1.9.190.zip",- "sha256": "79028cd19a140949ececc99e9adc7fe6e82cad079180a8d602f8a766d857ceeb",- "size": 489370,+ "version": "1.9.191",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.191/adom-bridge-fusion-v1.9.191.zip",+ "sha256": "179b2c54314dffd2351ed8b233a87f20d30bb1151f1ee3039f4fc78851887f68",+ "size": 494167, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.190",+ "version": "1.9.191", "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.190"+version = "1.9.191" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.190"+version = "1.9.191" edition = "2021" [dependencies]
dashboard/src/main.rs+113−2@@ -139,6 +139,15 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso // never red. Red is reserved for "actually needs setup". let last_signed = rd["lastSignedInAt"].as_str().unwrap_or(""); let last_ready = rd["lastReadyAt"].as_str().unwrap_or("");+ // Is lastReadyAt older than 2h? Parsed leniently from "YYYY-MM-DDTHH:MM:SS..." vs now;+ // unparseable timestamps count as stale (never let garbage read as freshly busy).+ let last_ready_stale = !last_ready.is_empty() && {+ use std::time::{SystemTime, UNIX_EPOCH};+ parse_epoch_lenient(last_ready).map(|t| {+ let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;+ now - t > 2 * 3600+ }).unwrap_or(true)+ }; let mcp_last = mcp["lastEnabledAt"].as_str().unwrap_or(""); // WHO is signed in, per facet (John, 2026-08-22): the auth test matrix runs // several accounts, and an anonymous green LED cannot distinguish them.@@ -186,10 +195,17 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso // occupied. Painting that red said "not serving" while the tour was visibly // driving Fusion. Amber + an honest sentence instead. "state": if ready {"on"}- else if running && !last_ready.is_empty() {"busy"}+ else if running && !last_ready.is_empty() && !last_ready_stale {"busy"}+ else if running && !last_ready.is_empty() {"stale"} else if !running && !last_ready.is_empty() {"dormant"} else {"off"}, "_hint": if ready {"Add-in serving - all fusion_* verbs live.".to_string()}+ // STALENESS CUTOFF (project-a6 cold-verify flag, 2026-08-24): "busy with a+ // long verb" citing a DAY-old lastReadyAt is a lie - no verb runs 2h+. Past+ // the cutoff say what it actually is: the add-in has not answered since+ // that timestamp and a Fusion restart reloads it (which also picks up a+ // newer installed add-in version).+ else if running && !last_ready.is_empty() && last_ready_stale {format!("Not serving since {} - too long ago to be a long verb. The add-in likely needs Fusion restarted to reload (fusion_stop + fusion_start; that also loads any newer installed add-in).", last_ready)} else if running && !last_ready.is_empty() {format!("Busy or still starting - Fusion is up and the add-in was serving at {}. A long verb (a tour, a 3D build) occupies its main thread, so readiness answers not-ready until it finishes.", last_ready)} else if !running && !last_ready.is_empty() {format!("Dormant - resumes when Fusion starts (last verified {}).", last_ready)} else {"Not serving. If needsSignin, run fusion_signin - it self-drives to the password step.".to_string()}},@@ -250,7 +266,30 @@ fn action(ai_thread: &str, target: &str, name: &str, args: &serde_json::Value) - r } "signin" => bridge(ai_thread, target, "fusion_signin", &a),- "tour" => bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}"),+ "tour" => {+ let r = bridge(ai_thread, target, "fusion_demo", "{\"mode\":\"tour\"}");+ // FLOATY TOUR REMOTE (John, 2026-08-24: "a play/pause/next/prev/exit window or+ // dialog or floaty giving me an easy way to stop the demo"): a compact pup+ // window on the user's desktop loading this dashboard's own /?remote=<demoId>+ // view - transport buttons + the live caption, clickable while they watch+ // Fusion. Best-effort: no pup, no problem, the dashboard page has the same+ // controls. Opened FOREGROUND deliberately - the user just pressed Run.+ if let Some(id) = unwrap_data(&r)["demoId"].as_str() {+ let url = format!("{}?remote={}", url_for(current_port()), id);+ let args = serde_json::json!({"url": url, "sessionId": format!("fusion-tour-remote-{}", id),+ "width": 320, "height": 420, "left": 40, "top": 120,+ "foreground": true, "userRequestedForeground": true,+ "foregroundReason": "the user just started the Fusion tour; this is its stop/pause remote"}).to_string();+ let ai2 = ai_thread.to_string(); let t2 = target.to_string();+ std::thread::spawn(move || {+ let _ = Command::new("adom-bridge")+ .args(["--ai-thread", &ai2, "--target", &t2, "pup_open_window", &args])+ .output();+ });+ }+ r+ }+ "tour_control" => bridge(ai_thread, target, "fusion_demo_control", &a), "tour_progress" => bridge(ai_thread, target, "fusion_demo_progress", &a), "set_auto_update" => bridge(ai_thread, target, "fusion_set_auto_update", &a), "set_seat_takeover" => bridge(ai_thread, target, "fusion_set_seat_takeover", &a),@@ -1253,6 +1292,35 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { "delivered": app.hub.client_count(), "_hint": "delivered = live SSE viewers that showed it. 0 means nobody has the page open - open it first."})); }+ ("GET", "/api/tts") => {+ // Tour audio narration (John, 2026-08-24: "add a checkbox to play audio and+ // use adom-tts to create the audio clips that correspond to the ab captions").+ // The PAGE plays the clip, so audio is perfectly synced to the caption it+ // shows - no cross-machine player surface routing. Clips cache by text hash+ // (adom-tts also has its own source-hash cache underneath).+ let text = path_full.split('?').nth(1).unwrap_or("").split('&')+ .find_map(|kv| kv.strip_prefix("text=").map(|v| v.to_string()))+ .map(|v| url_decode(&v)).unwrap_or_default();+ if text.is_empty() || text.len() > 1200 {+ respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "text required (<=1200 chars)"}));+ } else {+ let mut h: u64 = 1469598103934665603;+ for b in text.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(1099511628211); }+ let dir = std::env::temp_dir().join("fusion-dash-tts");+ let _ = std::fs::create_dir_all(&dir);+ let f = dir.join(format!("{:016x}.mp3", h));+ if !f.exists() {+ let _ = Command::new("adom-tts").args(["say", "--out"])+ .arg(&f).arg(&text).output();+ }+ match std::fs::read(&f) {+ Ok(bytes) if !bytes.is_empty() =>+ respond(&mut stream, "200 OK", "audio/mpeg", &bytes),+ _ => respond_json(&mut stream, &serde_json::json!({"ok": false,+ "why": "adom-tts synthesis failed (is adom-tts installed and the tts service reachable?)"})),+ }+ }+ } ("POST", "/api/say-try") => { // "Say it" strip Try-it (dock-bar `say` contract, John 2026-08-23: teach users to // ASK for the app by its noun). Stages the phrase into this app's own AI thread@@ -1344,6 +1412,48 @@ fn hy_post(path: &str, payload: &serde_json::Value) -> Option<serde_json::Value> serde_json::from_slice(&out.stdout).ok() } +/// Parse "YYYY-MM-DDTHH:MM:SS(.frac)(Z|+hh:mm)" to unix seconds without a chrono dep.+/// Treats a trailing offset as UTC-equivalent enough for a 2-hour staleness test.+/// Minimal percent-decoder for /api/tts?text= (plus '+' as space).+static SERVING_PORT: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(8930);+fn current_port() -> u16 { SERVING_PORT.load(std::sync::atomic::Ordering::Relaxed) }++fn url_decode(s: &str) -> String {+ let b = s.as_bytes();+ let mut out = Vec::with_capacity(b.len());+ let mut i = 0;+ while i < b.len() {+ match b[i] {+ b'+' => { out.push(b' '); i += 1; }+ b'%' if i + 2 < b.len() => {+ let hx = |c: u8| (c as char).to_digit(16);+ if let (Some(a), Some(c)) = (hx(b[i + 1]), hx(b[i + 2])) {+ out.push((a * 16 + c) as u8); i += 3;+ } else { out.push(b[i]); i += 1; }+ }+ c => { out.push(c); i += 1; }+ }+ }+ String::from_utf8_lossy(&out).to_string()+}++fn parse_epoch_lenient(s: &str) -> Option<i64> {+ let b = s.as_bytes();+ if b.len() < 19 { return None; }+ let num = |a: usize, z: usize| s.get(a..z)?.parse::<i64>().ok();+ let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);+ let (h, mi, sec) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);+ if !(1..=12).contains(&mo) || !(1..=31).contains(&d) { return None; }+ // days since epoch (civil algorithm, Howard Hinnant's days_from_civil)+ let y2 = if mo <= 2 { y - 1 } else { y };+ let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;+ let yoe = y2 - era * 400;+ let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1;+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+ let days = era * 146097 + doe - 719468;+ Some(days * 86400 + h * 3600 + mi * 60 + sec)+}+ fn stage_say_phrase(phrase: &str) -> serde_json::Value { const TAG: &str = "app:adom/fusion-bridge"; // 1. CENSUS: open threads. Shape: {"ok":true,"groups":[{"threads":[{"tid","sid"|null,"tags":[...]}]}]}@@ -1497,6 +1607,7 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) { } } };+ SERVING_PORT.store(port, std::sync::atomic::Ordering::Relaxed); let app = Arc::new(App { ai_thread, target,
dashboard/ui/index.html+135−12@@ -252,9 +252,21 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} <div class="card"> <div class="led-label">Guided tour</div> <div class="led-sub">Walks every surface on a sample part: library, symbol, footprint, 3D chip, a generated solo board in 2D and 3D. Runs entirely in the background.</div>- <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="btnrow" style="align-items:center">+ <button class="btn primary" id="btnTour" data-tip="Walk every layer of the bridge - library, symbol, footprint, the 3D chip, Data Hub, APS search, MCP, a generated board in 2D and 3D, and real exports - with a live progress bar and on-screen captions">Run the tour</button>+ <label style="display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--text-body)" data-tip="Speak each step's caption aloud (audio is generated by adom-tts and plays right here in this page)">+ <div class="toggle" id="tglTourAudio" role="switch" aria-checked="false"></div>+ Narrate with audio</label>+ </div>+ <div class="barlabel" id="tourCaption" style="display:none;margin-top:8px;color:var(--text);font-size:13px;line-height:1.5"></div> <div class="bar" id="tourBar"><div></div></div> <div class="barlabel" id="tourLabel"></div>+ <div class="btnrow" id="tourTransport" style="display:none;margin-top:8px">+ <button class="btn" id="ttPrev" data-tip="Replay the previous view">⏮</button>+ <button class="btn" id="ttPause" data-tip="Pause at the next step boundary">⏸</button>+ <button class="btn" id="ttNext" data-tip="Skip the upcoming step">⏭</button>+ <button class="btn" id="ttExit" data-tip="End the tour and clean up its documents">⏹ Exit</button>+ </div> <div class="sgrid" id="tourGallery" style="margin-top:12px"></div> </div> <div class="card" style="margin-top:12px">@@ -382,6 +394,50 @@ async function act(action,args){return api('api/action',{method:'POST',headers:{ let lastStatus=null; let curTarget=localStorage.getItem('fdTarget')||''; +// ── FLOATY TOUR REMOTE (?remote=<demoId>): the whole page collapses to a compact+// transport + live caption panel, sized for the little pup window the tour opens.+const REMOTE_ID=new URLSearchParams(location.search).get('remote');+if(REMOTE_ID){+ document.addEventListener('DOMContentLoaded',()=>{+ document.title='Fusion Tour Remote';+ document.querySelector('main').innerHTML=+ '<div style="padding:6px 2px">'++ '<div style="font-family:\'Familjen Grotesk\',sans-serif;font-weight:600;color:var(--accent);font-size:15px;margin-bottom:10px">Fusion Tour</div>'++ '<div class="bar" id="rBar" style="display:block"><div></div></div>'++ '<div class="barlabel" id="rLabel" style="margin-top:6px"></div>'++ '<div id="rCaption" style="margin-top:10px;font-size:13px;line-height:1.5;color:var(--text)"></div>'++ '<div class="btnrow" style="margin-top:14px">'++ '<button class="btn" id="rPrev" data-tip="Replay previous view">⏮</button>'++ '<button class="btn" id="rPause" data-tip="Pause / resume">⏸</button>'++ '<button class="btn" id="rNext" data-tip="Skip upcoming step">⏭</button>'++ '</div><div class="btnrow" style="margin-top:8px">'++ '<button class="btn" id="rExit" style="border-color:rgba(248,81,73,.5);color:var(--red)" data-tip="End the tour">⏹ Exit tour</button>'++ '</div></div>';+ let rp=false;+ const rctl=async(a)=>{try{await act('tour_control',{demoId:REMOTE_ID,action:a});+ if(a==='pause'){rp=true;$('#rPause').innerHTML='▶';}+ if(a==='resume'){rp=false;$('#rPause').innerHTML='⏸';}+ }catch(e){}};+ $('#rPause').onclick=()=>rctl(rp?'resume':'pause');+ $('#rNext').onclick=()=>rctl('next');+ $('#rPrev').onclick=()=>rctl('prev');+ $('#rExit').onclick=()=>rctl('exit');+ let busy=false;+ const rt=async()=>{+ if(busy)return; busy=true;+ try{+ const p=await act('tour_progress',{demoId:REMOTE_ID,imagesAs:'paths',sinceShot:9999});const q=p.data||p;+ $('#rBar>div').style.width=(q.percent||0)+'%';+ $('#rLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');+ if(q.stepCaption)$('#rCaption').textContent=q.stepCaption;+ if(q.done){$('#rLabel').textContent=q.failed?('stopped: '+(q.error||'')):'Tour complete';busy=false;return;}+ }catch(e){}+ busy=false;setTimeout(rt,2000);+ };+ rt();+ });+}+ // SAY IT strip (dock-bar `say` contract): keep this list in lockstep with dockbar.json's // `say` array - the dock interstitial and this strip must teach the same phrases. // SHIP-NIGHT POSTURE, amended by John's ruling (2026-08-23, via the dock-bar thread):@@ -494,7 +550,27 @@ $('#btnTour').onclick=async()=>{ 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;+ // PRE-GENERATE the narration clips (John, 2026-08-24: "pre-create these mp3 audio files+ // so you don't have to generate them on the fly"): the tour-start payload carries every+ // step's caption, so the clips synthesize into the server cache while the first steps+ // run. Sequential to keep the tts service polite; fire-and-forget.+ const caps=((d.steps||[]).map(st=>st.caption).filter(Boolean));+ if(tourAudioOn()&&caps.length){(async()=>{for(const c of caps){try{await fetch('api/tts?text='+encodeURIComponent(c));}catch(e){}}})();}+ const tt=$('#tourTransport');tt.style.display='flex';+ let ttPaused=false;+ const ctl=async(action)=>{try{const r=await act('tour_control',{demoId:id,action});const x=r.data||r;+ if(x.success===false){toast(x.error||'control refused','err');return;}+ if(action==='pause'){ttPaused=true;$('#ttPause').innerHTML='▶';toast('pausing at the next step boundary');}+ if(action==='resume'){ttPaused=false;$('#ttPause').innerHTML='⏸';toast('resuming');}+ if(action==='next')toast('skipping the upcoming step');+ if(action==='prev')toast('replaying the previous view');+ if(action==='exit')toast('exiting the tour (documents will be cleaned up)');+ }catch(e){toast('control failed: '+e,'err');}};+ $('#ttPause').onclick=()=>ctl(ttPaused?'resume':'pause');+ $('#ttNext').onclick=()=>ctl('next');+ $('#ttPrev').onclick=()=>ctl('prev');+ $('#ttExit').onclick=()=>ctl('exit');+ let since=0, shown=0, lastCaption='', polling=false; 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>'+@@ -502,19 +578,64 @@ $('#btnTour').onclick=async()=>{ c.querySelector('img').onclick=e=>window.open(e.target.src,'_blank'); gal.appendChild(c);shown++; };- const poll=setInterval(async()=>{- // 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;+ const tick=async()=>{+ // SELF-SCHEDULING, never overlapping (John caught 41 duplicate views at 35%,+ // 2026-08-24): setInterval stacked concurrent polls whenever a relay round-trip ran+ // past 2.5s, and every overlapping poll carried the same stale sinceShot cursor, so+ // each one re-appended the whole backlog. One poll in flight at a time, the cursor+ // advances before the next is scheduled, and the windowed shots array is the ONLY+ // delivery channel (stepShot's server-side cursor is per-tour, not per-viewer, so+ // two open pages would steal frames from each other).+ if(polling)return; polling=true;+ let q={};+ try{const p=await act('tour_progress',{demoId:id,imagesAs:'base64',sinceShot:since});q=p.data||p;}+ catch(e){polling=false;setTimeout(tick,4000);return;}+ polling=false; $('#tourBar>div').style.width=(q.percent||0)+'%'; $('#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);+ if(q.stepCaption&&q.stepCaption!==lastCaption){+ lastCaption=q.stepCaption;+ const cap=$('#tourCaption');cap.style.display='block';cap.textContent=q.stepCaption;+ tourSpeak(q.stepCaption);+ }+ (q.shots||[]).forEach(s=>{if(s.image)addShot(s.label||s.title,s.image);});+ if(typeof q.shotCount==='number')since=q.shotCount; else since+= (q.shots||[]).length;+ if(q.done){+ $('#tourTransport').style.display='none'; $('#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);+ toast(q.failed?('tour failed: '+(q.error||'')):'tour complete: '+shown+' views captured',q.failed?'err':'ok');+ return;+ }+ setTimeout(tick,2500);+ };+ tick();+};+// ── Tour audio narration: adom-tts clips of each step caption, played sequentially in+// this page (perfectly synced to the caption shown; nothing plays unless the toggle is on).+let tourAudioQueue=[], tourAudioPlaying=false;+function tourAudioOn(){return $('#tglTourAudio').classList.contains('is-on');}+function tourSpeak(text){+ if(!tourAudioOn())return;+ tourAudioQueue.push(text);+ if(!tourAudioPlaying)tourAudioNext();+}+async function tourAudioNext(){+ const text=tourAudioQueue.shift();+ if(!text){tourAudioPlaying=false;return;}+ tourAudioPlaying=true;+ try{+ const a=new Audio('api/tts?text='+encodeURIComponent(text));+ a.onended=()=>tourAudioNext();+ a.onerror=()=>tourAudioNext();+ await a.play();+ }catch(e){tourAudioNext();}+}+$('#tglTourAudio').onclick=async(e)=>{+ const t=e.currentTarget;const now=!t.classList.contains('is-on');+ t.classList.toggle('is-on',now);t.setAttribute('aria-checked',String(now));+ await saveDashSettings({tourAudio:now});+ if(!now){tourAudioQueue=[];}+ toast(now?'Audio narration on - each tour step will be spoken':'Audio narration off','ok'); }; $('#btnImportPart').onclick=async()=>{ const page=$('#partPage').value.trim();@@ -529,6 +650,8 @@ $('#btnSettings').onclick=async()=>{ await loadDashSettings(); const set=(sel,on)=>{const t=$(sel);t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));}; const ag=dashSettings.autoGreenOnLaunch!==false;+ const ta=!!dashSettings.tourAudio;+ const tg=$('#tglTourAudio');if(tg){tg.classList.toggle('is-on',ta);tg.setAttribute('aria-checked',String(ta));} set('#tglAutoGreen',ag); $('#stAutoGreen').textContent=ag?'active - drives every facet to green on open':'status only'; $('#stAutoGreen').className='card-status'+(ag?' ok':'');
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.190"+DASH_VERSION="1.9.191" 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.190",+ "version": "1.9.191", "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.190",+ "version": "1.9.191", "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]"- }-}+ ]+}
server.py+216−4@@ -6478,6 +6478,32 @@ def _seat_sweep_tick(): print("[Fusion Bridge] driving badge shed after %ds idle" % _BADGE_IDLE_SECONDS) except Exception: pass+ # DIRECT "Unsaved changes" catcher (John's blocked-screen screenshot, 2026-08-24:+ # a graceful stop wedged TEN MINUTES behind this dialog; the sentinel's Escape+ # fallback CANCELS the close, so the stop fought its own dialog). Find the dialog by+ # its title at the desktop level, prove the doc it names is OURS via UIA text, and+ # click Don't Save. Never touches a dialog naming a user's document.+ try:+ from handlers.dismiss_recovery import _find_window_by_title as _fwt2+ _uh = _fwt2("Unsaved changes")+ if _uh:+ _ours = None+ for _probe in tuple(_BRIDGE_DOCS)[:8] + _BRIDGE_DOC_BUILTINS:+ _rr = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(_uh), "contains": _probe}, timeout=8))+ if (_rr.get("best") or {}).get("name"):+ _ours = _probe+ break+ if _ours:+ _ck2 = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": int(_uh), "name": "Don't Save"}, timeout=10))+ _bridge_action_ledger("unsaved_changes_dont_save",+ {"hwnd": int(_uh), "docMatch": _ours,+ "clicked": bool(_ck2.get("invoked") or _ck2.get("success"))})+ print("[Fusion Bridge] janitor: answered the Unsaved-changes dialog with "+ "Don't Save (bridge doc %r)" % _ours)+ except Exception:+ pass # Same tick also sweeps a standing Save prompt for BRIDGE-CREATED docs (2026-08-16: # a bare "Save" Qt modal from a killed session blocked the main thread invisibly and # failed two tours in a row - the sentinel only ran during doc-switch calls).@@ -8043,6 +8069,43 @@ def _demo_tour_sample_lbr(): return dst.replace("\\", "/") +# Per-step narration (John, 2026-08-24: "show ab captions for each step ... explaining+# what each step is doing ... and all the cool stuff you needed to have in place to make+# that work"). One sentence of WHAT + one of the MACHINERY. Written to be READ ALOUD+# (adom-tts renders these on the dashboard when audio narration is on) and shown as a+# desktop caption over the live demo, so a screenshot appearing always has its why.+_TOUR_EXPLAIN = {+ "launch": "Starting Fusion in the background. The bridge launches it detached and minimized, takes the one Autodesk seat if another machine holds it, and never steals your focus - and the AdomBridge add-in loads itself inside Fusion and opens a local command port the bridge drives everything through.",+ "stage": "Staging the sample part. This library was built from open KiCad source by adom dash l b r and lands on disk as a real EAGLE library file Fusion can open natively.",+ "library": "Opening the electronics library. The bridge drives Fusion's own Electronics Library editor through the add-in - no clicks, no foreground, the same path a human would take.",+ "symbol": "The symbol editor: the part's full pinout, converted pin for pin from KiCad source. The add-in switches views with Fusion's own edit commands.",+ "footprint": "The footprint editor: real pads and layers, exactly the copper a board would get - never a placeholder.",+ "device": "The component view: every symbol pin mapped to its footprint pad. The bridge verifies this mapping offline before Fusion ever opens the file, so dead wiring cannot photograph well.",+ "3d-package": "The 3D chip - the showpiece. The bridge imports the part's STEP model onto the footprint in Fusion's 3D package generator, orients it flat automatically, saves it into your Autodesk cloud hub, and binds the resulting cloud model into the library, so every future board carries the real body.",+ "datahub": "Your Fusion Data Hub, through the add-in: hubs, projects and folders listed over Fusion's own cloud API - the same storage the 3D chip was just saved into.",+ "aps": "APS cloud search: the bridge's own server-indexed search over your Fusion cloud files, about two seconds per query after a single sign-in, and never charged.",+ "mcp": "Autodesk's official MCP server, answering over the bridge. Useful - but its toolset is small and read-mostly. The bridge's one hundred plus fusion verbs go far beyond it: electronics editors, text commands, cloud files, evidence captures and a dozen export formats.",+ "make-project": "Generating a complete test project around the part: schematic and board authored from EAGLE source and instantiated by Fusion, entirely in the background.",+ "open-design": "Opening the cloud electronics design from your hub - the bridge resolves it by name and opens the parent design, never a child view.",+ "schematic": "The schematic view of the generated project - the part wired into a real sheet.",+ "2d-board": "The 2D board layout - the same footprint you saw in the library, now placed on copper.",+ "3d-board": "The 3D board - and the chip body on it is the exact cloud model the bridge minted and bound earlier in this tour.",+ "exports": "Exports: STEP and STL written straight from the add-in. The same pipeline covers F 3 D, G L B, 3 M F, IGES, SAT, D X F, gerbers and BOM - every format a downstream tool could ask for.",+}+++def _tour_caption(text: str, seconds: int = 45):+ """One desktop caption per tour step (id-replace: each step's caption replaces the+ last, so the screen always narrates the CURRENT step). Best-effort, never raises."""+ try:+ _ad_call("desktop_caption", {"text": text, "id": "fusion-tour", "position": "top",+ "size": "small",+ "duration": max(15000, min(int(seconds) * 2000, 240000))},+ timeout=10)+ except Exception:+ pass++ def _orchestrate_demo_tour(args: dict) -> dict: """Start the async six-content-type tour; poll fusion_demo_progress {demoId}.""" import time@@ -8070,6 +8133,13 @@ def _orchestrate_demo_tour(args: dict) -> dict: ("footprint", "Footprint: pads + layers in the footprint editor", _est("footprint", 8)), ("device", "Component view: symbol + package mapping", _est("device", 8)), ("3d-package", "3D chip: the Package3D environment", _est("3d-package", 30)),+ # The platform side (John, 2026-08-24: "show off all aspects of your bridge+ # including aps and data hub and the add-in and the mcp server chatting and the+ # limits of the mcp server that you go way beyond"). Each degrades gracefully+ # (signed-out APS, disabled MCP) - the caption then narrates the honest state.+ ("datahub", "Data Hub: your cloud projects through the add-in", _est("datahub", 10)),+ ("aps", "APS cloud search: ~2s indexed lookups", _est("aps", 8)),+ ("mcp", "MCP: Autodesk's own AI server (and its limits)", _est("mcp", 10)), ] if include_design: # Design side is SELF-SUFFICIENT by default (John, 2026-08-15: customers have@@ -8081,6 +8151,7 @@ def _orchestrate_demo_tour(args: dict) -> dict: ("schematic", "Schematic view", _est("schematic", 12)), ("2d-board", "2D board layout", _est("2d-board", 12)), ("3d-board", "3D board", _est("3d-board", 20)),+ ("exports", "Exporting: STEP + STL (of a dozen formats)", _est("exports", 25)), ] est = sum(s[2] for s in steps) state = {"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": est,@@ -8121,8 +8192,31 @@ def _orchestrate_demo_tour(args: dict) -> dict: if not fg: begin_background_envelope(float(est + 60)) done_est = 0+ _docs_before_tour = _open_doc_names() # tour hygiene: close only what WE open try:- for key, label, dur in steps:+ _VIEW_STEPS = {"symbol", "footprint", "device", "schematic", "2d-board", "3d-board", "library"}+ _last_view_step = None+ _step_i = 0+ while _step_i < len(steps):+ key, label, dur = steps[_step_i]+ _step_i += 1+ # ── TRANSPORT (fusion_demo_control): boundary checks, never mid-verb ──+ if state.pop("abort", None):+ raise RuntimeError("tour exited by the user (transport control)")+ while state.get("paused") and not state.get("abort"):+ state.update(stepLabel="Paused - press play to continue", stepCaption="Paused.")+ time.sleep(1.5)+ if state.pop("abort", None):+ raise RuntimeError("tour exited by the user (transport control)")+ if state.pop("skipNext", None):+ state.setdefault("skippedSteps", []).append(key)+ done_est += dur+ continue+ if state.pop("replayPrev", None) and _last_view_step:+ _step_i -= 1 # come back to the current step after+ key, label, dur = _last_view_step # re-run the last view step once+ if key in _VIEW_STEPS:+ _last_view_step = (key, label, dur) if not fg: # RE-ARM per step: one est-sized envelope EXPIRED mid-tour on real runs # (485-890s vs ~376s armed) and later steps stole the user's foreground@@ -8130,9 +8224,16 @@ def _orchestrate_demo_tour(args: dict) -> dict: # 2026-08-16). Nesting is refcounted, so re-arming is free. begin_background_envelope(float(dur * 3 + 240)) _step_started = time.time()+ # NARRATION (John, 2026-08-24): the caption teaches what this step is doing+ # and the machinery underneath, on-screen (desktop caption, id-replaced per+ # step) and in the progress stream (stepCaption - the dashboard shows it and+ # renders it as audio when narration is on).+ _explain = _TOUR_EXPLAIN.get(key) or label+ if args.get("captions", True):+ _tour_caption(_explain, dur) # LIVE, per-step ETA (issue #32): the tour reports the same contract block # every other slow verb does, so ONE renderer draws every bar in Adom.- state.update(step=key, stepLabel=label,+ state.update(step=key, stepLabel=label, stepCaption=_explain, percent=int(100 * done_est / est), elapsedSec=int(time.time() - state["started"]), estimatedSec=est,@@ -8293,6 +8394,61 @@ def _orchestrate_demo_tour(args: dict) -> dict: state.setdefault("warnings", []).append( "open-design: %r never became the active document within 170s" % query) state["designOpenFailed"] = True+ elif key == "datahub":+ r = _proxy_to_addin("list_cloud_projects", {}, timeout=45)+ if r.get("success"):+ _d = r.get("data") or {}+ _projs = _d.get("projects") or _d.get("dataProjects") or []+ state.setdefault("highlights", {})["datahub"] = {+ "projects": len(_projs),+ "names": [str(p.get("name") or p)[:40] for p in _projs[:6]]}+ else:+ state.setdefault("warnings", []).append(+ "datahub: " + str(r.get("error"))[:100])+ elif key == "aps":+ _q = (state.get("deviceset") or "ADS8588").split("/")[-1][:20]+ _t0 = time.time()+ r = dispatch_command("aps_search", {"query": _q, "limit": 5})+ if r.get("success"):+ _hits = r.get("results") or r.get("items") or (r.get("data") or {}).get("results") or []+ state.setdefault("highlights", {})["aps"] = {+ "query": _q, "hits": len(_hits), "seconds": round(time.time() - _t0, 1)}+ else:+ state.setdefault("warnings", []).append(+ "aps: %s (sign in once with fusion_aps_signin and this step lights up)"+ % str(r.get("error"))[:80])+ elif key == "mcp":+ r = dispatch_command("mcp_tools", {})+ if r.get("success"):+ _tools = r.get("tools") or (r.get("data") or {}).get("tools") or []+ _names = [str(t.get("name") or t)[:32] for t in _tools][:20]+ state.setdefault("highlights", {})["mcp"] = {+ "toolCount": len(_tools), "tools": _names,+ "beyond": ("The bridge's fusion_* surface is ~120 verbs: electronics "+ "editors, EAGLE text commands, cloud files, APS search, "+ "evidence captures, and 14 export formats - none of which "+ "the MCP toolset offers.")}+ else:+ state.setdefault("warnings", []).append(+ "mcp: %s (fusion_mcp_enable turns it on in ~10s)" % str(r.get("error"))[:80])+ elif key == "exports":+ _wait_idle()+ _exp_dir = "C:/tmp/fusion-tour-exports"+ _got = []+ for _fmt, _verb in (("step", "export_step"), ("stl", "export_stl")):+ _p = "%s/%s.%s" % (_exp_dir, (state.get("deviceset") or "tour"), _fmt)+ r = dispatch_command(_verb, {"outputPath": _p})+ if r.get("success"):+ _got.append(_fmt.upper())+ else:+ state.setdefault("warnings", []).append(+ "%s: %s" % (_verb, str(r.get("error"))[:80]))+ if _got:+ state.setdefault("highlights", {})["exports"] = {+ "formats": _got, "dir": _exp_dir,+ "also": ["F3D", "GLB", "3MF", "IGES", "SAT", "DXF", "DWG", "OBJ",+ "FBX", "SKP", "USDZ", "gerbers", "BOM", "CPL"]}+ _snap(label) elif key in ("schematic", "2d-board", "3d-board"): if state.get("designOpenFailed"): state.setdefault("failedSteps", []).append(key)@@ -8316,6 +8472,10 @@ def _orchestrate_demo_tour(args: dict) -> dict: state.update(percent=int(100 * done_est / est), elapsedSec=int(time.time() - state["started"])) n_shots = len(state.get("shots") or [])+ if args.get("captions", True):+ _tour_caption("Tour complete: library, symbol, footprint, 3D chip, Data Hub, "+ "APS search, MCP, a generated project in schematic, 2D and 3D, "+ "and real exports - all driven in the background by the bridge.", 20) state.update(done=True, percent=100, step="done", stepLabel="Tour complete", narrate="Fusion tour complete: %d views captured across the electronics library%s." % ( n_shots, " and the board design" if include_design and query else ""),@@ -8326,11 +8486,25 @@ def _orchestrate_demo_tour(args: dict) -> dict: narrate="Fusion tour stopped at '%s': %s" % (state.get("stepLabel"), str(e)[:120]), elapsedSec=int(time.time() - state["started"])) finally:+ # TOUR HYGIENE (John's 9-open-docs screenshot, 2026-08-24: SIX copies of+ # ADOM-TEST-ADS8588SIPM left behind): same guarantee the create_library chain+ # got in 1.9.182/188 - close every doc the tour opened, plus unmodified+ # Untitleds, success or failure. Pre-existing docs are never touched.+ try:+ state["tourDocsClosed"] = _close_docs_opened_since(_docs_before_tour)+ except Exception:+ pass+ if args.get("captions", True):+ try:+ _ad_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)+ except Exception:+ pass _demo_tour_persist(state) threading.Thread(target=_run, daemon=True, name="fusion-demo-tour").start() return {"success": True, "demoId": demo_id, "estimatedSec": est,- "steps": [{"step": s[0], "label": s[1], "estimatedSec": s[2]} for s in steps],+ "steps": [{"step": s[0], "label": s[1], "estimatedSec": s[2],+ "caption": _TOUR_EXPLAIN.get(s[0]) or s[1]} for s in steps], "statusVerb": "fusion_demo_progress", "_hint": ("Tour started ASYNC (background by default - pass foreground:true only if the " "user wants to WATCH). Poll fusion_demo_progress {\"demoId\": \"%s\"} about "@@ -8338,6 +8512,40 @@ def _orchestrate_demo_tour(args: dict) -> dict: "poll (done:true) carries the screenshot array.") % demo_id} +def _handle_demo_control(args: dict) -> dict:+ """Transport control for a running tour (John, 2026-08-24: "play/pause/next/prev/exit+ ... an easy way to stop the demo"). Granularity is BETWEEN steps: a running Fusion verb+ cannot be preempted, so pause/skip/exit take effect at the next step boundary (a few+ seconds to a minute). actions: pause | resume | next (skip the upcoming step) |+ prev (re-run the last completed VIEW step) | exit (abort + close the tour's docs)."""+ demo_id = args.get("demoId") or ""+ action = (args.get("action") or "").lower()+ with _DEMO_TOURS_LOCK:+ state = _DEMO_TOURS.get(demo_id)+ if state is None and not demo_id and len(_DEMO_TOURS) == 1:+ state = list(_DEMO_TOURS.values())[0]+ if state is None or state.get("done"):+ return {"success": False, "error": "no running tour" + (" with id " + demo_id if demo_id else ""),+ "_hint": "Controls only apply while a tour is running."}+ if action == "pause":+ state["paused"] = True+ elif action == "resume":+ state["paused"] = False+ elif action == "next":+ state["skipNext"] = True+ state["paused"] = False+ elif action == "prev":+ state["replayPrev"] = True+ state["paused"] = False+ elif action == "exit":+ state["abort"] = True+ state["paused"] = False+ else:+ return {"success": False, "error": "action must be pause|resume|next|prev|exit"}+ return {"success": True, "action": action, "paused": bool(state.get("paused")),+ "_hint": "Takes effect at the next step boundary - a running Fusion verb finishes first."}++ def _handle_demo_progress(args: dict) -> dict: demo_id = args.get("demoId") or "" with _DEMO_TOURS_LOCK:@@ -8365,7 +8573,10 @@ def _handle_demo_progress(args: dict) -> dict: # does not. Delivery cursor lives in the tour state; encode failure still advances so # one unreadable file can never stall the stream. The final shots gallery (below and # at done:true) is unchanged - that is the AFTER evidence.- if not persisted:+ if not persisted and args.get("sinceShot") is None:+ # stepShot only for callers that do NOT window with sinceShot: its delivery cursor+ # is per-TOUR, so two windowing viewers would steal frames from each other, and+ # windowing callers already get every shot exactly once (2026-08-24 gallery fix). _sent = int(state.get("stepShotSent") or 0) _all = list(state.get("shots") or []) if _sent < len(_all):@@ -10848,6 +11059,7 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["create_library"] = _handle_create_library+COMMAND_HANDLERS["demo_control"] = lambda info, args: _handle_demo_control(args)