← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.365\ No newline at end of file+1.9.366\ 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.365",+  "version": "1.9.366",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.365"+version = "1.9.366" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.365"+version = "1.9.366" edition = "2021"  [dependencies]
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.365"+DASH_VERSION="1.9.366" 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.365",+  "version": "1.9.366",   "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.365",+  "version": "1.9.366",   "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.365"+version = "1.9.366" edition = "2021" license = "MIT" 
rust/PORTING.md+16
@@ -559,3 +559,19 @@ port learned: - The tour's front gate counts Fusion's own popups (the palette host) and PopupHost as front. - The taskbar overlay is the same 32 px PNG the Python bridge sent, not ab's larger built-in badge. - Fusion is placed at 95% of the work area; captions are ab's medium on one line.+- **The palette is one worker, not one thread per message.** A thread per message that gave up+  when a newer message arrived meant nothing ever painted during a bind: Fusion's main thread is+  blocked by the STEP import, every message was superseded before it could land. One worker keeps+  trying to paint the newest message and succeeds the moment Fusion breathes.+- **Field notes from the recorded runs (2026-09-05):** the Adom screensaver on ConfRoomROG+  (10 min) takes the input desktop, so clicks are refused by UIPI and captures come back blank;+  the version-aware dashboard serve moves to the next port after a release (a driver must find the+  dashboard by URL, never by a fixed port); ab's whole-desktop recorder could not start a video+  source there, ffmpeg gdigrab records the full screen fine.+- **ab's demo panel does not leave the screen on hide** (2.1.27 and 2.1.33): hide answers+  hidden:true and poll says stopped, but the glass stays painted at its last stage. Filed as+  adom/adom-bridge issue 151; the tour's `panelHidden` record is honest about what ab reported,+  not about the pixels.+- **A release restarts the exe under a running tour.** ab pulled 1.9.365 mid-run and the record+  vanished (run 25). Pin the bridge cache on a demo box with `bridge_dev_mode` before recording,+  and never release while a tour runs anywhere you cannot see.
rust/bridge/src/addin.rs+9−2
@@ -108,9 +108,16 @@ fn busy_gate(cmd: &str) -> Option<Value> {     if let Some(st) = addin_status_probe(Duration::from_secs(3)) {         // the palette (activity_banner) repaints in a fraction of a second and is advisory: a verb         // that lands while it paints waits its turn in the add-in instead of being refused-        if st["busy"].as_bool() == Some(true) && st["busyCommand"].as_str() != Some("activity_banner") {+        // a sub-second probe from another caller (the dashboard polls get_app_state every half+        // second while a tour runs) is not "busy": the add-in queues the next command behind it.+        // Only a LONG command in flight (a cloud walk, a search, an import, or anything past 2 s)+        // earns a refusal. ConfRoomROG 2026-09-05: a 0.1 s get_app_state cost the 2D board beat.+        let busy_cmd = st["busyCommand"].as_str().unwrap_or("").to_string();+        let busy_for = st.get("elapsedSeconds").and_then(|e| e.as_f64()).unwrap_or(0.0);+        let long_one = LONG_RUNNING.contains(&busy_cmd.as_str()) || busy_for >= 2.0 || st.get("walkProgress").map(|w| !w.is_null()).unwrap_or(false);+        if st["busy"].as_bool() == Some(true) && busy_cmd != "activity_banner" && long_one {             let elapsed = st.get("elapsedSeconds").cloned().unwrap_or(json!(0));-            let bc = st["busyCommand"].as_str().unwrap_or("unknown").to_string();+            let bc = busy_cmd.clone();             let mut resp = json!({"success": false,                 "error": format!("Fusion main thread busy — {bc} running for {elapsed}s (from another bridge/session). Your command '{cmd}' cannot execute until it finishes."),                 "errorCode": "main_thread_busy", "busyCommand": bc, "elapsedSeconds": elapsed,
rust/bridge/src/palette.rs+57−30
@@ -1,48 +1,70 @@ //! The in-Fusion palette ("Adom is driving Fusion"): the VERBS' voice. John, 2026-09-05: the ab //! captions describe the demo; the palette describes the verb running against Fusion right now-//! (opening a library, minting 3D part 2 of 5, switching to the 2D board). Every message goes-//! straight to the add-in's activity_banner over its own HTTP hop (busy-exempt), on a thread that-//! retries while Fusion's main thread is tied up, so the palette lands even mid-bind. A message-//! that nobody follows within 25 s takes the palette down; a newer message cancels older retries.+//! (opening a library, minting 3D part 2 of 5, switching to the 2D board), with the time it+//! usually takes on this machine.+//!+//! One worker thread owns the palette. `say` only records the latest message; the worker paints+//! it through the add-in's activity_banner (its own HTTP hop, busy-exempt) and keeps trying while+//! Fusion's main thread is tied up (a STEP import blocks it for a minute), so the newest message+//! always lands the moment Fusion breathes. Nothing new for 25 s and no import run in flight+//! takes the palette down. use serde_json::{json, Value};-use std::sync::atomic::{AtomicU64, Ordering};-use std::sync::Mutex;+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};+use std::sync::{Mutex, Once}; use std::time::{Duration, Instant}; -static GEN: AtomicU64 = AtomicU64::new(0);-static LAST: Mutex<Option<(String, String)>> = Mutex::new(None);-const TITLE_PREFIX: &str = "";+static WORKER: Once = Once::new();+static DIRTY: AtomicBool = AtomicBool::new(false);+static HIDE: AtomicBool = AtomicBool::new(false);+static SEQ: AtomicU64 = AtomicU64::new(0);+static LAST: Mutex<Option<(String, String, Option<f64>, Option<f64>, u64)>> = Mutex::new(None);+/// What the worker last did, for fusion_interactive: (action, title, success, error, seconds ago).+static LAST_RESULT: Mutex<Option<(String, String, bool, String, Instant)>> = Mutex::new(None);  fn send(action: &str, title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) -> Value {-    crate::addin::addin_http("activity_banner", &json!({"action": action, "title": format!("{TITLE_PREFIX}{title}"), "detail": detail, "percent": percent, "etaSec": eta.map(|e| e.max(0.0).round()), "tone": "working"}), 8)+    crate::addin::addin_http("activity_banner", &json!({"action": action, "title": title, "detail": detail, "percent": percent, "etaSec": eta.map(|e| e.max(0.0).round()), "tone": "working"}), 8)+}++fn worker() {+    let mut painted_seq: u64 = 0;+    let mut painted_at = Instant::now();+    let mut shown = false;+    loop {+        std::thread::sleep(Duration::from_millis(400));+        if HIDE.swap(false, Ordering::Relaxed) {+            for _ in 0..5 { if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(2)); }+            shown = false; DIRTY.store(false, Ordering::Relaxed); *LAST.lock().unwrap() = None; continue;+        }+        if DIRTY.load(Ordering::Relaxed) {+            let Some((t, d, p, e, seq)) = LAST.lock().unwrap().clone() else { DIRTY.store(false, Ordering::Relaxed); continue };+            let r = send("show", &t, &d, p, e);+            *LAST_RESULT.lock().unwrap() = Some(("show".into(), t.clone(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now()));+            if r["success"].as_bool() == Some(true) {+                shown = true; painted_seq = seq; painted_at = Instant::now();+                if SEQ.load(Ordering::Relaxed) == seq { DIRTY.store(false, Ordering::Relaxed); }+            } else { std::thread::sleep(Duration::from_secs(2)); }+            continue;+        }+        if shown && painted_at.elapsed() > Duration::from_secs(25) && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {+            if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; }+            else { painted_at = Instant::now(); }+        }+    } }  /// Say what is being done to Fusion right now, with the time it usually takes (from the timing-/// store, so the estimate sharpens with every run). Fire-and-forget.+/// store, so the estimate sharpens with every run). Fire-and-forget: the worker paints it. pub fn say(title: &str, detail: &str, percent: Option<f64>, eta: Option<f64>) {-    let gen = GEN.fetch_add(1, Ordering::Relaxed) + 1;-    *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string()));-    let (t, d) = (title.to_string(), detail.to_string());-    std::thread::spawn(move || {-        let t0 = Instant::now();-        // one call per message: the add-in's show creates the palette or repaints it in place-        while t0.elapsed() < Duration::from_secs(90) {-            if GEN.load(Ordering::Relaxed) != gen { return; }      // a newer message took over-            let r = send("show", &t, &d, percent, eta);-            if r["success"].as_bool() == Some(true) { break; }-            std::thread::sleep(Duration::from_secs(3));-        }-        // idle hide: nothing newer within 25 s and no import run in flight-        std::thread::sleep(Duration::from_secs(25));-        if GEN.load(Ordering::Relaxed) == gen && !run_in_flight() { let _ = send("hide", "", "", None, None); }-    });+    WORKER.call_once(|| { std::thread::spawn(worker); });+    let seq = SEQ.fetch_add(1, Ordering::Relaxed) + 1;+    *LAST.lock().unwrap() = Some((title.to_string(), detail.to_string(), percent, eta, seq));+    DIRTY.store(true, Ordering::Relaxed); }  /// Take the palette down now (the tour's teardown, a cancelled run). pub fn hide() {-    GEN.fetch_add(1, Ordering::Relaxed);-    *LAST.lock().unwrap() = None;-    std::thread::spawn(|| { for _ in 0..5 { let r = send("hide", "", "", None, None); if r["success"].as_bool() == Some(true) { break; } std::thread::sleep(Duration::from_secs(3)); } });+    WORKER.call_once(|| { std::thread::spawn(worker); });+    HIDE.store(true, Ordering::Relaxed); }  fn run_in_flight() -> bool {@@ -71,3 +93,8 @@ pub fn eta_for_verb(state_dir: &std::path::Path, verb: &str) -> Option<f64> {     let (secs, kind) = crate::timings::verb_estimate(state_dir, verb);     if kind == "unknown" || secs <= 0.0 { None } else { Some(secs) } }++/// The palette's last paint attempt, for fusion_interactive.+pub fn status() -> Value {+    match LAST_RESULT.lock().unwrap().as_ref() { Some((a, t, ok, e, at)) => json!({"lastAction": a, "lastTitle": t, "ok": ok, "error": if e.is_empty() { Value::Null } else { json!(e) }, "secondsAgo": at.elapsed().as_secs()}), None => json!({"lastAction": Value::Null}) }+}
rust/bridge/src/tour.rs+110−18
@@ -370,7 +370,7 @@ fn foreground_once(hwnd: i64, why: &str) -> bool { 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") }+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 tilt(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    right=v.crossProduct(up); right.normalize()\n    m=adsk.core.Matrix3D.create(); m.setToRotation(math.radians(deg), right, 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 focus(x,y,z):\n    c=cam(); e=c.eye; t=c.target\n    dx,dy,dz=x-t.x,y-t.y,z-t.z\n    c.target=adsk.core.Point3D.create(x,y,z); c.eye=adsk.core.Point3D.create(e.x+dx,e.y+dy,e.z+dz); 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") }  /// `fusion_view`: first-class camera motion on the active Design viewport (3D) through Fusion's own /// camera, or on the active Electron editor (2D) through the add-in's smooth WINDOW zoom. The tour's@@ -397,13 +397,17 @@ out={'action':'home'}".to_string(), out={{'action':'{action}'}}", if args["fit"].as_bool().unwrap_or(true) { "True" } else { "False" }) }         "orbit" => format!("orbit({}) out={{'action':'orbit'}}", args["degrees"].as_f64().unwrap_or(40.0)),+        "tilt" => format!("tilt({})+out={{'action':'tilt'}}", args["degrees"].as_f64().unwrap_or(-25.0)),+        "focus" => format!("focus({},{},{})+out={{'action':'focus'}}", args["x"].as_f64().unwrap_or(0.0), args["y"].as_f64().unwrap_or(0.0), args["z"].as_f64().unwrap_or(0.0)),         "zoom" => format!("zoom({}) out={{'action':'zoom'}}", 1.0 / args["factor"].as_f64().unwrap_or(1.33).max(0.05)),         "pan" => format!("pan({},{}) out={{'action':'pan'}}", args["dx"].as_f64().unwrap_or(0.0), args["dy"].as_f64().unwrap_or(0.0)),         "style" => { let st = match args["style"].as_str().unwrap_or("shaded-edges") { "shaded" => "ShadedVisualStyle", "wireframe" => "WireframeVisualStyle", "hidden-edges" => "ShadedWithHiddenEdgesVisualStyle", "wireframe-hidden" => "WireframeWithHiddenEdgesVisualStyle", _ => "ShadedWithVisibleEdgesOnlyVisualStyle" }; format!("vp.visualStyle=adsk.core.VisualStyles.{st}; vp.refresh() out={{'action':'style','style':'{st}'}}") }-        _ => return json!({"success": false, "error": "action must be fit | home | iso | top | front | right | back | left | bottom | orbit {degrees} | zoom {factor} | pan {dx,dy} | style {style}", "_hint": "3D actions move the active Design's camera; pass space:\"2d\" for a schematic, board or library editor."}),+        _ => return json!({"success": false, "error": "action must be fit | home | iso | top | front | right | back | left | bottom | orbit {degrees} | tilt {degrees} | zoom {factor} | pan {dx,dy} | focus {x,y,z in cm} | style {style}", "_hint": "3D actions move the active Design's camera; pass space:\"2d\" for a schematic, board or library editor."}),     };     let script = cam_script(&body).replace("put(c, SMOOTH)", if smooth { "put(c, True)" } else { "put(c, False)" }).replace("vp.goHome(SMOOTH)", if smooth { "vp.goHome(True)" } else { "vp.goHome(False)" });     let mut r = pass("run_modeling_script", json!({"script": script}));@@ -426,10 +430,10 @@ result={'jobs': adsk.core.Application.get().hasActiveJobs}"}));         if r["success"].as_bool() == Some(true) { jobs = r["data"]["result"]["jobs"].as_bool(); latency_ms = Some(t0.elapsed().as_millis() as u64); }     }     let interactive = addin_up && !addin_busy && jobs != Some(true) && latency_ms.map(|l| l < 2500).unwrap_or(true);-    json!({"success": true, "interactive": interactive, "addinUp": addin_up, "addinBusy": addin_busy, "activeJobs": jobs, "mainThreadMs": latency_ms,+    json!({"success": true, "interactive": interactive, "addinUp": addin_up, "addinBusy": addin_busy, "activeJobs": jobs, "mainThreadMs": latency_ms, "palette": crate::palette::status(),         "_hint": if interactive { "Fusion is taking input: drive it. The tour's narrator stamps fusion-busy / fusion-interactive transitions in the record from this probe." } else { "Fusion is busy (a long command, active jobs, or a slow main thread); wait and probe again before driving." }}) }-struct Sample { lbr: String, step: Option<String>, deviceset: String, package: String, pins: usize, pads: usize }+struct Sample { lbr: String, step: Option<String>, deviceset: String, package: String, pins: usize, pads: usize, pin_pts: Vec<(f64, f64)>, pad_pts: Vec<(f64, f64)> } 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.@@ -437,7 +441,10 @@ 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 })+    let pts = |re: &str| -> Vec<(f64, f64)> { regex::Regex::new(re).map(|r| r.captures_iter(&text).filter_map(|c| Some((c[1].parse().ok()?, c[2].parse().ok()?))).collect()).unwrap_or_default() };+    let pin_pts = pts(r#"<pin [^>]*x="([-\d.]+)" y="([-\d.]+)""#);+    let pad_pts = pts(r#"<(?:smd|pad) [^>]*x="([-\d.]+)" y="([-\d.]+)""#);+    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, pin_pts, pad_pts }) } /// 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 {@@ -482,7 +489,7 @@ fn pass_patient(pass: &dyn Fn(&str, Value) -> Value, verb: &str, args: Value) ->     loop {         let r = pass(verb, args.clone());         let busy = r["success"].as_bool() != Some(true) && r["error"].as_str().map(|e| e.contains("main thread busy") || e.contains("Fusion is busy") || e.contains("busy_")).unwrap_or(false);-        if !busy || t0.elapsed() > Duration::from_secs(12) || EXIT.load(Ordering::Relaxed) { return r; }+        if !busy || t0.elapsed() > Duration::from_secs(20) || EXIT.load(Ordering::Relaxed) { return r; }         sleep_s(0.4);     } }@@ -541,6 +548,8 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         let mut r = json!({"step": key, "label": label, "ok": false});         let mut expected = String::new();         let mut kind = "none";+        let mut idle_boxes: Vec<Value> = Vec::new();+        let mut idle_kind = String::new();         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 ----@@ -688,8 +697,13 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                 rec(|s| s["verified"] = v.clone());                 event("verified", json!({"ok": v["ok"], "front": v["front"], "painted": v["painted"], "stable": v["stable"]}));                 if v["ok"].as_bool() == Some(true) && kind != "none" {-                    event("motion-start", json!({"kind": kind}));-                    r["motion"] = motion(*h, kind, pass, &script);+                    let parts = match key { "schematic" => part_positions(ctx, env, pass, "Instance"), "board2d" | "board3d" => part_positions(ctx, env, pass, "Element"), _ => Vec::new() };+                    let plan = plan_for(key, &sample, &parts, &board_name);+                    r["parts"] = json!(parts.len());+                    event("motion-start", json!({"kind": kind, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>(), "parts": parts.len()}));+                    idle_boxes = plan.iter().filter(|p| p.1["action"] == "pan").map(|p| p.1.clone()).collect();+                    idle_kind = kind.to_string();+                    r["motion"] = motion(*h, kind, pass, plan);                     event("motion-done", json!({"gestures": r["motion"]["gestures"], "viewChanged": r["motion"]["viewChanged"], "aborted": r["motion"]["aborted"]}));                 } else if kind != "none" {                     r["motion"] = json!({"skipped": format!("not verified: front={} title={} painted={} (top window: {})", v["front"], v["title"], v["painted"], v["frontTitle"])});@@ -707,11 +721,19 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env         // beat ends when the sentence on air ends (bounded), honouring the remote         BEAT_BUSY.store(false, Ordering::Relaxed);         let wait_t0 = Instant::now();+        let mut tick: u64 = 0;+        let mut idle_gestures: u64 = 0;         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);+            // keep the content moving while the narration finishes+            if !idle_kind.is_empty() && r["motion"]["gestures"].as_u64().unwrap_or(0) > 0 && fusion.as_ref().map(|(h, _)| front_ok(*h)).unwrap_or(false) {+                let o = idle_step(&idle_kind, tick, &idle_boxes, pass); tick += 1;+                if o["success"].as_bool() == Some(true) { idle_gestures += 1; }+                sleep_s(if idle_kind == "2d" { 2.2 } else { 1.0 });+            } else { sleep_s(0.4); }         }+        if idle_gestures > 0 { r["motion"]["idleGestures"] = json!(idle_gestures); }         event("beat-end", json!({"index": i + 1, "key": key, "seconds": beat_t0.elapsed().as_secs_f64()}));         if EXIT.load(Ordering::Relaxed) { exited = true; break; }         i += 1;@@ -735,23 +757,93 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env     if snap()["demoId"].as_str() == Some(my_id.as_str()) { let s = snap(); crate::demo::persist_pub(&sd, &s); } } +fn bbox(pts: &[(f64, f64)]) -> Option<(f64, f64, f64, f64)> { if pts.is_empty() { return None; } Some((pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max))) }+fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 18}) }+/// Positions of the parts on the active board or schematic (mm), from the electronics read model.+fn part_positions(ctx: &Ctx, env: &Env, pass: &dyn Fn(&str, Value) -> Value, entity: &str) -> Vec<(String, String, f64, f64)> {+    let r = crate::electronics::query(ctx, &json!({"entity": entity, "fields": ["name", "value", "x", "y"], "limit": 600}), env.version, pass);+    r["items"].as_array().or(r["rows"].as_array()).map(|a| a.iter().filter_map(|it| Some((it["name"].as_str().unwrap_or("").to_string(), it["value"].as_str().unwrap_or("").to_string(), it["x"].as_f64()?, it["y"].as_f64()?))).collect()).unwrap_or_default()+}+fn find_ic(parts: &[(String, String, f64, f64)], board_name: &str) -> Option<(f64, f64)> {+    let bn = board_name.to_lowercase();+    parts.iter().find(|p| !bn.is_empty() && p.1.to_lowercase().contains(&bn)).or_else(|| parts.iter().find(|p| p.0.to_uppercase().starts_with("IC") || p.0.to_uppercase().starts_with("U1"))).map(|p| (p.2, p.3))+}+/// The motion plan for a beat: what an engineer would look at, from the data on screen. John,+/// 2026-09-05: "lots of smart motion as if the user is inspecting things in each window".+fn plan_for(key: &str, sample: &Sample, parts: &[(String, String, f64, f64)], board_name: &str) -> Vec<(&'static str, Value)> {+    let fit2 = || json!({"action": "fit"});+    let zoom_only = || vec![("fit", fit2()), ("zoom-in", json!({"action": "zoom", "factor": 1.8})), ("zoom-out", json!({"action": "zoom", "factor": 0.55})), ("fit-2", fit2())];+    match key {+        "symbol" => {+            let Some((x1, y1, x2, y2)) = bbox(&sample.pin_pts) else { return zoom_only() };+            let (w, h) = (x2 - x1, y2 - y1); let m = 3.0;+            vec![("fit", fit2()),+                 ("top-left pins", pan_box(x1 - m, y1 + h * 0.55, x1 + w * 0.5, y2 + m)),+                 ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.45)),+                 ("right pins", pan_box(x1 + w * 0.5, y1 + h * 0.35, x2 + m, y2 + m)),+                 ("fit-2", fit2())]+        }+        "footprint" => {+            let Some((x1, y1, x2, y2)) = bbox(&sample.pad_pts) else { return zoom_only() };+            let (w, h) = (x2 - x1, y2 - y1); let m = 1.5;+            let (p1x, p1y) = sample.pad_pts.first().cloned().unwrap_or((x1, y1));+            vec![("fit", fit2()),+                 ("pin 1", pan_box(p1x - 2.5, p1y - 2.5, p1x + 2.5, p1y + 2.5)),+                 ("top row", pan_box(x1 - m, y2 - h * 0.25, x2 + m, y2 + m)),+                 ("far corner", pan_box(x2 - w * 0.35, y1 - m, x2 + m, y1 + h * 0.35)),+                 ("whole footprint", pan_box(x1 - m * 2.0, y1 - m * 2.0, x2 + m * 2.0, y2 + m * 2.0)),+                 ("fit-2", fit2())]+        }+        "chip" => vec![("iso", json!({"action": "iso"})), ("orbit-1", json!({"action": "orbit", "degrees": 35})), ("tilt-down", json!({"action": "tilt", "degrees": -28})), ("zoom-leads", json!({"action": "zoom", "factor": 1.7})), ("orbit-2", json!({"action": "orbit", "degrees": 55})), ("orbit-3", json!({"action": "orbit", "degrees": 55})), ("tilt-up", json!({"action": "tilt", "degrees": 30})), ("zoom-out", json!({"action": "zoom", "factor": 0.6})), ("home", json!({"action": "iso"}))],+        "schematic" => {+            let Some((x1, y1, x2, y2)) = bbox(&parts.iter().map(|p| (p.2, p.3)).collect::<Vec<_>>()) else { return zoom_only() };+            let ic = find_ic(parts, board_name).unwrap_or(((x1 + x2) / 2.0, (y1 + y2) / 2.0));+            let left = parts.iter().min_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).map(|p| (p.2, p.3)).unwrap_or((x1, y2));+            let right = parts.iter().max_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).map(|p| (p.2, p.3)).unwrap_or((x2, y1));+            let r = 28.0;+            vec![("fit", fit2()), ("the controller", pan_box(ic.0 - r, ic.1 - r * 0.6, ic.0 + r, ic.1 + r * 0.6)), ("input side", pan_box(left.0 - r, left.1 - r * 0.6, left.0 + r, left.1 + r * 0.6)), ("output side", pan_box(right.0 - r, right.1 - r * 0.6, right.0 + r, right.1 + r * 0.6)), ("fit-2", fit2())]+        }+        "board2d" => {+            let Some((x1, y1, x2, y2)) = bbox(&parts.iter().map(|p| (p.2, p.3)).collect::<Vec<_>>()) else { return zoom_only() };+            let ic = find_ic(parts, board_name).unwrap_or(((x1 + x2) / 2.0, (y1 + y2) / 2.0));+            let r = 9.0;+            vec![("fit", fit2()), ("the controller", pan_box(ic.0 - r, ic.1 - r * 0.6, ic.0 + r, ic.1 + r * 0.6)), ("lower-left corner", pan_box(x1 - 4.0, y1 - 4.0, x1 + 2.0 * r, y1 + 1.4 * r)), ("upper-right corner", pan_box(x2 - 2.0 * r, y2 - 1.4 * r, x2 + 4.0, y2 + 4.0)), ("fit-2", fit2())]+        }+        "board3d" => {+            let ic = find_ic(parts, board_name).map(|(x, y)| (x / 10.0, y / 10.0));+            let mut v = vec![("iso", json!({"action": "iso"})), ("orbit-1", json!({"action": "orbit", "degrees": 40}))];+            match ic {+                Some((cx, cy)) => { v.push(("focus the controller", json!({"action": "focus", "x": cx, "y": cy, "z": 0.15}))); v.push(("zoom-in", json!({"action": "zoom", "factor": 2.2}))); v.push(("tilt-low", json!({"action": "tilt", "degrees": -30}))); v.push(("orbit-2", json!({"action": "orbit", "degrees": 70}))); v.push(("tilt-back", json!({"action": "tilt", "degrees": 25}))); v.push(("zoom-out", json!({"action": "zoom", "factor": 0.45}))); }+                None => { v.push(("zoom-in", json!({"action": "zoom", "factor": 1.5}))); v.push(("tilt-low", json!({"action": "tilt", "degrees": -30}))); v.push(("orbit-2", json!({"action": "orbit", "degrees": 70}))); v.push(("zoom-out", json!({"action": "zoom", "factor": 0.66}))); }+            }+            v.push(("whole board", json!({"action": "iso"}))); v+        }+        _ => vec![],+    }+}+/// One gentle idle gesture (John, 2026-09-05: the content never sits still): in 3D a slow orbit+/// with a breath of zoom every few ticks; in 2D a slow drift to the next inspection box.+fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, Value) -> Value) -> Value {+    if kind == "3d" {+        let args = if tick % 7 == 6 { json!({"action": "zoom", "factor": if (tick / 7) % 2 == 0 { 1.12 } else { 0.9 }}) } else { json!({"action": "orbit", "degrees": 6}) };+        view(pass, &args)+    } else if !boxes.is_empty() {+        let mut b = boxes[(tick as usize) % boxes.len()].clone(); b["space"] = json!("2d"); b["steps"] = json!(30);+        view(pass, &b)+    } else { json!({"success": true}) }+} /// 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!({"action": "fit"})), ("zoom-in", json!({"action": "zoom", "factor": 1.8, "steps": 14})), ("zoom-in-2", json!({"action": "zoom", "factor": 1.4, "steps": 10})), ("zoom-out", json!({"action": "zoom", "factor": 0.55, "steps": 14})), ("fit-2", json!({"action": "fit"}))]-    } else {-        vec![("iso", json!({"action": "iso"})), ("orbit-1", json!({"action": "orbit", "degrees": 40})), ("orbit-2", json!({"action": "orbit", "degrees": 40})), ("zoom-in", json!({"action": "zoom", "factor": 1.33})), ("orbit-3", json!({"action": "orbit", "degrees": 50})), ("zoom-out", json!({"action": "zoom", "factor": 0.74})), ("orbit-back", json!({"action": "orbit", "degrees": -65})), ("orbit-back-2", json!({"action": "orbit", "degrees": -65}))]-    };+fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<(&str, Value)>) -> Value {+    let mut rep = json!({"kind": kind, "gestures": 0, "aborted": null, "plan": plan.iter().map(|p| p.0).collect::<Vec<_>>()});     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" { view(pass, &json!({"space": "2d", "action": body["action"], "factor": body["factor"], "steps": body["steps"]})) } else { view(pass, &body) };+        let o = if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) };         if o["success"].as_bool() != Some(true) { rep["aborted"] = json!(format!("{name}: {}", o["error"].as_str().unwrap_or("script failed"))); break; }         rep["gestures"] = json!(rep["gestures"].as_u64().unwrap_or(0) + 1);-        sleep_s(0.6);+        sleep_s(if kind == "2d" { 1.4 } else { 1.1 });   // let the eye land before the next move     }     let after = shot_window(hwnd).and_then(|p| digest(&p));     rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);