app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 158 file(s)
13 files changed
+257−96
BRIDGE_VERSION+1−1addin/AdomBridge/commands/activity_palette.py+18addin/AdomBridge/commands/electron_view.py+75−23addin/AdomBridge/commands/electronics.py+10bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+11−27rust/Cargo.toml+1−1rust/PORTING.md+13rust/bridge/src/tour.rs+123−39BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.367+1.9.368
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]"- }-}+ ]+}
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);