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)
16 files changed
+176−54
BRIDGE_VERSION+1−1bridge.json+1−1contracts/describe.json+16dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+2install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+7rust/bridge/src/addin.rs+14−2rust/bridge/src/main.rs+2−2rust/bridge/src/palette.rs+17−3rust/bridge/src/tour.rs+79−13rust/core/src/dialogs.rs+21BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.366+1.9.367
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.366",+ "version": "1.9.367", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+16@@ -1699,6 +1699,22 @@ "summary": "Full tool list from Autodesk's Fusion MCP server INCLUDING input schemas, so you can construct a valid fusion_mcp_call. These are Autodesk's tools, not this bridge's.", "timeoutSeconds": 45 },+ {+ "example": {},+ "input": {+ "clip": "required str (clip name without .mp3)"+ },+ "longRunning": false,+ "name": "fusion_narrate",+ "output": {+ "clip": "str",+ "file": "str",+ "startedAt": "float"+ },+ "statusVerb": null,+ "summary": "Play one bundled narration clip on the box now (the guided tour's voice): dock, dashboard, or any <beat>[-a|-b] clip in resources/demo-audio. A driver speaks the dock bar and dashboard lines with it before the tour starts. Returns startedAt (epoch seconds) so a recording can be cut on it; the tour's own narration stops it when a beat begins.",+ "timeoutSeconds": 15+ }, { "example": { "brdPath": "C:/Users/me/adom-lib/MyBoard.brd",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.366"+version = "1.9.367" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.366"+version = "1.9.367" edition = "2021" [dependencies]
describe.py+2@@ -137,6 +137,8 @@ _T = [ {"action": "str", "space": "str", "data": "{result}"}, 60, None, True, {}), ("fusion_interactive", "Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on a trivial script (probeMainThread:false skips that and costs nothing). interactive:true means drive it; false means a load, a cloud open, or a bind is still running. The guided tour's narrator polls this every 1.5s and stamps fusion-busy / fusion-interactive transitions into the tour record, so a recording can be cut or sped up on them.", {"probeMainThread": "optional bool (default true)"}, {"interactive": "bool", "addinUp": "bool", "addinBusy": "bool", "activeJobs": "bool|null", "mainThreadMs": "int|null"}, 20, None, False, {}),+ ("fusion_narrate", "Play one bundled narration clip on the box now (the guided tour's voice): dock, dashboard, or any <beat>[-a|-b] clip in resources/demo-audio. A driver speaks the dock bar and dashboard lines with it before the tour starts. Returns startedAt (epoch seconds) so a recording can be cut on it; the tour's own narration stops it when a beat begins.",+ {"clip": "required str (clip name without .mp3)"}, {"clip": "str", "startedAt": "float", "file": "str"}, 15, None, False, {}), ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.", {}, {"rawXml": "str"}, 60, None, False, {}), ("fusion_alerts", "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",
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.366"+DASH_VERSION="1.9.367" 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.366",+ "version": "1.9.367", "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.366",+ "version": "1.9.367", "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.366"+version = "1.9.367" edition = "2021" license = "MIT"
rust/PORTING.md+7@@ -575,3 +575,10 @@ port learned: - **A release restarts the exe under a running tour.** ab pulled 1.9.365 mid-run and the record vanished (run 25). Pin the bridge cache on a demo box with `bridge_dev_mode` before recording, and never release while a tour runs anywhere you cannot see.+- **The bridge's own palette was being searched as a dialog (2026-09-05, run 35).** Every+ doc-switching verb paid 13 s in the post-envelope: the activity palette is a 760x820 Qt tool+ window titled "Adom", the dialog filter took it for a candidate prompt, and each sweep ran a+ UIA control search over its HTML (EDIT 16.6 s, execute_text_command 15 s). `qt_dialog_rows`+ now skips `BRIDGE_PALETTE_TITLE`; EDIT is 3.3 s, execute_text_command under 2 s. Lesson: when a+ verb is slow, ask the verb. Slow pass-throughs (2 s and up) now answer `_timing`+ {preSec, addinSec, dialogsSec, postSec} so the envelope can never hide behind the work again.
rust/bridge/src/addin.rs+14−2@@ -225,6 +225,8 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { // a direct add-in hop for the etiquette's own probes (document_info, get_app_state) let direct = |verb: &str, a: Value, t: u64| call_addin(verb, &a, t, sidecar); let pre = crate::etiquette::pre(ctx.host, &ctx.state_dir, &ctx.caller, cmd, args, &ctx.pids, &direct);+ let t_pre = t0.elapsed().as_secs_f64();+ let t_call_done: std::cell::Cell<f64> = std::cell::Cell::new(0.0); let args_owned: Value = pre.args.clone(); let args = &args_owned; let ran_bg = pre.ran_backgrounded;@@ -255,9 +257,11 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { return r; } let r = call_addin(cmd, args, timeout, sidecar);+ t_call_done.set(t0.elapsed().as_secs_f64()); if MUTATING.contains(&cmd) { return dialogs(r); } r })();+ let t_body = t0.elapsed().as_secs_f64(); let mut res = res; if ran_bg && res.is_object() { if res.get("windowEtiquette").is_none() { res["windowEtiquette"] = json!("background"); }@@ -266,8 +270,16 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { res["_hint"] = json!(match h { Some(h) if !h.contains(note) => format!("{h} {note}"), Some(h) => h, None => note.to_string() }); } let _ = window_raising;- let res = crate::etiquette::post(&ctx.state_dir, &ctx.caller, cmd, args, res, &pre, since, &ctx.pids, &direct);- let _ = t0;+ let mut res = crate::etiquette::post(&ctx.state_dir, &ctx.caller, cmd, args, res, &pre, since, &ctx.pids, &direct);+ // where a slow verb spent its time: the etiquette envelope around the add-in call is+ // ab traffic (window facts, dialog sweeps), and it must never dwarf the work itself+ let total = t0.elapsed().as_secs_f64();+ if total >= 2.0 && res.is_object() {+ let call_end = if t_call_done.get() > 0.0 { t_call_done.get() } else { t_body };+ let r2 = |x: f64| (x * 100.0).round() / 100.0;+ res["_timing"] = json!({"totalSec": r2(total), "preSec": r2(t_pre), "addinSec": r2(call_end - t_pre), "dialogsSec": r2(t_body - call_end), "postSec": r2(total - t_body)});+ eprintln!("[fusion-bridge] {cmd} took {:.1}s: pre {:.1}s, add-in {:.1}s, dialogs {:.1}s, post {:.1}s", total, t_pre, call_end - t_pre, t_body - call_end, total - t_body);+ } res }
rust/bridge/src/main.rs+2−2@@ -400,10 +400,10 @@ fn main() { v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),+ v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass), "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) }, "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),- "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },+ "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "narrate" => tour::narrate(&parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) }, "demo_control" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::control(&parsed["args"]) } else { demo::demo_control(&parsed["args"]) }, "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION }), "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) }
rust/bridge/src/palette.rs+17−3@@ -16,7 +16,11 @@ use std::time::{Duration, Instant}; static WORKER: Once = Once::new(); static DIRTY: AtomicBool = AtomicBool::new(false); static HIDE: AtomicBool = AtomicBool::new(false);+/// While held (a tour in flight) the palette is never taken down by the idle timer: created once,+/// repainted in place, so no per-beat creation cost (the add-in retries delivery 3 s on creation).+static HOLD: AtomicBool = AtomicBool::new(false); static SEQ: AtomicU64 = AtomicU64::new(0);+static PAINTED_SEQ: AtomicU64 = AtomicU64::new(0); static LAST: Mutex<Option<(String, String, Option<f64>, Option<f64>, u64)>> = Mutex::new(None); /// What the worker last did, for fusion_interactive: (action, title, success, error, seconds ago). static LAST_RESULT: Mutex<Option<(String, String, bool, String, Instant)>> = Mutex::new(None);@@ -37,15 +41,18 @@ fn worker() { } if DIRTY.load(Ordering::Relaxed) { let Some((t, d, p, e, seq)) = LAST.lock().unwrap().clone() else { DIRTY.store(false, Ordering::Relaxed); continue };+ if PAINTED_SEQ.load(Ordering::Relaxed) >= seq { DIRTY.store(false, Ordering::Relaxed); continue; }+ std::thread::sleep(Duration::from_secs(2)); // let the caller's verb go first let r = send("show", &t, &d, p, e); *LAST_RESULT.lock().unwrap() = Some(("show".into(), t.clone(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now())); if r["success"].as_bool() == Some(true) {- shown = true; painted_seq = seq; painted_at = Instant::now();+ shown = true; painted_seq = seq; painted_at = Instant::now(); PAINTED_SEQ.store(seq, Ordering::Relaxed); if SEQ.load(Ordering::Relaxed) == seq { DIRTY.store(false, Ordering::Relaxed); } } else { std::thread::sleep(Duration::from_secs(2)); } continue; }- if shown && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {+ if !shown && PAINTED_SEQ.load(Ordering::Relaxed) > painted_seq { shown = true; painted_seq = PAINTED_SEQ.load(Ordering::Relaxed); painted_at = Instant::now(); }+ if shown && !HOLD.load(Ordering::Relaxed) && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() { if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; } else { painted_at = Instant::now(); } }@@ -58,8 +65,15 @@ pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) { WORKER.call_once(|| { std::thread::spawn(worker); }); let seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1; *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string(), percent, eta, seq));- DIRTY.store(true, Ordering::Relaxed);+ // Paint NOW, on the caller's thread, before the caller's own verb goes to the add-in: a+ // background paint raced the tour's Electron commands and each collision cost a 15 s retry+ // (ConfRoomROG, 2026-09-05). The worker only picks it up if this attempt did not land.+ let r = send("show", title, detail, percent, eta);+ *LAST_RESULT.lock().unwrap() = Some(("show".into(), title.to_string(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now()));+ if r["success"].as_bool() == Some(true) { PAINTED_SEQ.store(seq, Ordering::Relaxed); } else { DIRTY.store(true, Ordering::Relaxed); } }+/// Keep the palette up between messages (a tour in flight) or let the idle timer take it down.+pub fn hold(on: bool) { HOLD.store(on, Ordering::Relaxed); } /// Take the palette down now (the tour's teardown, a cancelled run). pub fn hide() {
rust/bridge/src/tour.rs+79−13@@ -105,7 +105,19 @@ fn audio_playing() -> bool { let mut g = AUDIO.lock().unwrap(); match g.as_mut() fn audio_play(beat: &str) { rec(|s| s["narrationBeat"] = json!(beat)); *NARR.lock().unwrap() = Some((beat.to_string(), 0));- play_file(beat, "main");+ // part A clip if the beat has one; else the main clip only when no part B clip exists+ let a = format!("{beat}-a"); let b = format!("{beat}-b");+ if audio_dir().join(format!("{a}.mp3")).is_file() { play_file(&a, "part-a"); }+ else if !audio_dir().join(format!("{b}.mp3")).is_file() { play_file(beat, "main"); }+}+/// Part B: the content is on screen and moving. Its clip replaces whatever filler is on air+/// (after letting a sentence end, briefly); falls back to the main clip when the beat has no B.+fn audio_play_b(beat: &str) {+ let b = format!("{beat}-b");+ let name = if audio_dir().join(format!("{b}.mp3")).is_file() { b } else { beat.to_string() };+ let t0 = Instant::now();+ while audio_playing() && t0.elapsed() < Duration::from_secs(3) { sleep_s(0.2); }+ play_file(&name, "part-b"); } /// Play one clip from resources/demo-audio (kind = main | filler); records the stamp and the file. fn play_file(name: &str, kind: &str) {@@ -364,6 +376,14 @@ fn is_front(hwnd: i64) -> bool { top_window().and_then(|t| t["hwnd"].as_i64()) = fn foreground_once(hwnd: i64, why: &str) -> bool { let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, once")}), 8); for _ in 0..6 { sleep_s(0.25); if is_front(hwnd) { return true; } }+ // Windows keeps the foreground with the app the user last clicked (the dashboard in Hydrogen):+ // the user asked for the tour there, so that window steps behind Fusion, once+ if let Some(top) = top_window() { if let Some(th) = top["hwnd"].as_i64() { if th != hwnd && !top["title"].as_str().map(is_remote_viewer).unwrap_or(false) {+ let _ = ab("desktop_set_window_state", json!({"hwnd": th, "state": "bottom", "reason": format!("Fusion tour: {why}; the window in front steps behind Fusion once")}), 8);+ sleep_s(0.4);+ let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: {why}, after lowering the window in front")}), 8);+ for _ in 0..6 { sleep_s(0.25); if is_front(hwnd) { return true; } }+ } } } false } @@ -415,6 +435,18 @@ out={{'action':'style','style':'{st}'}}") } if r["success"].as_bool() == Some(true) { r["_hint"] = json!("Verify by capture: desktop_screenshot_window before and after; the tour records viewChanged this way."); } r }+/// `fusion_narrate {clip}`: play one bundled narration clip on the box now (the dock bar and+/// dashboard lines a driver speaks before the tour starts). Returns the start epoch and the+/// clip length so a recording can be cut on it.+pub fn narrate(args: &Value) -> Value {+ let clip = args["clip"].as_str().unwrap_or("").trim().trim_end_matches(".mp3").to_string();+ if clip.is_empty() || clip.contains('/') || clip.contains('\\') { return json!({"success": false, "error": "clip required: a bundled narration name such as dock or dashboard"}); }+ let f = audio_dir().join(format!("{clip}.mp3"));+ if !f.is_file() { return json!({"success": false, "error": format!("no bundled clip {clip}.mp3"), "_hint": "resources/demo-audio in the bridge cache lists the clips"}); }+ let at = now();+ play_file(&clip, "prelude");+ json!({"success": true, "clip": clip, "startedAt": at, "file": f.to_string_lossy().replace('\\', "/"), "_hint": "Playing on the box now; the tour's own narration stops it when a beat starts."})+} /// `fusion_interactive`: is Fusion ready for input right now? The add-in's /status busy flag (a /// long command of ours in flight), Fusion's own active-jobs flag, and how long the main thread /// took to answer a trivial script. The tour's narrator polls this to stamp busy stretches.@@ -489,7 +521,11 @@ fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) -> 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(20) || EXIT.load(Ordering::Relaxed) { return r; }+ if !busy || t0.elapsed() > Duration::from_secs(20) || EXIT.load(Ordering::Relaxed) {+ let secs = t0.elapsed().as_secs_f64();+ if secs > 1.5 { event("verb", json!({"verb": verb, "secs": (secs * 10.0).round() / 10.0, "ok": r["success"]})); }+ return r;+ } sleep_s(0.4); } }@@ -511,9 +547,19 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env 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()]);+ crate::palette::hold(true);+ let board_for_clean = args["board"].as_str().unwrap_or(DEMO_BOARD).to_lowercase();+ let stale = close_named_docs(pass, &["adomdemosample".into(), tp_base.clone(), "ads8588sipm_3d".into(), "adom-test-".into(), board_for_clean, "untitled".into()]);+ // Fusion takes the screen the instant the tour starts: forward once, placed at 95%+ if let Some((h, t)) = fusion_hwnd() { let placed = place_fusion(&sd, h, &t); let fg = foreground_once(h, "the tour is starting"); rec(|s| { s["placed"] = json!(placed["placed"].as_bool() == Some(true)); s["placedAtStart"] = placed; s["foregroundedAtStart"] = json!(fg); }); } let docs_before = crate::orchestrations::open_doc_names(pass); rec(|s| { s["staleDocsClosed"] = json!(stale); s["docsBefore"] = json!(docs_before.len()); });+ // the hub lookup for the board beat runs now, beside the first beats (run 36: it cost the+ // schematic beat 30 s of dead air when it ran inline)+ let mut board_prefetch: Option<std::thread::JoinHandle<Option<(String, String)>>> = if args["boardUrn"].is_string() { None } else {+ let bn = args["board"].as_str().unwrap_or(DEMO_BOARD).to_string();+ Some(std::thread::spawn(move || find_hub_design(&bn, DEMO_BOARD_PROJECT)))+ }; let mut results: Vec<Value> = Vec::new(); let mut i = 0usize; let mut exited = false;@@ -544,6 +590,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env panel_show(i, None); BEAT_BUSY.store(true, Ordering::Relaxed); event("beat-start", json!({"index": i + 1, "key": key}));+ event("part-a", json!({"key": key})); let beat_t0 = Instant::now(); let mut r = json!({"step": key, "label": label, "ok": false}); let mut expected = String::new();@@ -608,7 +655,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env } "schematic" => { // a real board from the hub first; the generated solo board only when the hub has none- let found = match args["boardUrn"].as_str() { Some(u) => Some((u.to_string(), String::new())), None => find_hub_design(&board_name, DEMO_BOARD_PROJECT) };+ let found = match args["boardUrn"].as_str() { Some(u) => Some((u.to_string(), String::new())), None => match board_prefetch.take() { Some(h) => h.join().ok().flatten(), None => find_hub_design(&board_name, DEMO_BOARD_PROJECT) } }; if let Some((urn, proj)) = found.filter(|_| !EXIT.load(Ordering::Relaxed)) { caption(&format!("Fusion tour 5/{n}: opening the {board_name} board from your hub (about a minute)"), 90000); audio_play("schematic");@@ -700,6 +747,8 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env let parts = match key { "schematic" => part_positions(ctx, env, pass, "Instance"), "board2d" | "board3d" => part_positions(ctx, env, pass, "Element"), _ => Vec::new() }; let plan = plan_for(key, &sample, &parts, &board_name); r["parts"] = json!(parts.len());+ event("part-b", json!({"key": key}));+ audio_play_b(key); event("motion-start", json!({"kind": kind, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>(), "parts": parts.len()})); idle_boxes = plan.iter().filter(|p| p.1["action"] == "pan").map(|p| p.1.clone()).collect(); idle_kind = kind.to_string();@@ -707,7 +756,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env event("motion-done", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]})); } else if kind != "none" { r["motion"] = json!({"skipped": format!("not verified: front={} title={} painted={} (top window: {})", v["front"], v["title"], v["painted"], v["frontTitle"])});- }+ } else if v["ok"].as_bool() == Some(true) { event("part-b", json!({"key": key})); audio_play_b(key); } } else { r["verified"] = json!({"why": "no Fusion window"}); } } // evidence frame at the moment the beat completes@@ -752,13 +801,13 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env 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();+ crate::palette::hold(false); 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); } } fn bbox(pts: &[(f64, f64)]) -> Option<(f64, f64, f64, f64)> { if pts.is_empty() { return None; } Some((pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max))) }-fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 18}) }+fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 12, "frameDelayMs": 10}) } /// Positions of the parts on the active board or schematic (mm), from the electronics read model. fn part_positions(ctx: &Ctx, env: &Env, pass: &dyn Fn(&str, Value) -> Value, entity: &str) -> Vec<(String, String, f64, f64)> { let r = crate::electronics::query(ctx, &json!({"entity": entity, "fields": ["name", "value", "x", "y"], "limit": 600}), env.version, pass);@@ -778,16 +827,23 @@ fn plan_for(key: &str, sample: &Sample, parts: &[(String, String, f64, f64)], bo let Some((x1, y1, x2, y2)) = bbox(&sample.pin_pts) else { return zoom_only() }; let (w, h) = (x2 - x1, y2 - y1); let m = 3.0; vec![("fit", fit2()),- ("top-left pins", pan_box(x1 - m, y1 + h * 0.55, x1 + w * 0.5, y2 + m)),- ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.45)),- ("right pins", pan_box(x1 + w * 0.5, y1 + h * 0.35, x2 + m, y2 + m)),+ ("top-left pins", pan_box(x1 - m, y1 + h * 0.6, x1 + w * 0.5, y2 + m)),+ ("mid-left pins", pan_box(x1 - m, y1 + h * 0.3, x1 + w * 0.5, y1 + h * 0.7)),+ ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.4)),+ ("bottom-right pins", pan_box(x1 + w * 0.5, y1 - m, x2 + m, y1 + h * 0.4)),+ ("top-right pins", pan_box(x1 + w * 0.5, y1 + h * 0.6, x2 + m, y2 + m)),+ ("whole symbol", pan_box(x1 - m * 2.0, y1 - m * 2.0, x2 + m * 2.0, y2 + m * 2.0)), ("fit-2", fit2())] } "footprint" => { let Some((x1, y1, x2, y2)) = bbox(&sample.pad_pts) else { return zoom_only() }; let (w, h) = (x2 - x1, y2 - y1); let m = 1.5; let (p1x, p1y) = sample.pad_pts.first().cloned().unwrap_or((x1, y1));+ // after WINDOW FIT the footprint fills the canvas; the pointer sweeps the pad rows+ // (window fractions measured on the 4K box: canvas 0.25..0.77 x 0.19..0.87)+ let sweep = json!({"action": "sweep", "path": [[0.42, 0.32], [0.50, 0.32], [0.58, 0.32], [0.62, 0.45], [0.62, 0.60], [0.62, 0.72], [0.50, 0.77], [0.38, 0.70], [0.38, 0.50]]}); vec![("fit", fit2()),+ ("pad sweep", sweep), ("pin 1", pan_box(p1x - 2.5, p1y - 2.5, p1x + 2.5, p1y + 2.5)), ("top row", pan_box(x1 - m, y2 - h * 0.25, x2 + m, y2 + m)), ("far corner", pan_box(x2 - w * 0.35, y1 - m, x2 + m, y1 + h * 0.35)),@@ -828,7 +884,7 @@ fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, Value) let args = if tick % 7 == 6 { json!({"action": "zoom", "factor": if (tick / 7) % 2 == 0 { 1.12 } else { 0.9 }}) } else { json!({"action": "orbit", "degrees": 6}) }; view(pass, &args) } else if !boxes.is_empty() {- let mut b = boxes[(tick as usize) % boxes.len()].clone(); b["space"] = json!("2d"); b["steps"] = json!(30);+ let mut b = boxes[(tick as usize) % boxes.len()].clone(); b["space"] = json!("2d"); b["steps"] = json!(10); view(pass, &b) } else { json!({"success": true}) } }@@ -840,10 +896,20 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec< for (name, body) in plan { if pending_interrupt() { rep["aborted"] = json!(format!("stop requested before {name}")); break; } if !front_ok(hwnd) { rep["aborted"] = json!(format!("window not in front before {name} (user switched away)")); break; }- let o = if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) };+ let o = if body["action"] == "sweep" {+ // the real pointer, no overlay: pads highlight under it+ let mut ok = true;+ for pt in body["path"].as_array().cloned().unwrap_or_default() {+ if pending_interrupt() { break; }+ let r = ab("desktop_hover", json!({"hwnd": hwnd, "fx": pt[0], "fy": pt[1], "cursor": false, "reason": "Fusion tour: sweep the pointer across the pads so they highlight"}), 6);+ if r["success"].as_bool() != Some(true) { ok = false; break; }+ sleep_s(0.18);+ }+ json!({"success": ok, "error": if ok { Value::Null } else { json!("hover refused") }})+ } else if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) }; if o["success"].as_bool() != Some(true) { rep["aborted"] = json!(format!("{name}: {}", o["error"].as_str().unwrap_or("script failed"))); break; } rep["gestures"] = json!(rep["gestures"].as_u64().unwrap_or(0) + 1);- sleep_s(if kind == "2d" { 1.4 } else { 1.1 }); // let the eye land before the next move+ sleep_s(if kind == "2d" { 0.9 } else { 1.0 }); // let the eye land before the next move } let after = shot_window(hwnd).and_then(|p| digest(&p)); rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);
rust/core/src/dialogs.rs+21@@ -34,6 +34,9 @@ pub fn category_set(name: &str) -> Vec<String> { /// `_find_qt_dialog_windows` over ab's window rows: visible Qt-class windows that are not the /// main window, minus small / tall docked tool panels.+/// the title Fusion gives the bridge's activity palette (`ui.palettes.add(PALETTE_ID, "Adom", ...)`)+pub const BRIDGE_PALETTE_TITLE: &str = "Adom";+ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> { let mut out = Vec::new(); for w in rows {@@ -41,6 +44,10 @@ pub fn qt_dialog_rows(rows: &[Value]) -> Vec<Value> { let class = w["className"].as_str().or(w["class"].as_str()).unwrap_or(""); let title = w["title"].as_str().unwrap_or(""); if !class.contains("Qt") || title.contains("Autodesk Fusion") { continue; }+ // the bridge's own activity palette is a 760x820 Qt tool window titled "Adom": a UIA+ // control search over its HTML costs seconds, and it is never a dialog (2026-09-05:+ // every doc-switching verb paid ~13 s of post-envelope for exactly that search)+ if title.trim().eq_ignore_ascii_case(BRIDGE_PALETTE_TITLE) { continue; } let rect = rect_of(&w["rect"]); let (ww, hh) = (rect["width"].as_i64().unwrap_or(0), rect["height"].as_i64().unwrap_or(0)); if class.contains("Tool") && (ww < 400 || hh < 300 || hh > ww * 3 / 2) { continue; }@@ -62,3 +69,17 @@ pub fn rect_of(r: &Value) -> Value { let (w, h) = (r["width"].as_i64().or_else(|| r["right"].as_i64().map(|x| x - l)).unwrap_or(0), r["height"].as_i64().or_else(|| r["bottom"].as_i64().map(|y| y - t)).unwrap_or(0)); serde_json::json!({"left": l, "top": t, "right": l + w, "bottom": t + h, "width": w, "height": h}) }++#[cfg(test)]+mod tests {+ #[test]+ fn own_palette_is_never_a_dialog() {+ let rows = vec![+ serde_json::json!({"className": "Qt683QWindowToolSaveBits", "hwnd": 1379664, "pid": 16004, "rect": [1900, 300, 2660, 1120], "title": "ADOM", "visible": true}),+ serde_json::json!({"className": "Qt683QWindow", "hwnd": 42, "pid": 16004, "rect": [100, 100, 700, 400], "title": "Save changes?", "visible": true}),+ ];+ let out = super::qt_dialog_rows(&rows);+ assert_eq!(out.len(), 1, "{out:?}");+ assert_eq!(out[0]["hwnd"], 42);+ }+}