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 158 file(s)
17 files changed
+192−51
BRIDGE_VERSION+1−1bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+14rust/bridge/src/addin.rs+7−2rust/bridge/src/etiquette.rs+4−1rust/bridge/src/main.rs+2rust/bridge/src/mint.rs+11−2rust/bridge/src/orchestrations.rs+2rust/bridge/src/palette.rs+73rust/bridge/src/testproject.rs+1rust/bridge/src/tour.rs+60−12BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.364+1.9.365
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.364",+ "version": "1.9.365", "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.364"+version = "1.9.365" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.364"+version = "1.9.365" edition = "2021" [dependencies]
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.364"+DASH_VERSION="1.9.365" 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.364",+ "version": "1.9.365", "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−27@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.364",+ "version": "1.9.365", "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,28 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "agent_permissions": {- "allow_bash": [],- "allow": [- "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",- "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",- "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",- "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."- ],- "soft_deny": [- "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",- "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",- "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",- "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."- ],- "environment": [- "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",- "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."- ]- },- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.364"+version = "1.9.365" edition = "2021" license = "MIT"
rust/PORTING.md+14@@ -545,3 +545,17 @@ port learned: dock's readiness probe: "serving on port 8932" in the log, then "never answered within 120s". `fusion-dashboard serve --print-url` now launches the server through `setsid -f` (its own session) and falls back to the plain spawn where setsid is absent.++## 2026-09-05: the palette is the verbs' voice; busy gates ignore it++- John: the ab captions describe the demo, the in-Fusion palette describes the verb running+ right now (opening a library, minting 3D part 2 of 5, switching to the 2D board). `palette.rs`+ sends those through the add-in's activity_banner on a retrying thread; the pass-through+ chokepoint speaks for the plain verbs, the orchestrations for their own, the bind per part with+ an ETA from the per-part timing store (`mint.part`, so a 50-part library estimates itself).+- Both busy gates (our long-command gate and the add-in /status gate) now ignore an+ activity_banner in flight: two recorded runs lost four beats to "activity_banner running for+ 0.1s" refusals before that.+- The tour's front gate counts Fusion's own popups (the palette host) and PopupHost as front.+- The taskbar overlay is the same 32 px PNG the Python bridge sent, not ab's larger built-in badge.+- Fusion is placed at 95% of the work area; captions are ab's medium on one line.
rust/bridge/src/addin.rs+7−2@@ -93,7 +93,9 @@ pub fn addin_status_probe(timeout: Duration) -> Option<Value> { fn busy_gate(cmd: &str) -> Option<Value> { if LONG_RUNNING.contains(&cmd) || BUSY_EXEMPT.contains(&cmd) { return None; } let busy = LONG_COMMAND.lock().unwrap().clone();- if let Some((bc, at)) = busy {+ // the palette (activity_banner) is advisory and never blocks a real verb: a beat's+ // show_2d_board was refused with "activity_banner running for 0.1s" (ConfRoomROG, 2026-09-05)+ if let Some((bc, at)) = busy.filter(|(bc, _)| bc != "activity_banner") { let st = addin_status_probe(Duration::from_secs(3)); let progress = st.as_ref().and_then(|s| s.get("walkProgress").cloned()).filter(|p| !p.is_null()); let elapsed = (at.elapsed().as_secs_f64() * 10.0).round() / 10.0;@@ -104,7 +106,9 @@ fn busy_gate(cmd: &str) -> Option<Value> { "_hint": format!("A cloud search ({bc}) is in progress{}, running for {elapsed}s. Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will all be rejected until the search finishes. Commands that still work right now: fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, fusion_send_key, fusion_close_window. Wait for the search to complete, then retry.", pct.map(|p| format!(" (~{p}% done)")).unwrap_or_default())})); } if let Some(st) = addin_status_probe(Duration::from_secs(3)) {- if st["busy"].as_bool() == Some(true) {+ // the palette (activity_banner) repaints in a fraction of a second and is advisory: a verb+ // that lands while it paints waits its turn in the add-in instead of being refused+ if st["busy"].as_bool() == Some(true) && st["busyCommand"].as_str() != Some("activity_banner") { let elapsed = st.get("elapsedSeconds").cloned().unwrap_or(json!(0)); let bc = st["busyCommand"].as_str().unwrap_or("unknown").to_string(); let mut resp = json!({"success": false,@@ -204,6 +208,7 @@ impl<'a> Ctx<'a> { pub fn host_paths_short(&self, p: &str) -> String { (self.sho /// The whole pass-through for one verb. pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {+ if let Some((title, detail)) = crate::palette::for_verb(cmd, args) { crate::palette::say(&title, &detail, None, crate::palette::eta_for_verb(&ctx.state_dir, cmd)); } let sidecar = ctx.sidecar; let dialogs = ctx.dialogs; let t0 = Instant::now();
rust/bridge/src/etiquette.rs+4−1@@ -18,7 +18,10 @@ static BADGED: AtomicBool = AtomicBool::new(false); static BADGED_HWND: std::sync::Mutex<i64> = std::sync::Mutex::new(0); /// `set_driving_badge`: the Adom mark on Fusion's taskbar button while Adom drives it. pub fn set_badge(caller: &Caller, hwnd: i64) -> bool {- let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": hwnd, "overlay": {"badge": "adom", "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok()).unwrap_or(false);+ // The SAME badge the Python bridge painted (handlers/taskbar_overlay.py): our own 32 px PNG as+ // base64, not ab's built-in "adom" badge, which draws larger (John, 2026-09-05).+ static OVERLAY_PNG: &[u8] = include_bytes!("../../../resources/adom-overlay-32.png");+ let ok = Loopback::from_env().map(|ab| ab.call("desktop_taskbar", json!({"hwnd": hwnd, "overlay": {"icon": b64_pub(OVERLAY_PNG), "tooltip": "Adom is driving Fusion"}}), caller, 10).is_ok()).unwrap_or(false); if ok { BADGED.store(true, Ordering::Relaxed); *BADGED_HWND.lock().unwrap() = hwnd; } ok }
rust/bridge/src/main.rs+2@@ -39,6 +39,7 @@ mod addin_install; mod janitor; mod electronics; mod tour;+mod palette; use fusion_core::ab::Caller; use fusion_core::{HostPaths, HostProcess}; use serde_json::{json, Value};@@ -757,6 +758,7 @@ fn native_aps_browse(args: &Value) -> Value { /// `fusion_aps_search`: the server-indexed name search across the hub, newest first. pub(crate) fn native_aps_search(args: &Value) -> Value {+ crate::palette::say("Searching your hub", &format!("cloud search for {}", args["query"].as_str().unwrap_or("")), None, None); use fusion_core::aps::*; let query = args["query"].as_str().unwrap_or("").trim().to_string(); if query.is_empty() { return json!({"success": false, "error": "Missing query.", "shell": "rust"}); }
rust/bridge/src/mint.rs+11−2@@ -307,13 +307,18 @@ pub fn build_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids, sidecar: &side, dialogs: &dlg }; let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2); let st = std::cell::RefCell::new(bstate.clone());+ let part_clock = std::cell::Cell::new(Instant::now()); let on_part = |entry: &Value, i: usize, total: usize| {+ if i > 1 { crate::timings::record(&sd, "mint.part", part_clock.get().elapsed().as_secs_f64(), None); }+ part_clock.set(Instant::now());+ let per_part = crate::chains::estimate(&sd, "mint.part", 90.0).0;+ let eta = Some(per_part * (total.saturating_sub(i) + 1) as f64); let mut s = st.borrow_mut(); s["stage"] = json!(format!("minting {} ({i}/{total})", entry["package"].as_str().unwrap_or("part"))); s["partsDone"] = json!(i); let mut ps = s["parts"].as_array().cloned().unwrap_or_default(); ps.push(entry.clone()); s["parts"] = json!(ps); crate::chains::save_state_pub(&rid, &s);- let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+ crate::palette::say(&format!("{lib_label}: binding 3D, part {i} of {total}"), &format!("{}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), Some(100.0 * i as f64 / total.max(1) as f64 * 0.4), eta); }; let res = build_library_3d_core(&ctx2, &args2, &pass2, &on_part); { let mut s = st.borrow_mut(); s["stage"] = json!("done"); s["success"] = json!(res["success"].as_bool().unwrap_or(false)); s["result"] = res; let n = s["parts"].as_array().map(|a| a.len()).unwrap_or(0); s["partsDone"] = json!(n); bstate = s.clone(); }@@ -325,9 +330,13 @@ pub fn build_library_3d(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> V "_hint": "⏳ 3D bind runs DETACHED (survives ab's 60s relay cap, which used to kill it + lose every wip_urn). POLL the boundLbr via read_file every ~30s until it has one <package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire while running (check fusion_addin_status.busy). When done, embed those <packages3d> + a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run for just that part - its f3d upload failed."}); } let lib_label = crate::chains::lib_label_pub(&combined);+ let part_clock = std::cell::Cell::new(Instant::now()); let on_part = |entry: &Value, i: usize, total: usize| {+ if i > 1 { crate::timings::record(&ctx.state_dir, "mint.part", part_clock.get().elapsed().as_secs_f64(), None); }+ part_clock.set(Instant::now());+ let per_part = crate::chains::estimate(&ctx.state_dir, "mint.part", 90.0).0; let story = step_story_from_file(entry["modelPath"].as_str().unwrap_or(""));- let _ = crate::addin::addin_http("activity_banner", &json!({"action": "update", "title": format!("{lib_label}: binding 3D, part {i} of {total}"), "detail": format!("{}. {story}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), "percent": 100.0 * i as f64 / total.max(1) as f64 * 0.4, "etaSec": Value::Null, "tone": "working"}), 20);+ crate::palette::say(&format!("{lib_label}: binding 3D, part {i} of {total}"), &format!("{}. {story}. Importing its STEP and saving its 3D package", entry["package"].as_str().unwrap_or("part")), Some(100.0 * i as f64 / total.max(1) as f64 * 0.4), Some(per_part * (total.saturating_sub(i) + 1) as f64)); }; build_library_3d_core(ctx, args, pass, &on_part) }
rust/bridge/src/orchestrations.rs+2@@ -91,6 +91,7 @@ pub fn close_bridge_doc_verified(ctx: &Ctx, name: &str, timeout_s: u64, pass: &d /// `fusion_open_lbr`. pub fn open_lbr(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ { let stem = args["filePath"].as_str().unwrap_or("").replace('\\', "/").rsplit('/').next().unwrap_or("").trim_end_matches(".lbr").to_string(); crate::palette::say(&format!("Opening library {stem}"), "Fusion's Electronics Library editor, in the background", None, crate::palette::eta_for_verb(&ctx.state_dir, "open_lbr")); } let file_path = ["filePath", "lbrPath", "libraryPath", "path"].iter().find_map(|k| args[k].as_str()).unwrap_or("").to_string(); if file_path.trim().is_empty() { let mut received: Vec<String> = args.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default(); received.sort();@@ -333,6 +334,7 @@ pub fn open_doc_names(pass: &dyn Fn(&str, Value) -> Value) -> Vec<String> { /// `_show_library_view`: EDIT to a library view, fit, background screenshot. Library must be open. pub fn show_library_view(ctx: &Ctx, name: &str, suffix: &str, lbr_path: Option<&str>, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ { let what = match suffix { "sym" => "symbol", "pac" => "footprint", "dev" => "component view", _ => suffix }; let lib = lbr_path.unwrap_or("").replace('\\', "/").rsplit('/').next().unwrap_or("").trim_end_matches(".lbr").to_string(); crate::palette::say(&format!("{}: {what} of {name}", if lib.is_empty() { "Library".to_string() } else { lib }), &format!("EDIT {name}.{suffix}, then WINDOW FIT"), None, crate::palette::eta_for_verb(&ctx.state_dir, "show_library_view")); } let mut name = name.to_string(); if let Some(lp) = lbr_path { match resolve_artifact_name(&name, suffix, lp) { Ok(r) => { if r != name { eprintln!("[fusion-bridge] resolved {name:?} -> {r:?} from the library (avoids a Create-new prompt)"); name = r; } }, Err(e) => return e }
rust/bridge/src/palette.rsadded+73@@ -0,0 +1,73 @@+//! The in-Fusion palette ("Adom is driving Fusion"): the VERBS' voice. John, 2026-09-05: the ab+//! captions describe the demo; the palette describes the verb running against Fusion right now+//! (opening a library, minting 3D part 2 of 5, switching to the 2D board). Every message goes+//! straight to the add-in's activity_banner over its own HTTP hop (busy-exempt), on a thread that+//! retries while Fusion's main thread is tied up, so the palette lands even mid-bind. A message+//! that nobody follows within 25 s takes the palette down; a newer message cancels older retries.+use serde_json::{json, Value};+use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Mutex;+use std::time::{Duration, Instant};++static GEN: AtomicU64 = AtomicU64::new(0);+static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);+const TITLE_PREFIX: &str = "";++fn send(action: &str, title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) -> Value {+ crate::addin::addin_http("activity_banner", &json!({"action": action, "title": format!("{TITLE_PREFIX}{title}"), "detail": detail, "percent": percent, "etaSec": eta.map(|e| e.max(0.0).round()), "tone": "working"}), 8)+}++/// Say what is being done to Fusion right now, with the time it usually takes (from the timing+/// store, so the estimate sharpens with every run). Fire-and-forget.+pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) {+ let gen = GEN.fetch_add(1, Ordering::Relaxed) + 1;+ *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string()));+ let (t, d) = (title.to_string(), detail.to_string());+ std::thread::spawn(move || {+ let t0 = Instant::now();+ // one call per message: the add-in's show creates the palette or repaints it in place+ while t0.elapsed() < Duration::from_secs(90) {+ if GEN.load(Ordering::Relaxed) != gen { return; } // a newer message took over+ let r = send("show", &t, &d, percent, eta);+ if r["success"].as_bool() == Some(true) { break; }+ std::thread::sleep(Duration::from_secs(3));+ }+ // idle hide: nothing newer within 25 s and no import run in flight+ std::thread::sleep(Duration::from_secs(25));+ if GEN.load(Ordering::Relaxed) == gen && !run_in_flight() { let _ = send("hide", "", "", None, None); }+ });+}++/// Take the palette down now (the tour's teardown, a cancelled run).+pub fn hide() {+ GEN.fetch_add(1, Ordering::Relaxed);+ *LAST.lock().unwrap() = None;+ std::thread::spawn(|| { for _ in 0..5 { let r = send("hide", "", "", None, None); if r["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(3)); } });+}++fn run_in_flight() -> bool {+ crate::chains::read_runs().values().any(|r| !matches!(r["stage"].as_str(), Some("done") | Some("failed") | Some("cancelled") | None))+}++/// The plain add-in verbs worth a line on the palette (the orchestrations say their own).+pub fn for_verb(cmd: &str, args: &Value) -> Option<(String, String)> {+ let name = |k: &str| args[k].as_str().map(|s| s.replace('\\', "/").rsplit('/').next().unwrap_or("").to_string()).filter(|s| !s.is_empty());+ Some(match cmd {+ "open_by_urn" => ("Opening a cloud design".into(), format!("from your hub: {}", args["name"].as_str().or(args["urn"].as_str()).unwrap_or("").chars().rev().take(40).collect::<String>().chars().rev().collect::<String>())),+ "open_cloud_file" => ("Opening a cloud design".into(), format!("from your hub: {}", args["fileName"].as_str().or(args["name"].as_str()).unwrap_or(""))),+ "show_schematic" => ("Switching to the schematic".into(), "Fusion's electronics design: the schematic sheet".into()),+ "show_2d_board" => ("Switching to the 2D board".into(), "the board editor: copper, pours and parts".into()),+ "show_3d_board" => ("Switching to the 3D board".into(), "the 3D PCB: every component body in place".into()),+ "import_step" | "import_file" => ("Importing a model".into(), name("filePath").unwrap_or_default()),+ "import_electronics" | "new_electronics_from_eagle" => ("Importing an electronics design".into(), name("filePath").or(name("schPath")).unwrap_or_default()),+ "export_step" | "export_stl" | "export_f3d" | "export_glb" | "export_gerbers" | "export_bom" => (format!("Exporting {}", cmd.trim_start_matches("export_").to_uppercase()), name("outputPath").unwrap_or_default()),+ "save_to_cloud" => ("Saving to your hub".into(), args["name"].as_str().unwrap_or("").into()),+ _ => return None,+ })+}++/// The measured time a verb usually takes on this machine (p50 of past runs), for the palette.+pub fn eta_for_verb(state_dir: &std::path::Path, verb: &str) -> Option<f64> {+ let (secs, kind) = crate::timings::verb_estimate(state_dir, verb);+ if kind == "unknown" || secs <= 0.0 { None } else { Some(secs) }+}
rust/bridge/src/testproject.rs+1@@ -132,6 +132,7 @@ pub fn make_test_project(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> if lbr_path.is_empty() { return json!({"success": false, "error": "libraryPath (.lbr, Windows path) required.", "_hint": "Stage the component page's .lbr onto the box first (send_files)."}); } let lbr_text = match std::fs::read(&lbr_path) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "error": format!("could not read {lbr_path}: {e}")}) }; let deviceset = match args["deviceset"].as_str().filter(|s| !s.is_empty()) { Some(d) => d.to_string(), None => match regex::Regex::new(r#"<deviceset name="([^"]+)""#).unwrap().captures(&lbr_text) { Some(c) => c[1].to_string(), None => return json!({"success": false, "error": "no deviceset found in the library"}) } };+ crate::palette::say(&format!("Generating a test project around {deviceset}"), "a schematic and a board from EAGLE source, then Fusion imports both", None, crate::palette::eta_for_verb(&ctx.state_dir, "make_test_project")); let ex = match mtp_extract(&lbr_text, &deviceset, args["device"].as_str()) { Ok(e) => e, Err(e) => return json!({"success": false, "error": e}) }; let lib_name: String = regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(lbr_path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s).unwrap_or(""), "_").to_string(); let mut urn = String::new();
rust/bridge/src/tour.rs+60−12@@ -15,8 +15,8 @@ use std::time::{Duration, Instant}; pub const PANEL_ID: &str = "fusion-tour"; const CAPTION_ID: &str = "fusion-tour"; const ACK_ID: &str = "fusion-tour-ack";-/// Caption glyph size: 32px, the size ab called medium before v2.0.84 grew it to 48px.-const CAPTION_PX: u32 = 32;+/// The share of the primary work area Fusion gets for the show (John, 2026-09-05: 95%).+const WINDOW_SHARE: f64 = 0.95; /// The board the schematic, 2D and 3D beats show: a real design from the user's hub (John, /// 2026-09-04: the generated one-chip board looked lame; show Adonis's BQ25792 charger). The /// generated solo board stays as the fallback for a hub without it. Override with args.board.@@ -56,6 +56,8 @@ static NARR: Mutex<Option<(String, usize)>> = Mutex::new(None); static BEAT_BUSY: AtomicBool = AtomicBool::new(false); static PAUSED: AtomicBool = AtomicBool::new(false); static GENERIC_USED: Mutex<Vec<String>> = Mutex::new(Vec::new());+/// The tour's state dir, for detached helpers (the in-Fusion palette) that need their own Ctx.+static STATE_DIR: Mutex<Option<std::path::PathBuf>> = Mutex::new(None); /// One timestamped event in the record (post-production cuts on these). fn event(name: &str, detail: Value) { let at = since_start(); rec(|s| push(s, "events", json!({"at": at, "event": name, "beat": s["step"].clone(), "detail": detail}))); }@@ -85,7 +87,7 @@ fn caption(text: &str, ms: u64) { // remote; 32px (the old medium) and one short line keep clear of it. ab's own warnings // (too_wide_for_screen, too_tall_for_screen) go into the record so a wrap never passes silently. let at0 = since_start();- let r = ab("desktop_caption", json!({"id": CAPTION_ID, "text": text, "duration": ms, "size": "medium", "fontSize": CAPTION_PX, "position": "bottom", "reason": "Narrate the Fusion tour the user started, one line per beat"}), 6);+ let r = ab("desktop_caption", json!({"id": CAPTION_ID, "text": text, "duration": ms, "size": "medium", "position": "bottom", "reason": "Narrate the Fusion tour the user started, one line per beat"}), 6); let ok = r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true); let at = at0; let warns: Vec<String> = r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w["check"].as_str().map(str::to_string)).collect()).unwrap_or_default();@@ -93,7 +95,7 @@ fn caption(text: &str, ms: u64) { } fn caption_clear() { let _ = ab("desktop_caption", json!({"action": "hide", "id": CAPTION_ID, "reason": "The user stopped the Fusion tour; clear its caption"}), 6); } fn ack(text: &str) {- let r = ab("desktop_caption", json!({"id": ACK_ID, "text": text, "duration": 3500, "size": "medium", "fontSize": CAPTION_PX, "position": "top", "reason": "Acknowledge a press on the Fusion tour remote the instant it arrives"}), 6);+ let r = ab("desktop_caption", json!({"id": ACK_ID, "text": text, "duration": 3500, "size": "medium", "position": "top", "reason": "Acknowledge a press on the Fusion tour remote the instant it arrives"}), 6); rec(|s| push(s, "captions", json!({"text": text, "ack": true, "ok": r["success"].as_bool() == Some(true), "error": r["error"].as_str().filter(|e| !e.is_empty())}))); } fn audio_dir() -> std::path::PathBuf { std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.join("resources").join("demo-audio"))).unwrap_or_default() }@@ -118,6 +120,23 @@ fn play_file(name: &str, kind: &str) { #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); } match cmd.spawn() { Ok(c) => { *AUDIO.lock().unwrap() = Some((c, name.to_string())); rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "started": true, "at": at, "file": f.to_string_lossy().replace('\\', "/")}))); } Err(e) => rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "error": e.to_string()}))) } }+/// The in-Fusion palette ("Adom is driving Fusion"): show / update / hide through the add-in's+/// activity_banner, fire-and-forget on a thread with its own Ctx. The palette carries the+/// caption's line, the beat and the percent, so Fusion itself shows the work getting done.+fn banner(action: &str, title: &str, detail: &str, percent: Option<u64>) {+ let Some(sd) = STATE_DIR.lock().unwrap().clone() else { return };+ let caller = caller();+ let args = json!({"action": action, "title": title, "detail": detail, "percent": percent, "tone": "working"});+ std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ let side = |_v: &str, _a: Value, _t: u64| -> Value { json!({"success": false, "error": "no sidecar"}) };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd, caller, pids, sidecar: &side, dialogs: &dlg };+ let r = crate::addin::pass_through("activity_banner", &args, &ctx);+ rec(|s| { s["palette"] = json!({"action": args["action"], "ok": r["success"].as_bool() == Some(true), "error": r["error"].as_str().filter(|e| !e.is_empty()), "at": 0}); });+ });+} /// The narrator thread: whenever the current beat is still busy on screen and nothing is playing, /// play the beat's next filler (<beat>-f<n>.mp3), then the generic pool, each once per tour. It /// never starts a clip while paused or muted, and never once the beat only waits for audio, so a@@ -257,10 +276,10 @@ fn place_fusion(sd: &std::path::Path, hwnd: i64, title: &str) -> Value { let Some(wa) = primary_work_area() else { return json!({"placed": false, "why": "no monitor bounds"}) }; let (mx, my, mw, mh) = (wa["x"].as_i64().unwrap_or(0), wa["y"].as_i64().unwrap_or(0), wa["width"].as_i64().unwrap_or(0), wa["height"].as_i64().unwrap_or(0)); if mw == 0 || mh == 0 { return json!({"placed": false, "why": "empty work area"}); }- let (w, h) = (mw * 8 / 10, mh * 8 / 10);+ let (w, h) = ((mw as f64 * WINDOW_SHARE) as i64, (mh as f64 * WINDOW_SHARE) as i64); let (x, y) = (mx + (mw - w) / 2, my + (mh - h) / 2); save_bounds(sd, hwnd, title);- let r = ab("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": "Fusion tour: place Fusion at 80% of the screen, centred"}), 8);+ let r = ab("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": "Fusion tour: place Fusion at 95% of the screen, centred"}), 8); json!({"placed": r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true), "x": x, "y": y, "w": w, "h": h, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())}) } fn restore_bounds(sd: &std::path::Path, source: &str) -> usize {@@ -324,13 +343,23 @@ fn verify_beat(hwnd: i64, expected_doc: &str) -> Value { } v["settleMs"] = json!(t0.elapsed().as_millis() as u64); let remote = v["frontTitle"].as_str().map(is_remote_viewer).unwrap_or(false);+ let own_popup = front_ok(hwnd) && v["front"].as_bool() != Some(true) && !remote; if v["front"].as_bool() != Some(true) && remote { v["frontNote"] = json!("a remote-desktop viewer is on top; front cannot be proven from here, layout and motion can"); }- v["ok"] = json!((v["front"].as_bool() == Some(true) || remote) && v["title"].as_bool() != Some(false) && v["painted"].as_bool() != Some(false));+ if own_popup { v["frontNote"] = json!("one of Fusion's own popups (the palette host) is on top; counted as front"); }+ v["ok"] = json!((v["front"].as_bool() == Some(true) || remote || own_popup) && v["title"].as_bool() != Some(false) && v["painted"].as_bool() != Some(false)); v } /// RustDesk and friends sit on top of everything on a box driven over remote desktop. fn is_remote_viewer(title: &str) -> bool { let t = title.to_lowercase(); ["rustdesk", "remote desktop", "anydesk", "teamviewer", "vnc", "parsec"].iter().any(|k| t.contains(k)) }-fn front_ok(hwnd: i64) -> bool { match top_window() { Some(t) => t["hwnd"].as_i64() == Some(hwnd) || t["title"].as_str().map(is_remote_viewer).unwrap_or(false), None => false } }+/// Windows that may sit above Fusion without meaning the user switched away: a remote-desktop+/// viewer, Fusion's own popups (the in-Fusion palette renders in a WebView popup host), and the+/// shell's popup host. Everything else in front is the user's business and stops motion.+fn front_ok(hwnd: i64) -> bool {+ match top_window() {+ Some(t) => t["hwnd"].as_i64() == Some(hwnd) || t["title"].as_str().map(is_remote_viewer).unwrap_or(false) || t["image"].as_str().map(|i| i.eq_ignore_ascii_case("Fusion360.exe")).unwrap_or(false) || matches!(t["title"].as_str(), Some("PopupHost")),+ None => false,+ }+} fn is_front(hwnd: i64) -> bool { top_window().and_then(|t| t["hwnd"].as_i64()) == Some(hwnd) } fn foreground_once(hwnd: i64, why: &str) -> bool { let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, once")}), 8);@@ -445,7 +474,21 @@ fn wait_active(pass: &dyn Fn(&str, Value) -> Value, pred: &dyn Fn(&str) -> bool, false } -fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {+/// Retry a verb while the add-in reports its main thread busy with someone else's command (the+/// dashboard polls the bridge during a tour); bounded to ~12 s. ConfRoomROG 2026-09-05: the 2D board+/// beat failed on a 0.1 s get_app_state from the dashboard.+fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) -> Value {+ let t0 = Instant::now();+ loop {+ let r = pass(verb, args.clone());+ let busy = r["success"].as_bool() != Some(true) && r["error"].as_str().map(|e| e.contains("main thread busy") || e.contains("Fusion is busy") || e.contains("busy_")).unwrap_or(false);+ if !busy || t0.elapsed() > Duration::from_secs(12) || EXIT.load(Ordering::Relaxed) { return r; }+ sleep_s(0.4);+ }+}+fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {+ let pass_owned = |verb: &str, a: Value| pass_patient(pass_raw, verb, a);+ let pass: &dyn Fn(&str, Value) -> Value = &pass_owned; rec(|s| s["bridgeVersion"] = json!(env.version)); let sd = ctx.state_dir.clone(); let t0 = Instant::now();@@ -460,6 +503,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E let _ = crate::etiquette::clear_badge(&caller()); if let Some((h, _)) = fusion_hwnd() { let _ = ab("desktop_taskbar", json!({"hwnd": h, "progress": {"state": "none"}}), 6); } let tp_base = crate::testproject::test_project(&sd).and_then(|t| t["docBase"].as_str().map(str::to_string)).unwrap_or_default();+ *STATE_DIR.lock().unwrap() = Some(sd.clone()); let stale = close_named_docs(pass, &["adomdemosample".into(), tp_base.clone()]); let docs_before = crate::orchestrations::open_doc_names(pass); rec(|s| { s["staleDocsClosed"] = json!(stale); s["docsBefore"] = json!(docs_before.len()); });@@ -589,7 +633,9 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E "board2d" => { caption(&format!("Fusion tour 6/{n}: the {} in 2D - real copper, real routing", if hub_board { board_name.clone() } else { "board".to_string() }), 30000); audio_play(if hub_board { "board2d" } else { "board2d-solo" });- let act = if hub_board { pass("activate_document", json!({"name": board_name}))["success"].as_bool() == Some(true) } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+ // the hub design carries schematic, board and 3D as one document set with one name:+ // never activate by substring (four documents share it); switch views inside it+ let act = if hub_board { true } else { crate::testproject::test_project_activate(ctx, "brd", pass) }; let o = pass("show_2d_board", json!({})); r["activatedBoard"] = json!(act); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone(); expected = project_base.clone(); kind = "2d";@@ -597,7 +643,8 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E "board3d" => { caption(&format!("Fusion tour 7/{n}: the {} in 3D{}", if hub_board { board_name.clone() } else { "board".to_string() }, if !hub_board && bound { ", carrying the chip minted a minute ago" } else { "" }), 30000); audio_play(if hub_board { "board3d" } else { "board3d-solo" });- let act = if hub_board { true } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+ let act = if hub_board { pass("show_2d_board", json!({}))["success"].as_bool() == Some(true) } else { crate::testproject::test_project_activate(ctx, "brd", pass) };+ if act { sleep_s(1.5); } let was = active_doc(pass); let o = pass("show_3d_board", json!({})); r["activatedBoard"] = json!(act); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();@@ -683,6 +730,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E let my_id = s["demoId"].as_str().unwrap_or("").to_string(); let close_t0 = Instant::now(); let closed = crate::mint::close_docs_opened_since(ctx, &docs_before, &[], pass);+ crate::palette::hide(); rec_for(&sd, &my_id, |s| { s["tourDocsClosed"] = json!(closed); s["closeSec"] = json!(close_t0.elapsed().as_secs()); }); if snap()["demoId"].as_str() == Some(my_id.as_str()) { let s = snap(); crate::demo::persist_pub(&sd, &s); } }@@ -751,7 +799,7 @@ pub fn progress(args: &Value) -> Value { let s = snap(); if s.is_null() { return json!({"success": false, "error": "no tour has been started", "_hint": "fusion_demo {mode:'tour'} starts one."}); } let mut out = json!({"success": true});- for (k, v) in s.as_object().unwrap() { if k != "shots" && k != "started" { out[k] = v.clone(); } }+ for (k, v) in s.as_object().unwrap() { if k != "shots" { out[k] = v.clone(); } } let all = s["shots"].as_array().cloned().unwrap_or_default(); out["shotCount"] = json!(all.len()); let since = args["sinceShot"].as_u64().unwrap_or(0) as usize;