← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.359\ No newline at end of file+1.9.360\ No newline at end of file
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]"-  }-}\ No newline at end of file+  ]+}
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)