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 157 file(s)
16 files changed
+690−54
BRIDGE_VERSION+1−1bridge.json+1−1contracts/describe.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+1−1install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+29rust/bridge/Cargo.toml+1rust/bridge/src/demo.rs+3rust/bridge/src/main.rs+10−1rust/bridge/src/tour.rs+574skills/fusion-demo/SKILL.md+53−17BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.359+1.9.360
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.359",+ "version": "1.9.360", "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+1−1@@ -665,7 +665,7 @@ "steps": "[str]" }, "statusVerb": "fusion_demo",- "summary": "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill. TOUR MODE (issue #29): pass {mode:'tour'} for the ASYNC six-content-type showcase - it stages the bundled sample library and walks symbol -> footprint -> device -> 3D package (plus schematic/2D/3D board when a query is given), returns {demoId, estimatedSec, steps} immediately, reports structured progress via fusion_demo_progress for a real progress bar, runs in the BACKGROUND by default (foreground:true only when the user wants to watch), and its final progress poll carries the screenshot array.",+ "summary": "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill. TOUR MODE: pass {mode:'tour'} for the ASYNC guided ELECTRONICS tour (John, 2026-09-04: Adom is an electronics prototyping company, so the tour shows electronics, never a mechanical part): eight beats over the bundled sample part - the library, the symbol, the footprint, the 3D chip minted from its STEP and bound into the user's hub, a generated test project in schematic, 2D board and 3D board, and a cloud search; multipart libraries are mentioned in the narration, never shown (a bind is minutes per part). ab's glass remote (desktop_demo_panel id fusion-tour) drives it: play, pause, next, back, mute, unmute, X = stop, with an instant loopback callback plus a poll backstop, one FIFO for the panel and fusion_demo_control, an ack caption per press; captions narrate every beat and pre-rendered narration audio plays locally (mute is session-only, unmute replays the beat). Every beat is measured before it moves (front, title, painted, stable) and then moves (2D zoom through the add-in, 3D orbit and zoom through Fusion's camera, verified by capture). Returns {demoId, estimatedSec, steps} at once; fusion_demo_progress {demoId} carries step, beatIndex/beatCount, percent, paused, muted, stopping, panel, presses[], captions[], per-step verified/placed/foregrounded/motion, shots[] (one evidence frame per beat), windowsRestored and tourDocsClosed. Fusion is placed on 80% of the primary work area for the show and restored at the end. Pass legacy:true for the previous step-list tour.", "timeoutSeconds": 240 }, {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.359"+version = "1.9.360" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.359"+version = "1.9.360" edition = "2021" [dependencies]
describe.py+1−1@@ -73,7 +73,7 @@ _T = [ {"code": "required str (the 6 digits; non-digits are stripped)", "hwnd": "optional int (the 2FA window; auto-detected)"}, {"submitted": "bool", "steps": "list", "narrate": "str"}, 90, "fusion_readiness", True, {"code": "123456"}),- ("fusion_demo", "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill. TOUR MODE (issue #29): pass {mode:'tour'} for the ASYNC six-content-type showcase - it stages the bundled sample library and walks symbol -> footprint -> device -> 3D package (plus schematic/2D/3D board when a query is given), returns {demoId, estimatedSec, steps} immediately, reports structured progress via fusion_demo_progress for a real progress bar, runs in the BACKGROUND by default (foreground:true only when the user wants to watch), and its final progress poll carries the screenshot array.",+ ("fusion_demo", "FIRST-TIME-USER DEMO in one verb - the thing to run when someone new wants to SEE what this bridge does (HD's installer calls this). Takes them from nothing to a real board: launches Fusion, FINISHES the Autodesk sign-in - it OPENS the sign-in in the user's NATIVE browser IN THE BACKGROUND (never interrupting their work) and returns signinWhere/signinProfile/signinSession so you can tell them exactly where it is waiting, then offer three paths: they finish it, you foreground it, or (with their OK) you drive it. Native browser via ABE/nbrowser_* because that profile is already signed into Autodesk - never pup, which is anonymous, sets up APS cloud search (or explains it if declined) and runs a LIVE sample search so they see 2-second results, then opens an electronics PROJECT and walks SCHEMATIC -> 2D BOARD -> 3D BOARD, screenshotting each view to show them. STAGED + RESUMABLE: every call advances as far as it safely can and returns stage/done/narrate/screenshots plus a _hint with the exact next action; keep calling it until done:true. NEVER leave a first-time user parked on the sign-in screen - that is the failure this verb exists to prevent. See the 'fusion-demo' skill. TOUR MODE: pass {mode:'tour'} for the ASYNC guided ELECTRONICS tour (John, 2026-09-04: Adom is an electronics prototyping company, so the tour shows electronics, never a mechanical part): eight beats over the bundled sample part - the library, the symbol, the footprint, the 3D chip minted from its STEP and bound into the user's hub, a generated test project in schematic, 2D board and 3D board, and a cloud search; multipart libraries are mentioned in the narration, never shown (a bind is minutes per part). ab's glass remote (desktop_demo_panel id fusion-tour) drives it: play, pause, next, back, mute, unmute, X = stop, with an instant loopback callback plus a poll backstop, one FIFO for the panel and fusion_demo_control, an ack caption per press; captions narrate every beat and pre-rendered narration audio plays locally (mute is session-only, unmute replays the beat). Every beat is measured before it moves (front, title, painted, stable) and then moves (2D zoom through the add-in, 3D orbit and zoom through Fusion's camera, verified by capture). Returns {demoId, estimatedSec, steps} at once; fusion_demo_progress {demoId} carries step, beatIndex/beatCount, percent, paused, muted, stopping, panel, presses[], captions[], per-step verified/placed/foregrounded/motion, shots[] (one evidence frame per beat), windowsRestored and tourDocsClosed. Fusion is placed on 80% of the primary work area for the show and restored at the end. Pass legacy:true for the previous step-list tour.", {"query": "optional str (design to demo; else picked from APS search)", "stage": "optional str ('skip_aps' to proceed without cloud search)", "mode": "optional str ('tour' = the async showcase; poll fusion_demo_progress {demoId})", "preset": "optional str ('electronics' = library import, symbol, footprint, 3D chip, ganged component view, schematic, 2D board, 3D board - the eight-stop showcase; omit for the full tour incl. Data Hub, APS, MCP, exports)", "remote": "optional bool (tour mode: show ab's floaty glass PLAY/PAUSE/NEXT/BACK/STOP panel on the desktop; its presses drive the same transport flags as fusion_demo_control, so the dashboard buttons and the remote can be mixed)", "captions": "optional bool (default true: one desktop caption per stop)", "foreground": "optional bool (default false: background envelope, never steals focus)"}, {"stage": "str", "done": "bool", "narrate": "str", "screenshots": "[{label,path}]", "steps": "[str]", "apsSampleSearch": "[{name,project}]"}, 240, "fusion_demo", True, {}),
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.359"+DASH_VERSION="1.9.360" 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.359",+ "version": "1.9.360", "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.359",+ "version": "1.9.360", "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.359"+version = "1.9.360" edition = "2021" license = "MIT"
rust/PORTING.md+29@@ -492,3 +492,32 @@ the ten-minute listener captured [email protected]. `fusion_aps_search` answers on b the board_info XML for Board/Element/Library/Package/Layer/Signal/Wire/Error, one row shape, `source` naming which. The fallback parser is pinned by `tests/fixtures/board_info_sample.json` (the generated test project on ConfRoomROG).++## The guided electronics tour (evening of 2026-09-04)++`fusion_demo {mode:"tour"}` is `rust/bridge/src/tour.rs`: the KiCad bridge's tour rules on the+electronics content John asked for (library, symbol, footprint, minted 3D chip, generated+schematic, 2D and 3D board, cloud search; multipart libraries mentioned, never shown). What the+port learned:++- **A tour beat is measured before it moves.** `verify_beat` reads z-order, live title, canvas+ colours and two captures 0.8 s apart; motion (`electron_zoom` for the EAGLE editors, the+ viewport camera for Designs) runs only on a verified window and is verified by capture digest.+ On ConfRoomROG the remote-desktop viewer sits on top; `is_remote_viewer` soft-gates front and+ records `frontNote` instead of pretending.+- **One FIFO for every source.** The loopback callback (`POST /demo/event`, ab 2.1.17+), the+ poll backstop (deduped by `at`) and `fusion_demo_control` all land in `on_press`, which acks,+ records and queues. Exit sets an atomic the beat loop and every gesture check. ab 2.1.22 on+ ConfRoomROG predates the synthetic press hook (2.1.26), so pause/play/back/stop were proven+ through the callback wire, the dashboard path and real OS clicks on the panel.+- **A detached tour thread writes only to its own record.** A later tour may replace the live+ record while the earlier thread is still closing documents; the fallback that marks an+ ended thread's record failed once marked the NEW tour failed. `rec_for(id)` writes to the+ live record only when the id matches, else to the persisted file.+- **Mark done before the slow cleanup.** Closing the tour's documents takes a minute; the+ record now says done with `tourDocsClosed: "closing"` and fills the list afterwards.+- **The 3D board keeps the board document's name**, so waiting for the active document to+ change never returns; wait for a Design product with bodies instead (`wait_design`).+- **A distributed-design f3d does not import** ("cannot be created from a local file containing+ external references"); that killed the first mechanical draft before John redirected the+ content anyway.
rust/bridge/Cargo.toml+1@@ -15,6 +15,7 @@ regex = "1" sha2 = "0.10" getrandom = "0.2" base64 = "0.22"+png = "0.17" rusqlite = { version = "0.32", features = ["bundled"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } fusion-core = { path = "../core" }
rust/bridge/src/demo.rs+3@@ -44,6 +44,9 @@ fn tour_explain(key: &str) -> Option<&'static str> { fn tours_dir(state_dir: &std::path::Path) -> std::path::PathBuf { let d = state_dir.join("tours"); let _ = std::fs::create_dir_all(&d); d } /// `_demo_tour_persist`: snapshot a FINISHED tour so its done frame is pollable across respawns; keep the last 8.+pub fn sample_lbr_pub() -> Option<String> { sample_lbr() }+pub fn load_pub(state_dir: &std::path::Path, id: &str) -> Option<Value> { load(state_dir, id) }+pub fn persist_pub(state_dir: &std::path::Path, state: &Value) { persist(state_dir, state) } fn persist(state_dir: &std::path::Path, state: &Value) { let d = tours_dir(state_dir); let mut snap = state.clone(); if let Some(o) = snap.as_object_mut() { o.remove("started"); }
rust/bridge/src/main.rs+10−1@@ -38,6 +38,7 @@ mod demo; mod addin_install; mod janitor; mod electronics;+mod tour; use fusion_core::ab::Caller; use fusion_core::{HostPaths, HostProcess}; use serde_json::{json, Value};@@ -360,6 +361,12 @@ fn main() { } let _ = req.respond(json_response(200, &body)); }+ (Method::Post, "/demo/event") => {+ let mut raw = String::new(); let _ = req.as_reader().read_to_string(&mut raw);+ let ev: Value = serde_json::from_str(&raw).unwrap_or(json!({}));+ let reply = tour::on_panel_event(&ev);+ let _ = req.respond(json_response(200, &reply));+ } (Method::Post, "/command") => { let caller = caller_from(&req); let mut raw = String::new();@@ -395,7 +402,9 @@ fn main() { 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") 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), "demo_progress" => demo::demo_progress(&ctx.state_dir, &parsed["args"]), "demo_control" => demo::demo_control(&parsed["args"]),+ "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "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) } else { let readiness = || owned_readiness(&**h, &sidecar, &caller); let aps_state = || native_aps_status(&sidecar, &caller)["data"].clone(); let aps_search = |a: &Value| native_aps_search(a); let start = || launch::start(&ctx, &json!({})); let aps_pair = || { 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})); let signin_fn = |a: &Value| signin::signin(&ctx, a, &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_pair, aps_signin: &aps_signin }); demo::demo(&ctx, &parsed["args"], &demo::DemoEnv { readiness: &readiness, signin: &signin_fn, aps_state: &aps_state, aps_search: &aps_search }, &pass) }, "web_signin" => signin::web_signin(&ctx, &parsed["args"]), "library_manager" => managed::library_manager(&ctx, &parsed["args"], &pass), "managed_library_create" => managed::create(&ctx, &parsed["args"], &pass), "managed_library_link" => managed::link(&ctx, &parsed["args"], &pass), "managed_library_pull" => managed::pull(&ctx, &parsed["args"], &pass), "managed_library_push" => managed::push(&ctx, &parsed["args"], &pass), "managed_library_unlink" => managed::unlink(&ctx, &parsed["args"], &pass),
rust/bridge/src/tour.rsadded+574@@ -0,0 +1,574 @@+//! `fusion_demo {mode:"tour"}`: the guided show of the electronics bridge. Nine beats over the+//! bundled sample part: symbol, footprint, the 3D chip minted and bound into your hub, then a generated project in schematic, 2D and 3D+//! board, and a cloud search; ab's glass remote drives it (instant callback + poll backstop, one FIFO for every source), every beat+//! is measured before it moves (front, title, painted, stable), motion runs through Fusion's+//! own camera, and a progress record carries the evidence. The KiCad bridge's tour is the+//! reference for every rule here (wiki adom/kicad-bridge handlers/demo.py).+use crate::addin::Ctx;+use fusion_core::ab::{AbClient, Caller, Loopback};+use serde_json::{json, Value};+use std::collections::{HashSet, VecDeque};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Mutex;+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";+const PANEL_REASON: &str = "Transport for the Fusion tour the user started: play, pause, next, back, mute, stop";++/// Beats: (key, panel stop label, estimated seconds). John, 2026-09-04: Adom is an electronics+/// prototyping company, so the tour demos symbol, footprint, 3D chip, schematic, 2D board, 3D+/// board and cloud search - never a mechanical part. Multipart libraries are MENTIONED, never+/// shown: a 3D bind is minutes per part, so a shown multipart beat is a nightmare, not a demo.+const BEATS: &[(&str, &str, u64)] = &[+ ("library", "Open the sample library", 30),+ ("symbol", "Symbol: the full pinout", 14),+ ("footprint", "Footprint: pads and layers", 14),+ ("chip", "3D chip: minted and bound", 170),+ ("schematic", "Schematic: the part wired in", 70),+ ("board2d", "2D board: the part on copper", 24),+ ("board3d", "3D board: the chip on it", 34),+ ("aps", "Cloud search over your hub", 8),+];++// ── state ─────────────────────────────────────────────────────────────────────+static TOUR: Mutex<Option<Value>> = Mutex::new(None);+static CTRL: Mutex<VecDeque<String>> = Mutex::new(VecDeque::new());+static EXIT: AtomicBool = AtomicBool::new(false);+static MUTED: AtomicBool = AtomicBool::new(false);+static SEEN_AT: Mutex<Vec<String>> = Mutex::new(Vec::new());+static PANEL_SHOWN: AtomicBool = AtomicBool::new(false);+static AUDIO: Mutex<Option<(std::process::Child, String)>> = Mutex::new(None);+static SAVED_BOUNDS: Mutex<Vec<Value>> = Mutex::new(Vec::new());+static CALLER: Mutex<Option<Caller>> = Mutex::new(None);++fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }+fn sleep_s(s: f64) { std::thread::sleep(Duration::from_secs_f64(s)); }+fn caller() -> Caller { CALLER.lock().unwrap().clone().unwrap_or_default() }+fn ab(verb: &str, args: Value, timeout: u64) -> Value {+ match Loopback::from_env() { Some(a) => match a.call(verb, args, &caller(), timeout) { Ok(v) => v, Err(e) => json!({"success": false, "_adCallError": format!("{e:?}")}) }, None => json!({"success": false, "_adCallError": "ab loopback unavailable"}) }+}+fn rec(f: impl FnOnce(&mut Value)) { if let Some(s) = TOUR.lock().unwrap().as_mut() { f(s); } }+/// Write to the record of tour `id` only: the live one if it still is, else its persisted file.+fn rec_for(sd: &std::path::Path, id: &str, f: impl FnOnce(&mut Value)) {+ { let mut g = TOUR.lock().unwrap(); if let Some(s) = g.as_mut() { if s["demoId"].as_str() == Some(id) { f(s); return; } } }+ if let Some(mut s) = crate::demo::load_pub(sd, id) { f(&mut s); crate::demo::persist_pub(sd, &s); }+}+fn snap() -> Value { TOUR.lock().unwrap().clone().unwrap_or(Value::Null) }+fn push(s: &mut Value, key: &str, v: Value) { let mut a = s[key].as_array().cloned().unwrap_or_default(); a.push(v); s[key] = json!(a); }+fn windows() -> Vec<Value> { crate::managed::find_key(&ab("desktop_list_windows", json!({}), 20), "windows", 0).and_then(|w| w.as_array().cloned()).unwrap_or_default() }+fn fusion_hwnd() -> Option<(i64, String)> { windows().into_iter().find(|w| w["title"].as_str().unwrap_or("").contains("Autodesk Fusion") && w["kind"].as_str() == Some("normal")).and_then(|w| Some((w["hwnd"].as_i64()?, w["title"].as_str().unwrap_or("").to_string()))) }+fn top_window() -> Option<Value> { let mut ws = windows(); ws.retain(|w| w["z"].is_number() && w["kind"].as_str() == Some("normal") && !w["title"].as_str().unwrap_or("").is_empty()); ws.sort_by_key(|w| w["z"].as_i64().unwrap_or(99)); ws.into_iter().next() }++// ── captions, acks, audio ────────────────────────────────────────────────────+fn caption(text: &str, ms: u64) {+ if EXIT.load(Ordering::Relaxed) { return; }+ 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);+ rec(|s| push(s, "captions", json!({"text": text.chars().take(80).collect::<String>(), "ok": ok, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())})));+}+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", "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() }+fn audio_stop() { if let Some((mut c, _)) = AUDIO.lock().unwrap().take() { let _ = c.kill(); } }+fn audio_playing() -> bool { let mut g = AUDIO.lock().unwrap(); match g.as_mut() { Some((c, _)) => c.try_wait().map(|s| s.is_none()).unwrap_or(false), None => false } }+/// Play the beat's pre-rendered line through a hidden WPF MediaPlayer (ab has no audio verb).+fn audio_play(beat: &str) {+ rec(|s| s["narrationBeat"] = json!(beat));+ if MUTED.load(Ordering::Relaxed) { return; }+ let f = audio_dir().join(format!("{beat}.mp3"));+ if !f.is_file() { rec(|s| push(s, "audio", json!({"beat": beat, "missing": true}))); return; }+ audio_stop();+ let ps = format!("Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'{}'); $p.Volume = 0.9; $p.Play(); Start-Sleep -Milliseconds 500; $t0 = Get-Date; while (((Get-Date) - $t0).TotalSeconds -lt 40 -and (-not $p.NaturalDuration.HasTimeSpan -or $p.Position -lt $p.NaturalDuration.TimeSpan)) {{ Start-Sleep -Milliseconds 200 }}", f.to_string_lossy().replace('\\', "/"));+ let mut cmd = std::process::Command::new("powershell");+ cmd.args(["-NoProfile", "-Command", &ps]).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());+ #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }+ match cmd.spawn() { Ok(c) => { *AUDIO.lock().unwrap() = Some((c, beat.to_string())); rec(|s| push(s, "audio", json!({"beat": beat, "started": true}))); } Err(e) => rec(|s| push(s, "audio", json!({"beat": beat, "error": e.to_string()}))) }+}+fn apply_mute(m: bool, source: &str) {+ let was = MUTED.swap(m, Ordering::Relaxed);+ if m && !was { audio_stop(); } else if was && !m { if let Some(b) = snap()["narrationBeat"].as_str().map(str::to_string) { audio_play(&b); } }+ rec(|s| { s["muted"] = json!(m); s["muteSource"] = json!(source); });+}++// ── the remote: one FIFO for every source ────────────────────────────────────+fn queue(cmd: &str) { let mut q = CTRL.lock().unwrap(); if cmd == "exit" { q.clear(); q.push_back("exit".into()); } else if q.len() < 8 { q.push_back(cmd.to_string()); } }+fn pending_interrupt() -> bool { EXIT.load(Ordering::Relaxed) || CTRL.lock().unwrap().iter().any(|c| matches!(c.as_str(), "prev" | "next" | "pause" | "exit")) }+/// One press from any source: record, ack, apply mute at once, queue the transport command.+pub fn on_press(button: &str, at: Value, via: &str) -> Value {+ let b = button.to_lowercase();+ let beat = snap();+ rec(|s| { push(s, "presses", json!({"button": b, "at": at, "via": via, "beat": beat["beatIndex"], "step": beat["step"]})); s["lastPress"] = json!({"button": b, "at": at, "via": via}); });+ let ack_text = match b.as_str() { "mute" => "Remote: muted", "unmute" => "Remote: narration back on", "pause" => "Remote: pausing", "play" | "resume" => "Remote: playing", "next" => "Remote: next", "back" | "prev" => "Remote: back", "stop" | "exit" => "Remote: stopping the tour", _ => "" };+ if !ack_text.is_empty() { ack(ack_text); }+ match b.as_str() {+ "mute" | "unmute" => { apply_mute(b == "mute", via); return json!({"applied": b}); }+ _ => {}+ }+ let cmd = match b.as_str() { "back" | "prev" => "prev", "play" | "resume" => "play", "pause" => "pause", "next" => "next", "stop" | "exit" => "exit", _ => return json!({"ignored": b}) };+ if matches!(cmd, "pause" | "exit" | "next" | "prev") { audio_stop(); }+ if cmd == "exit" { EXIT.store(true, Ordering::Relaxed); caption_clear(); rec(|s| { s["stopping"] = json!(true); s["stepLabel"] = json!("Closing the demo tour"); }); }+ queue(cmd);+ json!({"queued": cmd})+}+/// ab's instant callback (POST /demo/event): {id, button, at, state, stage, muted}.+pub fn on_panel_event(ev: &Value) -> Value {+ if ev["id"].as_str().map(|i| i != PANEL_ID).unwrap_or(false) { return json!({"ignored": true}); }+ if let Some(at) = ev["at"].as_str().map(str::to_string).or_else(|| ev["at"].as_f64().map(|f| f.to_string())) { SEEN_AT.lock().unwrap().push(at); }+ let b = ev["button"].as_str().unwrap_or("");+ if let Some(m) = ev["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) && !matches!(b, "mute" | "unmute") { apply_mute(m, "panel"); } }+ on_press(b, ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "callback-synthetic" } else { "callback" })+}+fn panel_show(stage: usize, state: Option<&str>) -> bool {+ let mut args = json!({"action": "show", "id": PANEL_ID, "stage": stage, "reason": PANEL_REASON});+ if !PANEL_SHOWN.load(Ordering::Relaxed) {+ args["title"] = json!("Fusion bridge tour"); args["stops"] = json!(BEATS.iter().map(|b| b.1).collect::<Vec<_>>()); args["state"] = json!(state.unwrap_or("playing")); args["position"] = json!("bottom-right"); args["muted"] = json!(MUTED.load(Ordering::Relaxed));+ if let Some(u) = crate::launch::SELF_URL.lock().unwrap().clone() { args["eventCallbackUrl"] = json!(u.replace("/command", "/demo/event")); }+ } else if let Some(st) = state { args["state"] = json!(st); }+ let r = ab("desktop_demo_panel", args.clone(), 8);+ let shown = r["shown"].as_bool() == Some(true) || (r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true) && r["_adCallError"].is_null());+ if shown { PANEL_SHOWN.store(true, Ordering::Relaxed); }+ rec(|s| s["panel"] = json!({"shown": shown, "stage": stage, "callbackUrl": args["eventCallbackUrl"].as_str().or(s["panel"]["callbackUrl"].as_str()), "callbackAccepted": r["callbackAccepted"], "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())}));+ shown+}+/// Backstop drain of the panel's own queue; presses the callback already delivered are skipped by `at`.+fn panel_poll() {+ let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6);+ if r["state"].as_str() == Some("gone") { PANEL_SHOWN.store(false, Ordering::Relaxed); let st = snap(); panel_show(st["panel"]["stage"].as_u64().unwrap_or(0) as usize, None); return; }+ if let Some(m) = r["muted"].as_bool() { if m != MUTED.load(Ordering::Relaxed) { apply_mute(m, "panel-poll"); } }+ for ev in r["events"].as_array().cloned().unwrap_or_default() {+ let at = ev["at"].as_str().map(str::to_string).or_else(|| ev["at"].as_f64().map(|f| f.to_string())).unwrap_or_default();+ if !at.is_empty() { let mut seen = SEEN_AT.lock().unwrap(); if seen.contains(&at) { continue; } seen.push(at.clone()); }+ on_press(ev["button"].as_str().unwrap_or(""), ev["at"].clone(), if ev["synthetic"].as_bool() == Some(true) { "poll-synthetic" } else { "poll" });+ }+}+fn drain_stale_presses() { for _ in 0..3 { let r = ab("desktop_demo_panel", json!({"action": "poll", "id": PANEL_ID}), 6); if r["events"].as_array().map(|a| a.is_empty()).unwrap_or(true) { break; } } }+fn panel_hide() { let _ = ab("desktop_demo_panel", json!({"action": "hide", "id": PANEL_ID}), 6); }+fn take_cmd() -> Option<String> { panel_poll(); CTRL.lock().unwrap().pop_front() }++// ── layout ────────────────────────────────────────────────────────────────────+fn primary_work_area() -> Option<Value> {+ let r = ab("desktop_list_monitors", json!({"reason": "Fusion tour: size the window to 80% of the primary screen"}), 6);+ let mons = crate::managed::find_key(&r, "monitors", 0).and_then(|m| m.as_array().cloned()).unwrap_or_default();+ let prim = mons.iter().find(|m| m["primary"].as_bool() == Some(true)).or(mons.first())?;+ Some(if prim["workArea"].is_object() { prim["workArea"].clone() } else { prim["bounds"].clone() })+}+fn leftovers_path(sd: &std::path::Path) -> std::path::PathBuf { sd.join("tours").join("window-bounds-to-restore.json") }+fn save_bounds(sd: &std::path::Path, hwnd: i64, title: &str) {+ if SAVED_BOUNDS.lock().unwrap().iter().any(|b| b["hwnd"].as_i64() == Some(hwnd)) { return; }+ if let Some(w) = windows().into_iter().find(|w| w["hwnd"].as_i64() == Some(hwnd)) {+ let rc = &w["rect"];+ let b = json!({"hwnd": hwnd, "title": title, "x": rc["left"], "y": rc["top"], "w": rc["width"], "h": rc["height"], "maximized": w["maximized"]});+ SAVED_BOUNDS.lock().unwrap().push(b);+ let _ = std::fs::create_dir_all(sd.join("tours"));+ let _ = std::fs::write(leftovers_path(sd), json!(SAVED_BOUNDS.lock().unwrap().clone()).to_string());+ }+}+/// The Fusion window at 80% of the primary work area, centred (Fusion is one window; its+/// documents are the beats), saved first so the end of the tour puts it back.+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 (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);+ 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 {+ let mut saved = SAVED_BOUNDS.lock().unwrap().clone();+ if saved.is_empty() { saved = std::fs::read_to_string(leftovers_path(sd)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).and_then(|v| v.as_array().cloned()).unwrap_or_default(); }+ let live: HashSet<i64> = windows().iter().filter_map(|w| w["hwnd"].as_i64()).collect();+ let mut n = 0;+ for b in saved { if let Some(h) = b["hwnd"].as_i64() { if live.contains(&h) && b["w"].as_i64().unwrap_or(0) > 0 {+ let _ = ab("desktop_set_window_bounds", json!({"hwnd": h, "x": b["x"], "y": b["y"], "w": b["w"], "h": b["h"], "reason": format!("Fusion tour {source}: restore Fusion to where the user had it")}), 8);+ if b["maximized"].as_bool() == Some(true) { let _ = ab("desktop_set_window_state", json!({"hwnd": h, "state": "maximize"}), 8); }+ n += 1;+ } } }+ SAVED_BOUNDS.lock().unwrap().clear();+ let _ = std::fs::remove_file(leftovers_path(sd));+ n+}++// ── measurement ───────────────────────────────────────────────────────────────+/// Decode a PNG the box wrote and count distinct colours in the central 60% (the canvas).+fn canvas_colours(path: &str) -> Option<usize> {+ let f = std::fs::File::open(path).ok()?;+ let dec = png::Decoder::new(std::io::BufReader::new(f));+ let mut reader = dec.read_info().ok()?;+ let mut buf = vec![0u8; reader.output_buffer_size()];+ let info = reader.next_frame(&mut buf).ok()?;+ let bpp = info.color_type.samples() * (info.bit_depth as usize / 8).max(1);+ let (w, h) = (info.width as usize, info.height as usize);+ let (x0, x1, y0, y1) = (w / 5, w * 4 / 5, h / 5, h * 4 / 5);+ let mut set: HashSet<u32> = HashSet::new();+ let mut y = y0;+ while y < y1 { let mut x = x0; while x < x1 { let i = (y * w + x) * bpp; if i + 2 < buf.len() { set.insert(((buf[i] as u32 >> 3) << 10) | ((buf[i + 1] as u32 >> 3) << 5) | (buf[i + 2] as u32 >> 3)); } x += 4; } y += 4; }+ Some(set.len())+}+fn shot_window(hwnd: i64) -> Option<String> { let r = ab("desktop_screenshot_window", json!({"hwnd": hwnd, "resizeMax": 640}), 20); r["fullPath"].as_str().or(r["safePath"].as_str()).map(str::to_string) }+fn digest(path: &str) -> Option<u64> { use std::hash::{Hash, Hasher}; let b = std::fs::read(path).ok()?; let mut h = std::collections::hash_map::DefaultHasher::new(); b.hash(&mut h); Some(h.finish()) }+/// front · title · painted · stable, read from the desktop; one more raise attempt, then remeasured.+fn verify_beat(hwnd: i64, expected_doc: &str) -> Value {+ let mut v = json!({"hwnd": hwnd, "expected": expected_doc, "front": false, "title": false, "painted": null, "stable": null, "attempts": 0});+ for attempt in 0..2 {+ v["attempts"] = json!(attempt + 1);+ let top = top_window();+ v["frontTitle"] = top.as_ref().map(|t| t["title"].clone()).unwrap_or(Value::Null);+ v["front"] = json!(top.as_ref().and_then(|t| t["hwnd"].as_i64()) == Some(hwnd));+ let live = windows().into_iter().find(|w| w["hwnd"].as_i64() == Some(hwnd)).map(|w| w["title"].as_str().unwrap_or("").to_string()).unwrap_or_default();+ v["liveTitle"] = json!(live);+ v["title"] = json!(if expected_doc.is_empty() { Value::Null } else { json!(live.to_lowercase().contains(&expected_doc.to_lowercase())) });+ if v["front"].as_bool() == Some(true) { break; }+ let _ = ab("desktop_bring_to_front", json!({"hwnd": hwnd, "reason": format!("Fusion tour: bring Fusion forward for the {expected_doc} beat, once")}), 8);+ sleep_s(1.5);+ }+ if let Some(p) = shot_window(hwnd) { let n = canvas_colours(&p); v["painted"] = json!(n.map(|n| n >= 12)); v["canvasColours"] = json!(n); }+ let t0 = Instant::now();+ let mut prev = shot_window(hwnd).and_then(|p| digest(&p));+ v["stable"] = json!(false);+ while t0.elapsed() < Duration::from_secs(8) {+ if pending_interrupt() { v["interrupted"] = json!(true); break; }+ sleep_s(0.8);+ let cur = shot_window(hwnd).and_then(|p| digest(&p));+ if prev.is_some() && prev == cur { v["stable"] = json!(true); break; }+ prev = cur;+ }+ v["settleMs"] = json!(t0.elapsed().as_millis() as u64);+ let remote = v["frontTitle"].as_str().map(is_remote_viewer).unwrap_or(false);+ 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));+ 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 } }+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);+ for _ in 0..6 { sleep_s(0.25); if is_front(hwnd) { return true; } }+ false+}++pub struct Env { pub version: &'static str }++// ── Fusion-side scripts ───────────────────────────────────────────────────────+fn cam_script(body: &str) -> String { format!("import adsk.core, adsk.fusion, math\napp=adsk.core.Application.get()\nd=adsk.fusion.Design.cast(app.activeProduct)\nroot=d.rootComponent\nvp=app.activeViewport\ndef cam():\n return vp.camera\ndef put(c, smooth=True):\n c.isSmoothTransition=smooth\n vp.camera=c\n vp.refresh()\ndef orient(o, fit=True):\n c=cam(); c.viewOrientation=o; c.isFitView=fit; put(c)\ndef orbit(deg):\n c=cam(); e=c.eye; t=c.target; up=c.upVector\n v=adsk.core.Vector3D.create(e.x-t.x,e.y-t.y,e.z-t.z)\n m=adsk.core.Matrix3D.create(); m.setToRotation(math.radians(deg), up, t)\n v.transformBy(m)\n c.eye=adsk.core.Point3D.create(t.x+v.x,t.y+v.y,t.z+v.z); c.isFitView=False; put(c)\ndef zoom(f):\n c=cam(); c.viewExtents=c.viewExtents*f; c.isFitView=False; put(c)\ndef pan(dx,dy):\n c=cam(); e=c.eye; t=c.target; ext=c.viewExtents; s=ext*0.001\n c.eye=adsk.core.Point3D.create(e.x+dx*s,e.y+dy*s,e.z); c.target=adsk.core.Point3D.create(t.x+dx*s,t.y+dy*s,t.z); c.isFitView=False; put(c)\n{body}\nresult=out if 'out' in dir() else {{'ok': True}}\n") }++struct Sample { lbr: String, step: Option<String>, deviceset: String, package: String, pins: usize, pads: usize }+fn count(re: &str, text: &str) -> usize { regex::Regex::new(re).map(|r| r.find_iter(text).count()).unwrap_or(0) }+fn first_cap(re: &str, text: &str) -> String { regex::Regex::new(re).ok().and_then(|r| r.captures(text).map(|c| c[1].to_string())).unwrap_or_default() }+/// The bundled single-part sample, staged as AdomDemoSample.lbr (+ .step) in the temp dir.+fn sample() -> Result<Sample, String> {+ let lbr = crate::demo::sample_lbr_pub().ok_or_else(|| "the bundled sample library is missing from this bridge install".to_string())?;+ let text = std::fs::read_to_string(&lbr).map_err(|e| e.to_string())?;+ let step = std::path::Path::new(&lbr).with_extension("step"); let step = step.is_file().then(|| step.to_string_lossy().replace('\\', "/"));+ Ok(Sample { deviceset: first_cap(r#"<deviceset name="([^"]+)""#, &text), package: first_cap(r#"<package name="([^"]+)""#, &text), pins: count(r"<pin ", &text), pads: count(r"<(smd|pad) ", &text), lbr, step })+}+/// Clean slate: close documents a previous tour left behind (by name, unsaved changes dropped).+fn close_named_docs(pass: &dyn Fn(&str, Value) -> Value, needles: &[String]) -> usize {+ let needles: Vec<String> = needles.iter().filter(|n| !n.is_empty()).map(|n| n.to_lowercase()).collect();+ if needles.is_empty() { return 0; }+ let script = format!("import adsk.core\napp=adsk.core.Application.get()\nneedles={}\nclosed=0\nfor i in range(app.documents.count-1,-1,-1):\n dd=app.documents.item(i)\n nm=dd.name.lower()\n if any(n in nm for n in needles):\n try:\n dd.close(False); closed+=1\n except Exception: pass\nadsk.doEvents()\nresult={{'closed': closed}}", crate::fusion_prefs::py_str(&json!(needles)));+ pass("run_modeling_script", json!({"script": script}))["data"]["result"]["closed"].as_u64().unwrap_or(0) as usize+}+fn active_doc(pass: &dyn Fn(&str, Value) -> Value) -> String { pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_string() }+fn wait_design(pass: &dyn Fn(&str, Value) -> Value, secs: u64) -> bool {+ let deadline = Instant::now() + Duration::from_secs(secs);+ while Instant::now() < deadline && !EXIT.load(Ordering::Relaxed) {+ let r = pass("run_modeling_script", json!({"script": "import adsk.core, adsk.fusion\napp=adsk.core.Application.get()\nd=adsk.fusion.Design.cast(app.activeProduct)\nresult={'design': d is not None, 'bodies': (d.rootComponent.bRepBodies.count + sum(o.bRepBodies.count for o in d.rootComponent.allOccurrences)) if d else 0}"}));+ if r["data"]["result"]["design"].as_bool() == Some(true) && r["data"]["result"]["bodies"].as_u64().unwrap_or(0) > 0 { return true; }+ sleep_s(2.0);+ }+ false+}+fn wait_active(pass: &dyn Fn(&str, Value) -> Value, pred: &dyn Fn(&str) -> bool, secs: u64) -> bool {+ let deadline = Instant::now() + Duration::from_secs(secs);+ while Instant::now() < deadline && !EXIT.load(Ordering::Relaxed) { if pred(&active_doc(pass)) { return true; } sleep_s(2.0); }+ false+}++fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &Env) {+ rec(|s| s["bridgeVersion"] = json!(env.version));+ let sd = ctx.state_dir.clone();+ let t0 = Instant::now();+ let total_est: u64 = BEATS.iter().map(|b| b.2).sum();+ let mut done_est: u64 = 0;+ // clean slate per tour+ EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear();+ MUTED.store(args["muted"].as_bool().unwrap_or(false), Ordering::Relaxed);+ drain_stale_presses();+ restore_bounds(&sd, "leftovers");+ 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();+ 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()); });+ let mut results: Vec<Value> = Vec::new();+ let mut i = 0usize;+ let mut exited = false;+ let mut failed_early = false;+ let mut fusion: Option<(i64, String)> = None;+ let mut bound_urn = String::new();+ let mut bound = false;+ let mut project_base = String::new();+ let sample = match sample() { Ok(s) => s, Err(e) => { rec(|s| { s["done"] = json!(true); s["failed"] = json!(true); s["error"] = json!(e); s["stepLabel"] = json!("Tour failed: no sample library"); }); return; } };+ rec(|s| s["sample"] = json!({"lbr": sample.lbr, "deviceset": sample.deviceset, "package": sample.package, "pins": sample.pins, "pads": sample.pads, "step": sample.step}));+ let update_pct = |done: u64, extra: u64| rec(|s| { s["percent"] = json!(((100 * (done + extra)) / total_est.max(1)).min(95)); s["elapsedSec"] = json!(t0.elapsed().as_secs()); s["etaSec"] = json!(total_est.saturating_sub(done + extra)); });+ let n = BEATS.len();+ while i < n {+ // transport between beats: pause holds, exit leaves, prev is TWO back (i is past the beat on screen)+ let mut cmd = take_cmd();+ if cmd.as_deref() == Some("pause") {+ audio_stop(); panel_show(i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); });+ loop { sleep_s(1.0); cmd = take_cmd(); if matches!(cmd.as_deref(), Some("play") | Some("next") | Some("prev") | Some("exit")) { break; } }+ rec(|s| s["paused"] = json!(false));+ }+ if cmd.as_deref() == Some("exit") || EXIT.load(Ordering::Relaxed) { exited = true; break; }+ if cmd.as_deref() == Some("prev") { i = i.saturating_sub(2); results.truncate(i); }+ let (key, label, dur) = BEATS[i];+ rec(|s| { s["step"] = json!(key); s["stepLabel"] = json!(label); s["beatIndex"] = json!(i + 1); s["beatCount"] = json!(n); });+ update_pct(done_est, 0);+ panel_show(i, None);+ let beat_t0 = Instant::now();+ let mut r = json!({"step": key, "label": label, "ok": false});+ let mut expected = String::new();+ let mut kind = "none";+ let script = |body: &str| pass("run_modeling_script", json!({"script": cam_script(body)}));+ let view = |suffix: &str| crate::orchestrations::show_library_view(ctx, &sample.deviceset, suffix, Some(&sample.lbr), pass);+ // ---- the beat body ----+ match key {+ "library" => {+ caption(&format!("Fusion tour 1/{n}: opening the sample library - {} with {} pins and {} pads, one file", sample.deviceset, sample.pins, sample.pads), 30000);+ audio_play("library");+ let o = crate::orchestrations::open_lbr(ctx, &json!({"filePath": sample.lbr}), pass);+ r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();+ expected = "AdomDemoSample".into();+ }+ "symbol" => {+ caption(&format!("Fusion tour 2/{n}: the symbol - all {} pins, converted pin for pin from the source", sample.pins), 30000);+ audio_play("symbol");+ let o = view("sym"); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();+ expected = "AdomDemoSample".into(); kind = "2d";+ }+ "footprint" => {+ caption(&format!("Fusion tour 3/{n}: the footprint - {} pads on real copper, mask and silk layers", sample.pads), 30000);+ audio_play("footprint");+ let o = view("pac"); r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();+ expected = "AdomDemoSample".into(); kind = "2d";+ }+ "chip" => {+ caption(&format!("Fusion tour 4/{n}: the 3D chip - importing the STEP onto the footprint, saving it to your hub, binding it into the library (about two minutes)"), 60000);+ audio_play("chip");+ let was = active_doc(pass);+ match (&sample.step, sample.package.is_empty()) {+ (Some(step), false) => {+ let on_part = |_p: &Value, k: usize, total: usize| caption(&format!("3D chip: part {k} of {total} minted and bound"), 20000);+ let br = crate::mint::build_library_3d_core(ctx, &json!({"lbrPath": sample.lbr, "parts": [{"package": sample.package, "lbrPath": sample.lbr, "modelPath": step}], "outLbrPath": sample.lbr, "capture": false}), pass, &on_part);+ bound = br["success"].as_bool() == Some(true) && br["partsBound"].as_u64().unwrap_or(0) >= 1;+ bound_urn = br["parts"][0]["wip_urn"].as_str().unwrap_or("").to_string();+ r["bind"] = json!({"success": bound, "partsBound": br["partsBound"], "error": br["error"], "urn": bound_urn, "dims_mm": br["parts"][0]["dims_mm"]});+ rec(|s| s["bound3d"] = json!(bound));+ if bound && !bound_urn.is_empty() && !EXIT.load(Ordering::Relaxed) {+ let o = pass("open_by_urn", json!({"urn": bound_urn}));+ r["open"] = json!({"success": o["success"], "error": o["error"]});+ r["ok"] = json!(wait_active(pass, &|d| !d.is_empty() && d != was && !d.to_lowercase().contains("adomdemosample"), 90));+ if r["ok"].as_bool() == Some(true) { caption(&format!("The minted {} - a real Fusion design in your hub, bound to the footprint", sample.package), 20000); let _ = script("vp.visualStyle=adsk.core.VisualStyles.ShadedWithVisibleEdgesOnlyVisualStyle\norient(adsk.core.ViewOrientations.IsoTopRightViewOrientation, True)\nout={'iso': True}"); }+ else { r["error"] = json!("the bound f3d did not become the active document"); }+ }+ if r["ok"].as_bool() != Some(true) && !EXIT.load(Ordering::Relaxed) {+ // graceful: show the chip from the STEP alone and say so+ let why = br["error"].as_str().unwrap_or("cloud bind unavailable").chars().take(90).collect::<String>();+ caption(&format!("3D chip from the STEP alone ({why})"), 20000);+ let o = pass("import_step", json!({"filePath": step}));+ r["fallback"] = json!({"importStep": o["success"], "error": o["error"], "why": why});+ r["ok"] = json!(o["success"].as_bool() == Some(true) && wait_active(pass, &|d| !d.is_empty() && d != was, 60));+ if r["ok"].as_bool() == Some(true) { let _ = script("orient(adsk.core.ViewOrientations.IsoTopRightViewOrientation, True)\nout={'iso': True}"); }+ }+ }+ _ => { r["error"] = json!("no STEP staged beside the sample library"); }+ }+ kind = "3d";+ }+ "schematic" => {+ caption(&format!("Fusion tour 5/{n}: generating a project around {} - schematic and board authored from EAGLE source, instantiated by Fusion", sample.deviceset), 60000);+ audio_play("schematic");+ let m = crate::testproject::make_test_project(ctx, &json!({"libraryPath": sample.lbr}), pass);+ r["makeProject"] = json!({"success": m["success"], "error": m["error"]});+ if m["success"].as_bool() == Some(true) {+ project_base = crate::testproject::test_project(&sd).and_then(|t| t["docBase"].as_str().map(str::to_string)).unwrap_or_default();+ sleep_s(2.0);+ let o = pass("show_schematic", json!({}));+ r["ok"] = json!(o["success"].as_bool() == Some(true)); r["error"] = o["error"].clone();+ if r["ok"].as_bool() == Some(true) { caption(&format!("The schematic: {} wired into a real sheet", sample.deviceset), 20000); }+ } else { r["error"] = m["error"].clone(); }+ expected = project_base.clone(); kind = "2d";+ }+ "board2d" => {+ caption(&format!("Fusion tour 6/{n}: the 2D board - the same footprint, now placed on copper"), 30000);+ audio_play("board2d");+ let act = 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";+ }+ "board3d" => {+ caption(&format!("Fusion tour 7/{n}: the 3D board{}", if bound { " - and the chip body on it is the exact cloud model minted a minute ago" } else { "" }), 30000);+ audio_play("board3d");+ let act = crate::testproject::test_project_activate(ctx, "brd", pass);+ 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();+ // the 3D board keeps the board document's NAME, so wait for a Design product, not a new name+ if r["ok"].as_bool() == Some(true) { let _ = was; r["designActive"] = json!(wait_design(pass, 45)); sleep_s(1.5); let _ = script("orient(adsk.core.ViewOrientations.IsoTopRightViewOrientation, True)\nout={'iso': True}"); }+ expected = project_base.clone(); kind = "3d";+ }+ "aps" => {+ let q: String = sample.deviceset.chars().take(8).collect();+ let q0 = Instant::now();+ let a = crate::native_aps_search(&json!({"query": q, "limit": 5}));+ let secs = q0.elapsed().as_secs_f64();+ if a["success"].as_bool() == Some(true) {+ let hits = a["results"].as_array().or(a["items"].as_array()).or(a["data"]["results"].as_array()).map(|a| a.len()).unwrap_or(0);+ caption(&format!("Fusion tour 8/{n}: cloud search - {hits} hits for {q} across your hub in {secs:.1} s, indexed, never charged"), 20000);+ r["ok"] = json!(true); r["hits"] = json!(hits); r["seconds"] = json!(secs);+ } else {+ caption(&format!("Fusion tour 8/{n}: cloud search skipped - not signed in to APS (fusion_aps_signin lights this up)"), 20000);+ r["ok"] = json!(true); r["skipped"] = json!(a["error"].as_str().unwrap_or("not signed in").chars().take(100).collect::<String>());+ }+ audio_play("aps");+ }+ _ => {}+ }+ if key == "library" && r["ok"].as_bool() != Some(true) {+ let why = r["error"].as_str().unwrap_or("the sample library did not open").to_string();+ caption(&format!("Fusion tour stopped: {why}"), 8000);+ r["seconds"] = json!(beat_t0.elapsed().as_secs_f64());+ results.push(r.clone()); rec(|s| { push(s, "steps", r.clone()); s["error"] = json!(why); });+ failed_early = true; break;+ }+ // ---- measure, then move ----+ if r["ok"].as_bool() == Some(true) && !EXIT.load(Ordering::Relaxed) && key != "aps" {+ if fusion.is_none() { fusion = fusion_hwnd(); }+ if let Some((h, t)) = &fusion {+ if !r["placed"].is_object() && !snap()["placed"].as_bool().unwrap_or(false) { r["placed"] = place_fusion(&sd, *h, t); rec(|s| s["placed"] = json!(true)); }+ r["foregrounded"] = json!(front_ok(*h) || foreground_once(*h, &format!("the {label} beat")));+ let v = verify_beat(*h, &expected);+ r["verified"] = v.clone();+ rec(|s| s["verified"] = v.clone());+ if v["ok"].as_bool() == Some(true) && kind != "none" {+ r["motion"] = motion(*h, kind, pass, &script);+ } 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 { r["verified"] = json!({"why": "no Fusion window"}); }+ }+ // evidence frame at the moment the beat completes+ if let Some((h, _)) = &fusion { if let Some(p) = shot_window(*h) { if let Ok(b) = std::fs::read(&p) { if b.len() <= 4 * 1024 * 1024 { let img = crate::etiquette::b64_pub(&b); rec(|s| { push(s, "shots", json!({"label": label, "title": label, "path": p, "image": img})); }); } } } }+ r["seconds"] = json!(beat_t0.elapsed().as_secs_f64());+ results.push(r.clone());+ rec(|s| push(s, "steps", r.clone()));+ done_est += dur;+ update_pct(done_est, 0);+ // advance at max(action done, audio done), bounded, honouring the remote+ let wait_t0 = Instant::now();+ while audio_playing() && wait_t0.elapsed() < Duration::from_secs(40) {+ if EXIT.load(Ordering::Relaxed) { break; }+ if let Some(c) = take_cmd() { CTRL.lock().unwrap().push_front(c); break; }+ sleep_s(0.4);+ }+ if EXIT.load(Ordering::Relaxed) { exited = true; break; }+ i += 1;+ }+ // ---- teardown: audio off, panel down, windows back, the tour marked done; THEN the tour's+ // documents close (closing takes a minute and must not read as dead air) ----+ audio_stop(); panel_hide();+ let restored = restore_bounds(&sd, if exited { "stop" } else { "end" });+ let ok_ct = results.iter().filter(|r| r["ok"].as_bool() == Some(true)).count();+ if exited { caption("Fusion tour stopped", 4000); } else if failed_early { caption("Fusion tour stopped before it began: the sample library did not open", 6000); } else { audio_play("done"); caption(&format!("Fusion tour done: {ok_ct} of {n} beats - symbol, footprint, 3D chip, schematic, 2D and 3D board, cloud search; the same pipeline builds multipart libraries"), 8000); }+ rec(|s| { s["done"] = json!(true); s["exited"] = json!(exited); s["failed"] = json!(failed_early || (!exited && ok_ct < n)); s["percent"] = json!(100); s["windowsRestored"] = json!(restored); s["tourDocsClosed"] = json!("closing"); s["elapsedSec"] = json!(t0.elapsed().as_secs()); s["step"] = json!("done"); s["stepLabel"] = json!(if exited { "Tour stopped" } else if failed_early { "Tour failed: the sample library did not open" } else { "Tour complete" });+ s["result"] = json!({"openedBeats": ok_ct, "totalBeats": n, "exited": exited, "bound3d": bound, "failedSteps": results.iter().filter(|r| r["ok"].as_bool() != Some(true)).map(|r| r["step"].clone()).collect::<Vec<_>>()}); s["narrate"] = json!(format!("Fusion tour {}: {ok_ct} of {n} beats across the electronics library, the 3D chip and a generated board.", if exited { "stopped" } else { "complete" })); });+ let s = snap(); crate::demo::persist_pub(&sd, &s);+ 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);+ 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); }+}++/// Motion on a verified window only; every gesture re-checks the front and the remote.+/// 2D editors move through the add-in's smooth WINDOW zoom; 3D through Fusion's own camera.+fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, script: &dyn Fn(&str) -> Value) -> Value {+ let mut rep = json!({"kind": kind, "gestures": 0, "aborted": null});+ let plan: Vec<(&str, Value)> = if kind == "2d" {+ vec![("fit", json!({"fit": true})), ("zoom-in", json!({"factor": 1.8, "steps": 14})), ("zoom-in-2", json!({"factor": 1.4, "steps": 10})), ("zoom-out", json!({"factor": 0.55, "steps": 14})), ("fit-2", json!({"fit": true}))]+ } else {+ vec![("iso", json!("orient(adsk.core.ViewOrientations.IsoTopRightViewOrientation, True)")), ("orbit-1", json!("orbit(40)")), ("orbit-2", json!("orbit(40)")), ("zoom-in", json!("zoom(0.75)")), ("orbit-3", json!("orbit(50)")), ("zoom-out", json!("zoom(1.35)")), ("orbit-back", json!("orbit(-65)")), ("orbit-back-2", json!("orbit(-65)"))]+ };+ let before = shot_window(hwnd).and_then(|p| digest(&p));+ 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" { pass("electron_zoom", body) } else { script(body.as_str().unwrap_or("")) };+ 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(0.6);+ }+ let after = shot_window(hwnd).and_then(|p| digest(&p));+ rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);+ rep+}++/// `fusion_demo {mode:"tour"}`: start the show detached; poll fusion_demo_progress {demoId}.+pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value {+ if snap()["done"].as_bool() == Some(false) { let s = snap(); return json!({"success": false, "error": "a tour is already running", "demoId": s["demoId"], "_hint": "Poll fusion_demo_progress with that demoId, or fusion_demo_control {action:'exit'} to stop it."}); }+ if snap()["tourDocsClosed"].as_str() == Some("closing") { let s = snap(); return json!({"success": false, "error": "the previous tour is still closing its documents", "demoId": s["demoId"], "_hint": "Wait a few seconds and start again; fusion_demo_progress {demoId} shows tourDocsClosed as a list once it is done."}); }+ let mut idb = [0u8; 4]; let _ = getrandom::getrandom(&mut idb);+ let id = format!("show-{:08x}", u32::from_le_bytes(idb));+ *CALLER.lock().unwrap() = Some(ctx.caller.clone());+ let est: u64 = BEATS.iter().map(|b| b.2).sum();+ *TOUR.lock().unwrap() = Some(json!({"demoId": id, "done": false, "failed": false, "step": "starting", "stepLabel": "Starting the tour", "beatIndex": 0, "beatCount": BEATS.len(), "percent": 0, "elapsedSec": 0, "estimatedSec": est, "steps": [], "shots": [], "presses": [], "captions": [], "audio": [], "muted": args["muted"].as_bool().unwrap_or(false), "paused": false, "stopping": false, "panel": {"shown": false}, "started": now()}));+ let (sd, caller, args2, my_id) = (ctx.state_dir.clone(), ctx.caller.clone(), args.clone(), id.clone());+ 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 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 _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_show(&ctx2, &pass2, args2, &env)));+ // only THIS tour's record: a later tour may own the live record by now+ let live = snap();+ if live["demoId"].as_str() == Some(my_id.as_str()) && live["done"].as_bool() != Some(true) { rec(|s| { s["done"] = json!(true); s["failed"] = json!(true); s["error"] = json!("the tour thread ended unexpectedly"); }); panel_hide(); }+ });+ json!({"success": true, "demoId": id, "estimatedSec": est, "steps": BEATS.iter().map(|b| json!({"step": b.0, "label": b.1, "estimatedSec": b.2})).collect::<Vec<_>>(), "statusVerb": "fusion_demo_progress",+ "_hint": format!("Tour started in the background. Poll fusion_demo_progress {{\"demoId\": \"{id}\"}} every 2-3 s: percent, stepLabel, per-step verified/placed/motion, presses[], captions[], panel, and a per-beat evidence frame in shots[]. Drive it with fusion_demo_control {{demoId, action: play|pause|next|prev|exit|mute|unmute}} or the glass remote on screen.")})+}+pub fn owns(id: &str) -> bool { id.starts_with("show-") && snap()["demoId"].as_str() == Some(id) }+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(); } }+ 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;+ let imgs = args["imagesAs"].as_str().unwrap_or("base64");+ let shots: Vec<Value> = all.into_iter().skip(since).map(|mut sh| { if imgs == "paths" { sh.as_object_mut().map(|o| o.remove("image")); } sh }).collect();+ out["shots"] = json!(shots);+ out["_hint"] = json!(if s["done"].as_bool() == Some(true) { "Tour finished: steps[] carries verified/placed/motion per beat, presses[] every remote press with its source, shots[] the evidence frames." } else { "Tour running. Poll every 2-3 s; pass sinceShot=shotCount to receive only new frames." });+ out+}+pub fn control(args: &Value) -> Value {+ let a = args["action"].as_str().unwrap_or("").to_lowercase();+ if !matches!(a.as_str(), "play" | "resume" | "pause" | "next" | "prev" | "back" | "exit" | "stop" | "mute" | "unmute") { return json!({"success": false, "error": "action must be play|pause|next|prev|exit|mute|unmute"}); }+ if snap()["done"].as_bool() != Some(false) && !matches!(a.as_str(), "mute" | "unmute") { return json!({"success": false, "error": "no running tour", "_hint": "Controls only apply while a tour is running."}); }+ let r = on_press(&a, json!(now()), "dashboard");+ json!({"success": true, "action": a, "result": r, "muted": MUTED.load(Ordering::Relaxed), "paused": snap()["paused"], "_hint": "Honoured at the running beat's next gesture (a document mid-open finishes first); exit stops the tour, silences narration and clears the captions."})+}
skills/fusion-demo/SKILL.md+53−17@@ -163,25 +163,61 @@ Full flow: **fusion-onboarding**. - Nothing to open -> ask which board, or `fusion_aps_search` and open the first hit. - Never end a demo on an error screen. If a stage fails, say what you tried, and offer the next thing. -## The on-screen remote (from 1.9.343): `fusion_demo {mode:"tour", preset:"electronics", remote:true}`--`remote:true` makes the tour show ab's floaty glass transport panel (`desktop_demo_panel`,-id `fusion-tour`) with the tour's stops, and drain its PLAY / PAUSE / NEXT / BACK / STOP-presses onto the same flags `fusion_demo_control` sets, so the dashboard buttons and the-desktop remote can be mixed mid-tour. `preset:"electronics"` is the eight-stop showcase-John asked for: library import, symbol, footprint, 3D chip, ganged component view, a-schematic with that part, the 2D board, the 3D board (measured 2026-09-03: 247s, 9 frames,-zero failures on ConfRoomROG).+## The guided electronics tour (from 1.9.360): `fusion_demo {mode:"tour"}`++John, 2026-09-04: Adom is an electronics prototyping company, so the tour demos electronics+and never a mechanical part. Eight beats, all on the bundled sample part (ADS8588SIPM, 64+pins), each with a caption, pre-rendered narration and on-screen motion:++| # | beat | what happens | motion |+|---|---|---|---|+| 1 | Open the sample library | `open_lbr` of the staged sample; caption with the pin and pad counts | none |+| 2 | Symbol: the full pinout | symbol editor, `WINDOW FIT` | 2D zoom in, in, out, fit |+| 3 | Footprint: pads and layers | footprint editor | 2D zoom |+| 4 | 3D chip: minted and bound | the library chain's bind core on the sample (STEP onto the footprint, saved to the hub, bound); the bound f3d is opened in the 3D canvas | iso, orbits, zoom |+| 5 | Schematic: the part wired in | `make_test_project` then `show_schematic` | 2D zoom |+| 6 | 2D board: the part on copper | board document in front, `show_2d_board` | 2D zoom |+| 7 | 3D board: the chip on it | `show_3d_board`, waits for a Design with bodies | iso, orbits, zoom |+| 8 | Cloud search over your hub | `fusion_aps_search` for the part; skipped honestly when not signed in | none |++Multipart libraries are MENTIONED in the narration and never shown: a real 3D bind is minutes+per part, so a shown multipart beat is a nightmare, not a demo (John).++**Measured before it moves.** Before any gesture the tour checks the Fusion window is in+front (z-order, one extra raise, 1.5 s), its title is the expected document, the canvas is+painted (distinct colours in the central 60%) and stable (two captures 0.8 s apart identical).+Motion runs only on a verified window and is itself verified by capture (`viewChanged`). On a+box driven over remote desktop the viewer sits on top of everything; the record says so+(`frontNote`) and still proves layout and motion. Fusion is placed on 80% of the primary work+area for the show (saved bounds, a leftovers file) and restored at the end.++**The remote.** ab's glass panel (`desktop_demo_panel`, id `fusion-tour`, `reason` on every+show, stage-advance shows send only id and stage) with play, pause, next, back, mute and the X+(= stop). Presses reach the tour instantly through a loopback callback (`/demo/event` on the+exe, ab 2.1.17+) and a poll backstop deduped by `at`; `fusion_demo_control {demoId, action}` is+the dashboard's path onto the same FIFO. Every press is acked with a caption at the top of the+screen the instant it arrives and recorded in `presses[]` with its source (`callback`,+`poll`, `dashboard`, `*-synthetic`). Pause holds at the next gesture; a Fusion operation in+flight (the two-minute bind, a document opening) finishes first. Back is the previous beat.+Mute stops the local audio (session-only); unmute replays the beat's narration. Stop ends the+tour, silences narration, clears captions, hides the panel, restores the window and closes the+tour's documents. **⛔ Never poll the panel yourself.** `desktop_demo_panel {action:"poll", id:"fusion-tour"}`-DRAINS the press queue, and the tour is the consumer. A CLI poll fired "to check" ate the-user's PAUSE press live: the panel flipped to paused, the tour never saw it. Verify a-remote-driven tour ONLY through `fusion_demo_progress` (`remotePresses`, `paused`, `step`).--Presses are recorded immediately (a 2s ticker) but HONORED only at step boundaries, never-mid-verb: a Fusion operation in flight cannot be interrupted safely. So a pause pressed-during the 60s test-project generation takes effect when that step finishes. The caption-and the progress stream say "Paused - press play to continue" once it does.+DRAINS the press queue, and the tour is the consumer. Verify only through+`fusion_demo_progress`: `step`, `beatIndex`/`beatCount`, `percent`, `paused`, `muted`,+`stopping`, `panel`, `presses[]`, `captions[]`, per-step `verified` / `placed` /+`foregrounded` / `motion`, `shots[]` (one evidence frame per beat, `imagesAs:"paths"` to skip+the base64), `windowsRestored`, `tourDocsClosed` (`"closing"` while the documents close after+`done:true`, so the end never reads as dead air).++Measured on ConfRoomROG 2026-09-04 (exe 1.9.359+): eight of eight beats, every beat front,+title, painted and stable on the first attempt, 2D and 3D motion verified by capture, the 3D+chip bound in 80 to 90 s, tour done in about five minutes. Pause, play, mute, unmute, back and+stop were each honoured from the dashboard path, from the loopback callback, and from real OS+clicks on the panel. `{action:"press"}` (the synthetic press hook) needs ab 2.1.26+.++`legacy:true` still runs the previous step-list tour. ## The 3D stop is a REAL bind (from 1.9.345)