← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.367\ No newline at end of file+1.9.368\ No newline at end of file
addin/AdomBridge/commands/activity_palette.py+18
@@ -238,6 +238,24 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict:                 _palette.dockingState = adsk.core.PaletteDockingStates.PaletteDockStateRight             except Exception:                 pass+            # Fusion's electronics editors leave it FLOATING (dockingState reads 0 after the+            # set, 2026-09-05) and drop it over the top right of the canvas, where it hid the+            # symbol's right pins in the tour. A floating palette is kept small and parked over+            # the right-hand panel column, off the drawing.+            try:+                if int(_palette.dockingState) == 0:+                    _palette.setSize(320, 210)+                    import ctypes+                    sw = int(ctypes.windll.user32.GetSystemMetrics(0))+                    try:+                        scale = float(ctypes.windll.shcore.GetScaleFactorForDevice(0)) / 100.0+                    except Exception:+                        scale = 1.0+                    if scale <= 0:+                        scale = 1.0+                    _palette.setPosition(int(sw / scale) - 320 - 44, 150)+            except Exception:+                pass         else:             if _user_closed[0]:                 # The user closed it during this run: honour that, keep the content current
addin/AdomBridge/commands/electron_view.py+75−23
@@ -14,9 +14,32 @@ forced to render with `Electron.ImmediateRepaint /full` (the 2D analogue of vp.r import time import adsk.core -# Last view box we set, in board mm (x1, y1, x2, y2). Lets pan interpolate from where-# the view actually is. None until the first pan establishes it.+# Last view box we set, in mm (x1, y1, x2, y2), and the editor it belongs to. Lets pan+# interpolate from where the view actually is. None until the first pan establishes it;+# forgotten when the active document or product changes (a box from the symbol editor+# must never seed a glide in the footprint editor - 2026-09-05, the tour panned to nothing). _view_box = None+_view_owner = None+++def _owner(app):+    try:+        return (app.activeDocument.name, app.activeProduct.productType)+    except Exception:+        return None+++def _mm(v):+    # EAGLE reads bare coordinates in the editor's CURRENT grid unit (0.1 inch in a symbol+    # editor, 1 mm in a package editor); an explicit unit makes the box mean the same thing+    # in every editor.+    return f"{float(v):.3f}mm"+++def forget_view():+    global _view_box, _view_owner+    _view_box = None+    _view_owner = None   def _run(app, eagle_cmd):@@ -24,8 +47,12 @@ def _run(app, eagle_cmd):   def _repaint(app):+    # The electronics editor does not PRESENT a new view until the next command arrives+    # (2026-09-05: a WINDOW box looked dropped for seconds, then appeared the moment any other+    # command came in). A bare WINDOW is EAGLE's redraw and is that next command; the+    # ImmediateRepaint text command does not do it.     try:-        app.executeTextCommand("Electron.ImmediateRepaint /full")+        app.executeTextCommand("Electron.run WINDOW")     except Exception:         pass @@ -41,6 +68,7 @@ def handle_electron_zoom(app: adsk.core.Application, args: dict) -> dict:     """     if args.get("fit"):         _run(app, "WINDOW FIT")+        forget_view()         _repaint(app)         return {"success": True, "output": "Zoomed to fit."} @@ -51,6 +79,7 @@ def handle_electron_zoom(app: adsk.core.Application, args: dict) -> dict:     for _ in range(steps):         _run(app, f"WINDOW {per:.5f}")         _repaint(app)+        _repaint(app)         adsk.doEvents()         time.sleep(delay)     return {@@ -60,49 +89,72 @@ def handle_electron_zoom(app: adsk.core.Application, args: dict) -> dict:   def handle_electron_pan(app: adsk.core.Application, args: dict) -> dict:-    """Smoothly pan + zoom the view to frame a board-coordinate box.+    """Smoothly pan + zoom the view to frame a box in mm. -    Coords are board millimetres (get part positions from fusion_board_info). The view-    animates from wherever it last was (tracked) to the target box.+    Coords are millimetres in the active editor (board, schematic, symbol or footprint),+    sent to EAGLE with an explicit mm unit. The view animates from the last box this+    add-in set in the same editor, or from `from` when given (the caller knows the fitted+    extent after WINDOW FIT); with neither it jumps.      Args:-        x1, y1, x2, y2: required floats - the target rectangle (board mm).+        x1, y1, x2, y2: required floats - the target rectangle (mm).+        from:           [x1, y1, x2, y2] - where the view is now (optional).         steps:          int   - frames. Default 16.         frameDelayMs:   int   - ms between frames. Default 35.     """-    global _view_box+    global _view_box, _view_owner     try:         tx1, ty1 = float(args["x1"]), float(args["y1"])         tx2, ty2 = float(args["x2"]), float(args["y2"])     except (KeyError, TypeError, ValueError):-        return {"success": False, "error": "Need x1,y1,x2,y2 (board mm). Get part positions from fusion_board_info."}+        return {"success": False, "error": "Need x1,y1,x2,y2 (mm). Get part positions from fusion_board_info."}      steps = max(1, int(args.get("steps", 16)))-    delay = float(args.get("frameDelayMs", 35)) / 1000.0--    if _view_box is None:-        _run(app, f"WINDOW ({tx1:.3f} {ty1:.3f}) ({tx2:.3f} {ty2:.3f})")+    delay = max(0.03, float(args.get("frameDelayMs", 35)) / 1000.0)+    owner = _owner(app)+    if _view_owner != owner:+        _view_box = None+    start = None+    frm = args.get("from")+    if isinstance(frm, (list, tuple)) and len(frm) == 4:+        try:+            start = tuple(float(v) for v in frm)+        except (TypeError, ValueError):+            start = None+    if start is None:+        start = _view_box++    def frame(x1, y1, x2, y2):+        _run(app, f"WINDOW ({_mm(x1)} {_mm(y1)}) ({_mm(x2)} {_mm(y2)})")         _repaint(app)++    if start is None:+        frame(tx1, ty1, tx2, ty2)         _view_box = (tx1, ty1, tx2, ty2)-        return {"success": True, "output": "Framed region (no animation - first view set; later pans animate).",+        _view_owner = owner+        return {"success": True, "output": "Framed region (no animation - first view set; later pans animate, or pass from:[x1,y1,x2,y2]).",                 "data": {"box": _view_box}} -    sx1, sy1, sx2, sy2 = _view_box+    sx1, sy1, sx2, sy2 = start     for i in range(1, steps + 1):         t = i / steps-        x1 = sx1 + (tx1 - sx1) * t-        y1 = sy1 + (ty1 - sy1) * t-        x2 = sx2 + (tx2 - sx2) * t-        y2 = sy2 + (ty2 - sy2) * t-        _run(app, f"WINDOW ({x1:.3f} {y1:.3f}) ({x2:.3f} {y2:.3f})")-        _repaint(app)+        # ease in and out so the glide reads as a hand on the wheel, not a slide show+        t = t * t * (3 - 2 * t)+        frame(sx1 + (tx1 - sx1) * t, sy1 + (ty1 - sy1) * t, sx2 + (tx2 - sx2) * t, sy2 + (ty2 - sy2) * t)         adsk.doEvents()         time.sleep(delay)+    # the settle frame: the package editor drops WINDOW commands that arrive while it is still+    # laying out the previous one (2026-09-05: a glide could end one gesture behind), so the+    # target is issued once more after the editor has had a moment+    time.sleep(0.3)+    adsk.doEvents()+    frame(tx1, ty1, tx2, ty2)+    adsk.doEvents()     _view_box = (tx1, ty1, tx2, ty2)+    _view_owner = owner     return {"success": True,             "output": f"Panned to ({tx1:.1f},{ty1:.1f})-({tx2:.1f},{ty2:.1f}) over {steps} frames.",-            "data": {"box": _view_box}}-+            "data": {"box": _view_box, "from": list(start)}}  def handle_electron_select(app: adsk.core.Application, args: dict) -> dict:     """Select a part (by reference designator, or by x/y board coords) and read its
addin/AdomBridge/commands/electronics.py+10
@@ -480,6 +480,16 @@ def handle_electron_run(app: adsk.core.Application, args: dict) -> dict:      try:         raw_result = app.executeTextCommand(f"Electron.run {eagle_cmd}")+        if cmd_verb == "WINDOW" and eagle_cmd.strip().rstrip(";").strip().upper() != "WINDOW":+            # a view command is not presented until the next command arrives: send the bare+            # WINDOW (EAGLE's redraw) so a capture or a recording right after this call shows+            # the new view, and forget any tracked pan box (2026-09-05)+            try:+                from . import electron_view as _ev+                _ev._repaint(app)+                _ev.forget_view()+            except Exception:+                pass     except Exception as e:         return {             "success": False,
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.367",+  "version": "1.9.368",   "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.367"+version = "1.9.368" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.367"+version = "1.9.368" 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.367"+DASH_VERSION="1.9.368" 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.367",+  "version": "1.9.368",   "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.367",+  "version": "1.9.368",   "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.367"+version = "1.9.368" edition = "2021" license = "MIT" 
rust/PORTING.md+13
@@ -582,3 +582,16 @@ port learned:   now skips `BRIDGE_PALETTE_TITLE`; EDIT is 3.3 s, execute_text_command under 2 s. Lesson: when a   verb is slow, ask the verb. Slow pass-throughs (2 s and up) now answer `_timing`   {preSec, addinSec, dialogsSec, postSec} so the envelope can never hide behind the work again.+- **Fusion's electronics editors do not honour a WINDOW box on command (2026-09-05, runs 37+  and 38, about forty screenshot trials on the package editor).** `WINDOW (x1 y1) (x2 y2)`+  reads bare numbers in the editor's current grid unit (0.1 inch in a symbol editor), and even+  with an explicit `mm` suffix it is applied only sometimes and presented later or never; a+  glide of twelve boxes from one add-in call showed nothing, and separate calls behaved+  differently from one minute to the next. `WINDOW <factor>` (a smooth factor zoom) and the+  real pointer moving over the drawing (pads and pins highlight, tooltips name them) rendered+  every single time, so every 2D beat is built from those two and no plan pans by box. The+  lesson under the lesson: verify motion from the recording's frames (tools: ralph.py in the+  scratchpad, per-beat montage plus a blank and a motion number), never from a verb's `success`.+- **The activity palette floats over the canvas in the electronics editors** (its docking+  request reads back 0); the tour parks it over the right panel column with+  desktop_set_window_bounds, and the palette API's setPosition throws there.
rust/bridge/src/tour.rs+123−39
@@ -294,6 +294,23 @@ fn place_fusion(sd: &std::path::Path, hwnd: i64, title: &str) -> Value {     let r = ab("desktop_set_window_bounds", json!({"hwnd": hwnd, "x": x, "y": y, "w": w, "h": h, "reason": "Fusion tour: place Fusion at 95% of the screen, centred"}), 8);     json!({"placed": r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true), "x": x, "y": y, "w": w, "h": h, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())}) }+/// The activity palette floats over the top right of the canvas in Fusion's electronics editors+/// (its docking request is ignored there), which hid the symbol's right pins in run 37. Park it+/// over the right-hand panel column of the placed Fusion window, off the drawing, at a size+/// that shows the whole card. Returns the rect it was given, or why not.+fn park_palette(fusion: &Value) -> Value {+    let rows = crate::managed::find_key(&ab("desktop_enum_windows", json!({}), 10), "windows", 0).and_then(|w| w.as_array().cloned()).unwrap_or_default();+    let Some(pal) = rows.iter().find(|w| w["title"].as_str().map(|t| t.trim().eq_ignore_ascii_case(fusion_core::dialogs::BRIDGE_PALETTE_TITLE)).unwrap_or(false) && w["visible"].as_bool() != Some(false)) else { return json!({"parked": false, "why": "no palette window"}) };+    let Some(h) = pal["hwnd"].as_i64() else { return json!({"parked": false, "why": "palette without hwnd"}) };+    let (fx, fy, fw) = (fusion["x"].as_i64().unwrap_or(0), fusion["y"].as_i64().unwrap_or(0), fusion["w"].as_i64().unwrap_or(0));+    if fw < 1200 { return json!({"parked": false, "why": "Fusion bounds unknown"}); }+    let (w, hh) = (640, 420);+    let (x, y) = (fx + fw - w - 24, fy + 250);+    let cur = pal["rect"].as_array().cloned().unwrap_or_default();+    if cur.len() == 4 && cur[0].as_i64() == Some(x) && cur[1].as_i64() == Some(y) { return json!({"parked": true, "x": x, "y": y, "w": w, "h": hh, "already": true}); }+    let r = ab("desktop_set_window_bounds", json!({"hwnd": h, "x": x, "y": y, "w": w, "h": hh, "reason": "Fusion tour: park the Adom activity palette over the right panel, off the drawing"}), 8);+    json!({"parked": r["success"].as_bool() == Some(true), "x": x, "y": y, "w": w, "h": hh, "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(); }@@ -395,15 +412,50 @@ fn cam_script(body: &str) -> String { format!("import adsk.core, adsk.fusion, ma /// `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 /// motion uses this verb, so the tour tests it. John, 2026-09-05: motion verbs are first-class.+/// The last 2D box this exe framed (mm), so a glide can start from where the view is.+static LAST_BOX2D: Mutex<Option<(f64, f64, f64, f64)>> = Mutex::new(None);+/// One EAGLE command straight to the add-in, no envelope: a view frame is ~50 ms this way.+fn eagle(cmd: &str) -> Value { crate::addin::addin_http("execute_text_command", &json!({"command": format!("Electron.run {cmd}")}), 8) }+/// A 2D glide driven from HERE, one add-in call per frame. The electronics editor applies a+/// WINDOW box only when the main thread returns to its event loop, and presents it only when+/// the next command arrives (2026-09-05, proven on the package editor with screenshots): a+/// glide sent as one add-in call never shows, a glide sent as separate calls does, and a+/// bare WINDOW (EAGLE's redraw) presents the last frame.+fn glide_2d(from: Option<(f64, f64, f64, f64)>, to: (f64, f64, f64, f64), steps: u64, delay_ms: u64) -> Value {+    let start = from.or(*LAST_BOX2D.lock().unwrap());+    let mm = |v: f64| format!("{v:.3}mm");+    let frame = |b: (f64, f64, f64, f64)| eagle(&format!("WINDOW ({} {}) ({} {})", mm(b.0), mm(b.1), mm(b.2), mm(b.3)));+    let mut frames = 0u64;+    match start {+        None => { let r = frame(to); if r["success"].as_bool() != Some(true) { return r; } frames += 1; }+        Some(s) => {+            for i in 1..=steps.max(1) {+                let t = i as f64 / steps.max(1) as f64; let t = t * t * (3.0 - 2.0 * t);+                let b = (s.0 + (to.0 - s.0) * t, s.1 + (to.1 - s.1) * t, s.2 + (to.2 - s.2) * t, s.3 + (to.3 - s.3) * t);+                let r = frame(b); if r["success"].as_bool() != Some(true) { return r; } frames += 1;+                std::thread::sleep(Duration::from_millis(delay_ms));+            }+        }+    }+    std::thread::sleep(Duration::from_millis(150));+    let _ = eagle("WINDOW");+    *LAST_BOX2D.lock().unwrap() = Some(to);+    json!({"success": true, "frames": frames, "box": [to.0, to.1, to.2, to.3], "from": start.map(|s| json!([s.0, s.1, s.2, s.3]))})+} pub fn view(pass: &dyn Fn(&str, Value) -> Value, args: &Value) -> Value {     let action = args["action"].as_str().unwrap_or("").to_lowercase();     let smooth = args["smooth"].as_bool().unwrap_or(true);     let space = args["space"].as_str().unwrap_or("3d").to_lowercase();     if space == "2d" {         let r = match action.as_str() {-            "fit" => pass("electron_zoom", json!({"fit": true})),-            "zoom" => pass("electron_zoom", json!({"factor": args["factor"].as_f64().unwrap_or(1.6), "steps": args["steps"].as_u64().unwrap_or(14)})),-            "pan" => pass("electron_pan", json!({"x1": args["x1"], "y1": args["y1"], "x2": args["x2"], "y2": args["y2"], "steps": args["steps"].as_u64().unwrap_or(16)})),+            "fit" => { let r = pass("electron_zoom", json!({"fit": true})); *LAST_BOX2D.lock().unwrap() = None; std::thread::sleep(Duration::from_millis(150)); let _ = eagle("WINDOW"); r },+            "zoom" => { let r = pass("electron_zoom", json!({"factor": args["factor"].as_f64().unwrap_or(1.6), "steps": args["steps"].as_u64().unwrap_or(14)})); *LAST_BOX2D.lock().unwrap() = None; std::thread::sleep(Duration::from_millis(150)); let _ = eagle("WINDOW"); r },+            "pan" => {+                let f = |k: &str| args[k].as_f64();+                let (Some(x1), Some(y1), Some(x2), Some(y2)) = (f("x1"), f("y1"), f("x2"), f("y2")) else { return json!({"success": false, "error": "pan needs x1, y1, x2, y2 in mm"}) };+                let from = args["from"].as_array().filter(|a| a.len() == 4).and_then(|a| Some((a[0].as_f64()?, a[1].as_f64()?, a[2].as_f64()?, a[3].as_f64()?)));+                glide_2d(from, (x1, y1, x2, y2), args["steps"].as_u64().unwrap_or(6), args["frameDelayMs"].as_u64().unwrap_or(120))+            },             _ => return json!({"success": false, "error": "2D actions: fit | zoom {factor} | pan {x1,y1,x2,y2}", "_hint": "space:\"2d\" drives the active schematic, board or library editor through Fusion's WINDOW commands."}),         };         let mut r = r; r["action"] = json!(action); r["space"] = json!("2d"); return r;@@ -606,7 +658,7 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env                 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();+                expected = "AdomDemoSample".into(); kind = "2d";             }             "symbol" => {                 caption(&format!("Fusion tour 2/{n}: the symbol - all {} pins, converted pin for pin", sample.pins), 30000);@@ -739,12 +791,17 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env             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 pb = snap()["placedAtStart"].clone(); let pk = park_palette(&pb); if pk["parked"].as_bool() == Some(true) && pk["already"].as_bool() != Some(true) { rec(|s| s["palettePlaced"] = pk.clone()); } }                 let v = verify_beat(*h, &expected);                 r["verified"] = v.clone();                 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" {-                    let parts = match key { "schematic" => part_positions(ctx, env, pass, "Instance"), "board2d" | "board3d" => part_positions(ctx, env, pass, "Element"), _ => Vec::new() };+                    // the editor on screen must be the one the caption names (run 37 showed the+                    // board under the schematic sentence): probe, switch again, record the truth+                    match key { "schematic" => { r["editor"] = assert_editor(pass, "Schematic", "SwitchSchDocCmd"); } "board2d" => { r["editor"] = assert_editor(pass, "Board", "SwitchPcbDocCmd"); } _ => {} }+                    let mut parts = match key { "schematic" => part_positions(ctx, env, pass, "Instance"), "board2d" | "board3d" => part_positions(ctx, env, pass, "Element"), _ => Vec::new() };+                    if parts.is_empty() && matches!(key, "schematic" | "board2d" | "board3d") { sleep_s(3.0); parts = part_positions(ctx, env, pass, if key == "schematic" { "Instance" } else { "Element" }); }                     let plan = plan_for(key, &sample, &parts, &board_name);                     r["parts"] = json!(parts.len());                     event("part-b", json!({"key": key}));@@ -792,27 +849,59 @@ fn run_show(ctx: &Ctx, pass_raw: &dyn Fn(&str, Value) -> Value, args: Value, env     // ---- 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: the sample library did not open", 6000); } else { audio_play("done"); caption(&format!("Fusion tour done: {ok_ct} of {n} beats, from symbol to 3D board to cloud search"), 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" });+    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"] = Value::Null; 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();+    // John, 2026-09-05: the outro shows the documents closing (that is a feature too); the+    // windows go back to where they were only after the last tab is gone     let closed = crate::mint::close_docs_opened_since(ctx, &docs_before, &[], pass);+    let restored = restore_bounds(&sd, if exited { "stop" } else { "end" });     crate::palette::hold(false); crate::palette::hide();-    rec_for(&sd, &my_id, |s| { s["tourDocsClosed"] = json!(closed); s["closeSec"] = json!(close_t0.elapsed().as_secs()); });+    rec_for(&sd, &my_id, |s| { s["tourDocsClosed"] = json!(closed); s["closeSec"] = json!(close_t0.elapsed().as_secs()); s["windowsRestored"] = json!(restored); });     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": 12, "frameDelayMs": 10}) }+fn pan_box(x1: f64, y1: f64, x2: f64, y2: f64) -> Value { json!({"action": "pan", "x1": x1, "y1": y1, "x2": x2, "y2": y2, "steps": 6, "frameDelayMs": 150}) }+/// A pause inside a plan: the package editor drops any WINDOW box that arrives within ~8 s of a+/// WINDOW FIT, so a plan that must fit first waits before its first glide.+fn settle(seconds: f64) -> Value { json!({"action": "wait", "seconds": seconds}) }+/// The first glide after WINDOW FIT starts from the fitted extent (the add-in cannot know it).+fn pan_from(fit: (f64, f64, f64, f64), x1: f64, y1: f64, x2: f64, y2: f64) -> Value { let mut b = pan_box(x1, y1, x2, y2); b["from"] = json!([fit.0, fit.1, fit.2, fit.3]); b }+/// The pointer walks the left pin column of a fitted symbol (window fractions, 4K box): pins+/// highlight under it, which is what a person does when they read a symbol.+fn pin_sweep() -> Value { json!({"action": "sweep", "path": [[0.40, 0.30], [0.40, 0.37], [0.40, 0.44], [0.40, 0.51], [0.40, 0.58], [0.40, 0.65], [0.62, 0.60], [0.62, 0.45], [0.62, 0.32]]}) } /// 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() }+/// Which electronics editor is on screen, from Fusion itself; re-issue the switch when it is not+/// the one wanted. `want` is a substring of DebugCommands.ActiveWorkspace ("Schematic", "Board").+fn assert_editor(pass: &dyn Fn(&str, Value) -> Value, want: &str, switch_cmd: &str) -> Value {+    let probe = |pass: &dyn Fn(&str, Value) -> Value| -> (String, String) {+        let r = pass("run_modeling_script", json!({"script": "import adsk.core\napp=adsk.core.Application.get()\nws=''\ntry:\n    ws=app.executeTextCommand('DebugCommands.ActiveWorkspace')\nexcept Exception:\n    pass\npt=''\ntry:\n    pt=app.activeProduct.productType\nexcept Exception:\n    pass\nresult={'ws': ws, 'pt': pt}"}));+        (r["data"]["result"]["ws"].as_str().unwrap_or("").trim().to_string(), r["data"]["result"]["pt"].as_str().unwrap_or("").to_string())+    };+    let (ws0, pt0) = probe(pass);+    let mut out = json!({"want": want, "workspace": ws0, "productType": pt0, "switched": false});+    // the workspace name or the product type may carry the word (run 38: the board editor+    // reports BoardProductType under a workspace not named "Board")+    let is_wanted = |ws: &str, pt: &str| ws.contains(want) || pt.contains(want);+    if !is_wanted(&ws0, &pt0) {+        let _ = pass("execute_text_command", json!({"command": format!("Commands.Start {switch_cmd}")}));+        sleep_s(2.5);+        let _ = pass("electron_run", json!({"command": "WINDOW FIT"}));+        let (ws1, pt1) = probe(pass);+        out["switched"] = json!(true); out["workspaceAfter"] = json!(ws1.clone()); out["productTypeAfter"] = json!(pt1.clone()); out["ok"] = json!(is_wanted(&ws1, &pt1));+        eprintln!("[fusion-bridge] tour: the {want} editor was not on screen ({ws0:?}); switched again -> {ws1:?}");+    } else { out["ok"] = json!(true); }+    out+} 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))@@ -820,36 +909,27 @@ fn find_ic(parts: &[(String, String, f64, f64)], board_name: &str) -> Option<(f6 /// 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)> {+    // 2D motion is built from the two gestures Fusion's electronics editors render every time+    // (2026-09-05, ~40 screenshot trials on the package editor): a smooth WINDOW factor zoom+    // and the real pointer moving over the drawing (pads and pins highlight, tooltips name+    // them). A WINDOW box (pan) is applied only sometimes and presented later or never, so+    // no plan uses one.     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())];+    let z = |f: f64| json!({"action": "zoom", "factor": f, "steps": 12});+    let zoom_only = || vec![("zoom-in", z(2.2)), ("zoom-out", z(0.45)), ("zoom-in-2", z(1.6)), ("fit-2", fit2())];+    let _ = (sample, parts, board_name);     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.6, x1 + w * 0.5, y2 + m)),-                 ("mid-left pins", pan_box(x1 - m, y1 + h * 0.3, x1 + w * 0.5, y1 + h * 0.7)),-                 ("bottom-left pins", pan_box(x1 - m, y1 - m, x1 + w * 0.5, y1 + h * 0.4)),-                 ("bottom-right pins", pan_box(x1 + w * 0.5, y1 - m, x2 + m, y1 + h * 0.4)),-                 ("top-right pins", pan_box(x1 + w * 0.5, y1 + h * 0.6, x2 + m, y2 + m)),-                 ("whole symbol", pan_box(x1 - m * 2.0, y1 - m * 2.0, x2 + m * 2.0, y2 + m * 2.0)),-                 ("fit-2", fit2())]-        }+        "library" => vec![("fit", fit2()), ("pin sweep", pin_sweep()), ("zoom-in", z(1.8)), ("read the pins", pin_sweep()), ("zoom-out", z(0.55)), ("fit-2", fit2())],+        "symbol" => vec![("pin sweep", pin_sweep()), ("zoom-in", z(2.0)), ("read the pins", pin_sweep()), ("zoom-out", z(0.5)), ("zoom-in-2", z(1.5)), ("fit-2", fit2())],         "footprint" => {-            let Some((x1, y1, x2, y2)) = bbox(&sample.pad_pts) else { return zoom_only() };-            let (w, h) = (x2 - x1, y2 - y1); let m = 1.5;-            let (p1x, p1y) = sample.pad_pts.first().cloned().unwrap_or((x1, y1));             // after WINDOW FIT the footprint fills the canvas; the pointer sweeps the pad rows             // (window fractions measured on the 4K box: canvas 0.25..0.77 x 0.19..0.87)             let sweep = json!({"action": "sweep", "path": [[0.42, 0.32], [0.50, 0.32], [0.58, 0.32], [0.62, 0.45], [0.62, 0.60], [0.62, 0.72], [0.50, 0.77], [0.38, 0.70], [0.38, 0.50]]});-            vec![("fit", fit2()),-                 ("pad sweep", sweep),-                 ("pin 1", pan_box(p1x - 2.5, p1y - 2.5, p1x + 2.5, p1y + 2.5)),-                 ("top row", pan_box(x1 - m, y2 - h * 0.25, x2 + m, y2 + m)),-                 ("far corner", pan_box(x2 - w * 0.35, y1 - m, x2 + m, y1 + h * 0.35)),-                 ("whole footprint", pan_box(x1 - m * 2.0, y1 - m * 2.0, x2 + m * 2.0, y2 + m * 2.0)),-                 ("fit-2", fit2())]+            let inner = json!({"action": "sweep", "path": [[0.44, 0.40], [0.56, 0.40], [0.58, 0.55], [0.56, 0.66], [0.44, 0.66], [0.42, 0.52]]});+            vec![("pad sweep", sweep), ("zoom-in", z(1.8)), ("pad sweep close", inner), ("zoom-out", z(0.55)), ("zoom-in-2", z(2.4)), ("fit-2", fit2())]         }+        "schematic" => vec![("zoom-in", z(2.2)), ("net sweep", json!({"action": "sweep", "path": [[0.40, 0.40], [0.50, 0.42], [0.60, 0.45], [0.60, 0.58], [0.48, 0.62], [0.40, 0.56]]})), ("zoom-out", z(0.45)), ("zoom-in-2", z(1.6)), ("fit-2", fit2())],+        "board2d" => vec![("zoom-in", z(2.4)), ("copper sweep", json!({"action": "sweep", "path": [[0.42, 0.42], [0.50, 0.45], [0.58, 0.48], [0.58, 0.58], [0.48, 0.60], [0.42, 0.54]]})), ("zoom-out", z(0.42)), ("zoom-in-2", z(1.7)), ("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() };@@ -857,13 +937,13 @@ fn plan_for(key: &str, sample: &Sample, parts: &[(String, String, f64, f64)], bo             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())]+            vec![("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())]+            vec![("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));@@ -883,9 +963,9 @@ fn idle_step(kind: &str, tick: u64, boxes: &[Value], pass: &dyn Fn(&str, 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!(10);-        view(pass, &b)+    } else if kind == "2d" {+        let _ = boxes;+        view(pass, &json!({"space": "2d", "action": "zoom", "factor": if tick % 2 == 0 { 1.18 } else { 0.85 }, "steps": 10}))     } else { json!({"success": true}) } } /// Motion on a verified window only; every gesture re-checks the front and the remote.@@ -896,7 +976,9 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<     for (name, body) in plan {         if pending_interrupt() { rep["aborted"] = json!(format!("stop requested before {name}")); break; }         if !front_ok(hwnd) { rep["aborted"] = json!(format!("window not in front before {name} (user switched away)")); break; }-        let o = if body["action"] == "sweep" {+        let o = if body["action"] == "wait" {+            sleep_s(body["seconds"].as_f64().unwrap_or(2.0)); json!({"success": true})+        } else if body["action"] == "sweep" {             // the real pointer, no overlay: pads highlight under it             let mut ok = true;             for pt in body["path"].as_array().cloned().unwrap_or_default() {@@ -909,7 +991,9 @@ fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, plan: Vec<         } else if kind == "2d" { let mut b = body.clone(); b["space"] = json!("2d"); view(pass, &b) } else { view(pass, &body) };         if o["success"].as_bool() != Some(true) { rep["aborted"] = json!(format!("{name}: {}", o["error"].as_str().unwrap_or("script failed"))); break; }         rep["gestures"] = json!(rep["gestures"].as_u64().unwrap_or(0) + 1);-        sleep_s(if kind == "2d" { 0.9 } else { 1.0 });   // let the eye land before the next move+        // let the eye land before the next move; a 2D fit needs longer, the editor is still+        // laying it out and a box sent inside that window is dropped (2026-09-05)+        sleep_s(if kind == "2d" && body["action"] == "fit" { 3.0 } else if kind == "2d" { 1.2 } else { 1.0 });     }     let after = shot_window(hwnd).and_then(|p| digest(&p));     rep["viewChanged"] = json!(before.is_some() && after.is_some() && before != after);