app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 157 file(s)
14 files changed
+236−53
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.362+1.9.363
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.362",+ "version": "1.9.363", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
contracts/describe.json+48−1@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-09-04",+ "frozen": "2026-09-05", "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.", "source": "describe.py", "verbs": [@@ -1355,6 +1355,24 @@ "summary": "COMPOSITE, the Fusion twin of kicad_install_library_bundle (#68): ONE call takes a staged Cloud Library bundle (zip of <Lib>.lbr + <Part>.step models, or a bare lbrPath with STEPs beside it), unpacks it safely, places it under ~/Documents/Adom Libraries/<name>/, pairs EVERY deviceset with its STEP by name (deviceset, package, normalised, prefix; a one-part library pairs its single model regardless), lints part numbers (partMetadata: MPN, manufacturer, Mouser/DigiKey/LCSC - reported, never blocking), pre-flights Fusion state (fusion{status}), and starts the same bind -> prove chain fusion_create_library runs. ASYNC BY CONTRACT: returns IMMEDIATELY with runId + statusVerb because a STEP bind is 3-15 minutes and cannot fit a relay window; poll fusion_prove_result (measured percent, four streamed proof frames per part, provedParts[], and an addin_restart_required payload the CALLER turns into the ask). PARTIAL SHIPS: a deviceset with no model is a NAMED skip in unbound[], the rest still bind; partsBound < partsTotal is the signal. Each part is minted on its own single-deviceset library (the Package3D generator loads the FIRST package of the open library, so a merged library would put every chip on part one's footprint), with the STEP placed exactly as published (orient:false, origin trusted), then all urns are bound into the merged library, and after prove the bound library is SAVED TO THE CLOUD under <project>/Adom Libraries so it appears in Fusion's Library Manager (cloudLibrary{saved, fileId, duplicatesBefore} in fusion_prove_result). Per-part scaffolding is deleted; the user is left with one library plus its per-package f3d folder. capture:false only places the files and returns. This is the one contract the wiki CLI (`library install --format fusion`) and the Export button both read; the recipe has one owner.", "timeoutSeconds": 60 },+ {+ "example": {},+ "input": {+ "probeMainThread": "optional bool (default true)"+ },+ "longRunning": false,+ "name": "fusion_interactive",+ "output": {+ "activeJobs": "bool|null",+ "addinBusy": "bool",+ "addinUp": "bool",+ "interactive": "bool",+ "mainThreadMs": "int|null"+ },+ "statusVerb": null,+ "summary": "Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on a trivial script (probeMainThread:false skips that and costs nothing). interactive:true means drive it; false means a load, a cloud open, or a bind is still running. The guided tour's narrator polls this every 1.5s and stamps fusion-busy / fusion-interactive transitions into the tour record, so a recording can be cut or sped up on them.",+ "timeoutSeconds": 20+ }, { "example": {}, "input": {@@ -2483,6 +2501,35 @@ "summary": "This machine MEASURED timings per verb/stage - the same store that paces every progress bar and ETA. Read it to see whether an estimate is measured (this box has run it) or typical (still the seeded literal), and to explain a slow run.", "timeoutSeconds": 15 },+ {+ "example": {},+ "input": {+ "action": "required str",+ "degrees": "orbit: float (default 40)",+ "dx": "pan: float",+ "dy": "pan: float",+ "factor": "zoom: float (>1 in)",+ "fit": "orientations: optional bool fit after (default true)",+ "smooth": "optional bool (default true)",+ "space": "optional '3d' (default) | '2d'",+ "steps": "2D: frames",+ "style": "style: str",+ "x1": "2D pan",+ "x2": "2D pan",+ "y1": "2D pan",+ "y2": "2D pan"+ },+ "longRunning": true,+ "name": "fusion_view",+ "output": {+ "action": "str",+ "data": "{result}",+ "space": "str"+ },+ "statusVerb": null,+ "summary": "FIRST-CLASS camera motion. 3D (the active Design viewport, through Fusion's own camera): fit | home | iso | top | front | right | back | left | bottom | orbit {degrees} | zoom {factor, >1 zooms in} | pan {dx,dy} | style {shaded|shaded-edges|wireframe|hidden-edges|wireframe-hidden}; smooth transitions by default. 2D (space:'2d', the active schematic, board or library editor, through Fusion's WINDOW commands): fit | zoom {factor, steps} | pan {x1,y1,x2,y2 in board mm}. The guided tour's motion runs through this verb. Verify by capture (desktop_screenshot_window before and after).",+ "timeoutSeconds": 60+ }, { "example": {}, "input": {},
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.362"+version = "1.9.363" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.362"+version = "1.9.363" edition = "2021" [dependencies]
describe.py+5@@ -132,6 +132,11 @@ _T = [ ("fusion_open_by_urn_note", "(see fusion_open_by_urn)", {}, {}, 30, None, False, {}), ("fusion_electronics_query", "TYPED electronics read model: rows for one of Autodesk's 49 electronics entity classes (Element, Signal, Net, Layer, Via, Smd, Wire, Pad, Part, Instance, Pin, Sheet, Package, Library, Error, Variant...) over the ACTIVE electronics document, with filters, field projection and pagination. Served by Autodesk's own MCP electronics_read when its server answers (fusion_mcp_enable turns it on), else by the board_info geometry export for Board/Element/Library/Package/Layer/Signal/Wire/Error (coordinates in mm). `source` names which. Replaces reading board_info XML by hand.", {"entity": "required str (class name, with or without the electronics. prefix)", "fields": "optional [str] property names to return", "filters": "optional [{property, op: eq|lt|gt, value}] (strings: eq only)", "limit": "optional int (default 100)", "offset": "optional int", "source": "optional auto|mcp|board (default auto)"}, {"success": "bool", "source": "mcp|board", "entity": "str", "count": "int", "total": "int", "rows": "[dict]"}, 120, "fusion_get_app_state", False, {"entity": "Element", "fields": ["name", "x", "y", "angle"]}),+ ("fusion_view", "FIRST-CLASS camera motion. 3D (the active Design viewport, through Fusion's own camera): fit | home | iso | top | front | right | back | left | bottom | orbit {degrees} | zoom {factor, >1 zooms in} | pan {dx,dy} | style {shaded|shaded-edges|wireframe|hidden-edges|wireframe-hidden}; smooth transitions by default. 2D (space:'2d', the active schematic, board or library editor, through Fusion's WINDOW commands): fit | zoom {factor, steps} | pan {x1,y1,x2,y2 in board mm}. The guided tour's motion runs through this verb. Verify by capture (desktop_screenshot_window before and after).",+ {"action": "required str", "space": "optional '3d' (default) | '2d'", "degrees": "orbit: float (default 40)", "factor": "zoom: float (>1 in)", "dx": "pan: float", "dy": "pan: float", "style": "style: str", "smooth": "optional bool (default true)", "fit": "orientations: optional bool fit after (default true)", "steps": "2D: frames", "x1": "2D pan", "y1": "2D pan", "x2": "2D pan", "y2": "2D pan"},+ {"action": "str", "space": "str", "data": "{result}"}, 60, None, True, {}),+ ("fusion_interactive", "Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on a trivial script (probeMainThread:false skips that and costs nothing). interactive:true means drive it; false means a load, a cloud open, or a bind is still running. The guided tour's narrator polls this every 1.5s and stamps fusion-busy / fusion-interactive transitions into the tour record, so a recording can be cut or sped up on them.",+ {"probeMainThread": "optional bool (default true)"}, {"interactive": "bool", "addinUp": "bool", "addinBusy": "bool", "activeJobs": "bool|null", "mainThreadMs": "int|null"}, 20, None, False, {}), ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.", {}, {"rawXml": "str"}, 60, None, False, {}), ("fusion_alerts", "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",
dockbar.json+3−2@@ -23,9 +23,10 @@ }, { "label": "Starting the Fusion Dashboard",- "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; for i in $(seq 1 15); do command -v fusion-dashboard >/dev/null 2>&1 && break; sleep 2; done; exec fusion-dashboard serve --print-url'",+ "run": "sh -c 'PATH=~/.local/bin:/usr/local/bin:/usr/bin:/bin; exec fusion-dashboard serve --print-url'", "await": "url",- "timeout_seconds": 120+ "timeout_seconds": 120,+ "_why": "No $ and no $(...) in a dock step: Hydrogen's /workspace/exec expands them on the HOST before the container sees the command (measured on ConfRoomROG 2026-09-05: `for i in $(seq 1 15)` became a syntax error, exit code null, and the dock showed 'no output' for 649s). The install step before this one guarantees the binary; the serve is self-backgrounding and prints the URL within seconds." } ], "on_ready": "navigate_placeholder",
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.362"+DASH_VERSION="1.9.363" 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.362",+ "version": "1.9.363", "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.362",+ "version": "1.9.363", "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.362"+version = "1.9.363" edition = "2021" license = "MIT"
rust/PORTING.md+12@@ -528,3 +528,15 @@ port learned: when the hub has none. A KiCad schematic cannot be imported into Fusion Electronics (EAGLE only), so the KiCad tour's board was not an option. Captions dropped to 32px on one line: ab's medium is 48px since v2.0.84 and covered the remote.+- **Never lock the record inside the record lock.** The tour stamps each clip and caption with+ `since_start()`, which reads the record; the first draft evaluated it inside `rec(|s| ...)`+ (which holds the same std Mutex), so the tour thread deadlocked itself on its first caption and+ every later `TOUR.lock()` hung: `fusion_demo` timed out while every other verb answered. Std+ mutexes are not reentrant; compute anything that reads the record before entering `rec`.+- **A dock step must carry no `$`.** Hydrogen's `/workspace/exec` (the route the dock falls back+ to when the editor extension has no exec port) expands `$var` and `$(...)` on the host before+ the container runs the command, so a `for i in $(seq 1 15)` wait loop in dockbar.json died as+ a shell syntax error with exit code null and the dock showed "no output" while its timer kept+ counting (ConfRoomROG, 2026-09-05; the same failure took the laptop's Hard Refresh Open the+ day before). The serve step is now `sh -c 'PATH=~/.local/bin:...; exec fusion-dashboard serve+ --print-url'`. Filed on adom/hydrogen.
rust/bridge/src/main.rs+2−2@@ -399,10 +399,10 @@ fn main() { v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r }, v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),+ v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass), "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) }, "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),- "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) },+ "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "view" => tour::view(&pass, &parsed["args"]), "interactive" => tour::interactive(&pass, parsed["args"]["probeMainThread"].as_bool().unwrap_or(true)), "demo_progress" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::progress(&parsed["args"]) } else { demo::demo_progress(&ctx.state_dir, &parsed["args"]) }, "demo_control" => if tour::owns(parsed["args"]["demoId"].as_str().unwrap_or("")) || parsed["args"]["demoId"].as_str().map(|s| s.is_empty()).unwrap_or(true) { tour::control(&parsed["args"]) } else { demo::demo_control(&parsed["args"]) }, "demo" if parsed["args"]["mode"].as_str() == Some("tour") && parsed["args"]["legacy"].as_bool() != Some(true) => tour::start(&ctx, &parsed["args"], tour::Env { version: VERSION }), "demo" => if parsed["args"]["mode"].as_str() == Some("tour") { let (sc1, sc2) = (sidecar.clone(), sidecar.clone()); let readiness: Arc<dyn Fn() -> Value + Send + Sync> = Arc::new(move || owned_readiness(&*host(), &sc1, &Caller::default())); let aps_state: Arc<dyn Fn() -> (bool, bool) + Send + Sync> = Arc::new(move || { let st = native_aps_status(&sc2, &Caller::default()); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }); demo::demo_tour(&ctx, &parsed["args"], demo::TourEnv { readiness, aps_state, version: VERSION }, &pass) }
rust/bridge/src/tour.rs+148−14@@ -49,7 +49,17 @@ static PANEL_SHOWN: AtomicBool = AtomicBool::new(false); static AUDIO: Mutex<Option<(std::process::Child, String)>> = Mutex::new(None); static SAVED_BOUNDS: Mutex<Vec<Value>> = Mutex::new(Vec::new()); static CALLER: Mutex<Option<Caller>> = Mutex::new(None);+/// The narrator: the current beat's key and how many of its fillers have played.+static NARR: Mutex<Option<(String, usize)>> = Mutex::new(None);+/// True while a beat is still doing something on screen (action, verify, motion): the narrator+/// may start a filler. False once the beat only waits for audio, so the beat can end.+static BEAT_BUSY: AtomicBool = AtomicBool::new(false);+static PAUSED: AtomicBool = AtomicBool::new(false);+static GENERIC_USED: Mutex<Vec<String>> = Mutex::new(Vec::new()); +/// One timestamped event in the record (post-production cuts on these).+fn event(name: &str, detail: Value) { let at = since_start(); rec(|s| push(s, "events", json!({"at": at, "event": name, "beat": s["step"].clone(), "detail": detail}))); }+fn since_start() -> f64 { let st = snap()["started"].as_f64().unwrap_or(0.0); if st > 0.0 { ((now() - st) * 100.0).round() / 100.0 } else { 0.0 } } fn now() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) } fn sleep_s(s: f64) { std::thread::sleep(Duration::from_secs_f64(s)); } fn caller() -> Caller { CALLER.lock().unwrap().clone().unwrap_or_default() }@@ -74,10 +84,12 @@ fn caption(text: &str, ms: u64) { // John, 2026-09-04: ab's "medium" is 48px since v2.0.84 and the captions overlapped the glass // remote; 32px (the old medium) and one short line keep clear of it. ab's own warnings // (too_wide_for_screen, too_tall_for_screen) go into the record so a wrap never passes silently.+ let at0 = since_start(); let r = ab("desktop_caption", json!({"id": CAPTION_ID, "text": text, "duration": ms, "size": "medium", "fontSize": CAPTION_PX, "position": "bottom", "reason": "Narrate the Fusion tour the user started, one line per beat"}), 6); let ok = r["success"].as_bool() == Some(true) && r["error"].as_str().map(|e| e.is_empty()).unwrap_or(true);+ let at = at0; let warns: Vec<String> = r["warnings"].as_array().map(|a| a.iter().filter_map(|w| w["check"].as_str().map(str::to_string)).collect()).unwrap_or_default();- rec(|s| push(s, "captions", json!({"text": text.chars().take(80).collect::<String>(), "ok": ok, "chars": text.chars().count(), "warnings": warns, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())})));+ rec(|s| push(s, "captions", json!({"text": text.chars().take(80).collect::<String>(), "ok": ok, "at": at, "chars": text.chars().count(), "warnings": warns, "error": r["error"].as_str().filter(|e| !e.is_empty()).or(r["_adCallError"].as_str())}))); } fn caption_clear() { let _ = ab("desktop_caption", json!({"action": "hide", "id": CAPTION_ID, "reason": "The user stopped the Fusion tour; clear its caption"}), 6); } fn ack(text: &str) {@@ -90,15 +102,57 @@ fn audio_playing() -> bool { let mut g = AUDIO.lock().unwrap(); match g.as_mut() /// Play the beat's pre-rendered line through a hidden WPF MediaPlayer (ab has no audio verb). fn audio_play(beat: &str) { rec(|s| s["narrationBeat"] = json!(beat));- if MUTED.load(Ordering::Relaxed) { return; }- let f = audio_dir().join(format!("{beat}.mp3"));- if !f.is_file() { rec(|s| push(s, "audio", json!({"beat": beat, "missing": true}))); return; }+ *NARR.lock().unwrap() = Some((beat.to_string(), 0));+ play_file(beat, "main");+}+/// Play one clip from resources/demo-audio (kind = main | filler); records the stamp and the file.+fn play_file(name: &str, kind: &str) {+ let at = since_start();+ if MUTED.load(Ordering::Relaxed) { rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "muted": true, "at": at}))); return; }+ let f = audio_dir().join(format!("{name}.mp3"));+ if !f.is_file() { rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "missing": true, "at": at}))); return; } audio_stop();- let ps = format!("Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'{}'); $p.Volume = 0.9; $p.Play(); Start-Sleep -Milliseconds 500; $t0 = Get-Date; while (((Get-Date) - $t0).TotalSeconds -lt 40 -and (-not $p.NaturalDuration.HasTimeSpan -or $p.Position -lt $p.NaturalDuration.TimeSpan)) {{ Start-Sleep -Milliseconds 200 }}", f.to_string_lossy().replace('\\', "/"));+ let ps = format!("Add-Type -AssemblyName presentationCore; $p = New-Object System.Windows.Media.MediaPlayer; $p.Open([uri]'{}'); $p.Volume = 0.9; $p.Play(); Start-Sleep -Milliseconds 500; $t0 = Get-Date; while (((Get-Date) - $t0).TotalSeconds -lt 40 -and (-not $p.NaturalDuration.HasTimeSpan -or $p.Position -lt $p.NaturalDuration.TimeSpan)) {{ Start-Sleep -Milliseconds 100 }}; $p.Close()", f.to_string_lossy().replace('\'', "''")); let mut cmd = std::process::Command::new("powershell"); cmd.args(["-NoProfile", "-Command", &ps]).stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); #[cfg(windows)] { use std::os::windows::process::CommandExt; cmd.creation_flags(0x0800_0000); }- match cmd.spawn() { Ok(c) => { *AUDIO.lock().unwrap() = Some((c, beat.to_string())); rec(|s| push(s, "audio", json!({"beat": beat, "started": true}))); } Err(e) => rec(|s| push(s, "audio", json!({"beat": beat, "error": e.to_string()}))) }+ match cmd.spawn() { Ok(c) => { *AUDIO.lock().unwrap() = Some((c, name.to_string())); rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "started": true, "at": at, "file": f.to_string_lossy().replace('\\', "/")}))); } Err(e) => rec(|s| push(s, "audio", json!({"beat": name, "kind": kind, "error": e.to_string()}))) }+}+/// The narrator thread: whenever the current beat is still busy on screen and nothing is playing,+/// play the beat's next filler (<beat>-f<n>.mp3), then the generic pool, each once per tour. It+/// never starts a clip while paused or muted, and never once the beat only waits for audio, so a+/// beat ends at the end of a sentence. John, 2026-09-05: no dead air.+fn narrator_loop(pass: &dyn Fn(&str, Value) -> Value) {+ let mut quiet_since: Option<Instant> = None;+ let mut last_probe = Instant::now() - Duration::from_secs(10);+ let mut was_interactive: Option<bool> = None;+ loop {+ sleep_s(0.25);+ let st = snap();+ if st.is_null() || st["done"].as_bool() != Some(false) || EXIT.load(Ordering::Relaxed) { break; }+ // busy probe (cheap: the add-in's /status only; the main thread is the beat's to use)+ if last_probe.elapsed() >= Duration::from_millis(1500) {+ last_probe = Instant::now();+ let p = interactive(pass, false);+ let now_i = p["interactive"].as_bool().unwrap_or(true);+ if was_interactive != Some(now_i) { event(if now_i { "fusion-interactive" } else { "fusion-busy" }, json!({"addinBusy": p["addinBusy"], "addinUp": p["addinUp"]})); was_interactive = Some(now_i); }+ }+ if MUTED.load(Ordering::Relaxed) || PAUSED.load(Ordering::Relaxed) || !BEAT_BUSY.load(Ordering::Relaxed) { quiet_since = None; continue; }+ if audio_playing() { quiet_since = None; continue; }+ let q = *quiet_since.get_or_insert_with(Instant::now);+ if q.elapsed() < Duration::from_millis(900) { continue; } // a breath between sentences+ let Some((beat, n)) = NARR.lock().unwrap().clone() else { continue };+ let candidate = format!("{beat}-f{}", n + 1);+ if audio_dir().join(format!("{candidate}.mp3")).is_file() {+ *NARR.lock().unwrap() = Some((beat.clone(), n + 1));+ play_file(&candidate, "filler");+ } else {+ let mut used = GENERIC_USED.lock().unwrap();+ let next = (1..=2).map(|k| format!("generic-f{k}")).find(|g| !used.contains(g) && audio_dir().join(format!("{g}.mp3")).is_file());+ match next { Some(g) => { used.push(g.clone()); drop(used); play_file(&g, "filler"); } None => { BEAT_BUSY.store(false, Ordering::Relaxed); } }+ }+ quiet_since = None;+ } } fn apply_mute(m: bool, source: &str) { let was = MUTED.swap(m, Ordering::Relaxed);@@ -289,6 +343,63 @@ 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") } +/// `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.+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)})),+ _ => 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;+ }+ let body = match action.as_str() {+ "fit" => "c=cam(); c.isFitView=True; put(c, SMOOTH)+out={'action':'fit'}".to_string(),+ "home" => "vp.goHome(SMOOTH)+out={'action':'home'}".to_string(),+ "iso" | "top" | "front" | "right" | "back" | "left" | "bottom" => { let o = match action.as_str() { "iso" => "IsoTopRightViewOrientation", "top" => "TopViewOrientation", "front" => "FrontViewOrientation", "right" => "RightViewOrientation", "back" => "BackViewOrientation", "left" => "LeftViewOrientation", _ => "BottomViewOrientation" }; format!("orient(adsk.core.ViewOrientations.{o}, {})+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)),+ "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."}),+ };+ 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}));+ r["action"] = json!(action); r["space"] = json!("3d");+ if r["success"].as_bool() == Some(true) { r["_hint"] = json!("Verify by capture: desktop_screenshot_window before and after; the tour records viewChanged this way."); }+ r+}+/// `fusion_interactive`: is Fusion ready for input right now? The add-in's /status busy flag (a+/// long command of ours in flight), Fusion's own active-jobs flag, and how long the main thread+/// took to answer a trivial script. The tour's narrator polls this to stamp busy stretches.+pub fn interactive(pass: &dyn Fn(&str, Value) -> Value, probe_main_thread: bool) -> Value {+ let st = crate::addin::addin_status_probe(Duration::from_secs(3));+ let addin_up = st.is_some();+ let addin_busy = st.as_ref().and_then(|s| s["busy"].as_bool()).unwrap_or(false);+ let mut jobs: Option<bool> = None; let mut latency_ms: Option<u64> = None;+ if probe_main_thread && addin_up && !addin_busy {+ let t0 = Instant::now();+ let r = pass("run_modeling_script", json!({"script": "import adsk.core+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,+ "_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 } 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() }@@ -343,6 +454,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E // clean slate per tour EXIT.store(false, Ordering::Relaxed); PANEL_SHOWN.store(false, Ordering::Relaxed); SEEN_AT.lock().unwrap().clear(); CTRL.lock().unwrap().clear(); MUTED.store(args["muted"].as_bool().unwrap_or(false), Ordering::Relaxed);+ BEAT_BUSY.store(false, Ordering::Relaxed); PAUSED.store(false, Ordering::Relaxed); *NARR.lock().unwrap() = None; GENERIC_USED.lock().unwrap().clear(); drain_stale_presses(); restore_bounds(&sd, "leftovers"); let _ = crate::etiquette::clear_badge(&caller());@@ -369,9 +481,9 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E // transport between beats: pause holds, exit leaves, prev is TWO back (i is past the beat on screen) let mut cmd = take_cmd(); if cmd.as_deref() == Some("pause") {- audio_stop(); panel_show(i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); });+ audio_stop(); PAUSED.store(true, Ordering::Relaxed); event("paused", json!({})); panel_show(i.max(1), Some("paused")); caption("Fusion tour paused", 300000); rec(|s| { s["paused"] = json!(true); s["stepLabel"] = json!("Paused"); }); loop { sleep_s(1.0); cmd = take_cmd(); if matches!(cmd.as_deref(), Some("play") | Some("next") | Some("prev") | Some("exit")) { break; } }- rec(|s| s["paused"] = json!(false));+ PAUSED.store(false, Ordering::Relaxed); event("resumed", json!({})); rec(|s| s["paused"] = json!(false)); } if cmd.as_deref() == Some("exit") || EXIT.load(Ordering::Relaxed) { exited = true; break; } if cmd.as_deref() == Some("prev") { i = i.saturating_sub(2); results.truncate(i); }@@ -379,6 +491,8 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E rec(|s| { s["step"] = json!(key); s["stepLabel"] = json!(label); s["beatIndex"] = json!(i + 1); s["beatCount"] = json!(n); }); update_pct(done_est, 0); panel_show(i, None);+ BEAT_BUSY.store(true, Ordering::Relaxed);+ event("beat-start", json!({"index": i + 1, "key": key})); let beat_t0 = Instant::now(); let mut r = json!({"step": key, "label": label, "ok": false}); let mut expected = String::new();@@ -515,6 +629,7 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E results.push(r.clone()); rec(|s| { push(s, "steps", r.clone()); s["error"] = json!(why); }); failed_early = true; break; }+ event("action-done", json!({"ok": r["ok"], "error": r["error"]})); // ---- measure, then move ---- if r["ok"].as_bool() == Some(true) && !EXIT.load(Ordering::Relaxed) && key != "aps" { if fusion.is_none() { fusion = fusion_hwnd(); }@@ -524,8 +639,11 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E 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" {+ event("motion-start", json!({"kind": kind})); r["motion"] = motion(*h, kind, pass, &script);+ 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"])}); }@@ -538,16 +656,21 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E rec(|s| push(s, "steps", r.clone())); done_est += dur; update_pct(done_est, 0);- // advance at max(action done, audio done), bounded, honouring the remote+ // advance at max(action done, audio done): the narrator stops starting fillers now, and the+ // beat ends when the sentence on air ends (bounded), honouring the remote+ BEAT_BUSY.store(false, Ordering::Relaxed); let wait_t0 = Instant::now(); while audio_playing() && wait_t0.elapsed() < Duration::from_secs(40) { if EXIT.load(Ordering::Relaxed) { break; } if let Some(c) = take_cmd() { CTRL.lock().unwrap().push_front(c); break; } sleep_s(0.4); }+ 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; }+ BEAT_BUSY.store(false, Ordering::Relaxed);+ event("tour-end", json!({"exited": exited})); // ---- 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();@@ -566,18 +689,18 @@ fn run_show(ctx: &Ctx, pass: &dyn Fn(&str, Value) -> Value, args: Value, env: &E /// 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 {+fn motion(hwnd: i64, kind: &str, pass: &dyn Fn(&str, Value) -> Value, _script: &dyn Fn(&str) -> Value) -> Value { let mut rep = json!({"kind": kind, "gestures": 0, "aborted": null}); let plan: Vec<(&str, Value)> = if kind == "2d" {- vec![("fit", json!({"fit": true})), ("zoom-in", json!({"factor": 1.8, "steps": 14})), ("zoom-in-2", json!({"factor": 1.4, "steps": 10})), ("zoom-out", json!({"factor": 0.55, "steps": 14})), ("fit-2", json!({"fit": true}))]+ 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!("orient(adsk.core.ViewOrientations.IsoTopRightViewOrientation, True)")), ("orbit-1", json!("orbit(40)")), ("orbit-2", json!("orbit(40)")), ("zoom-in", json!("zoom(0.75)")), ("orbit-3", json!("orbit(50)")), ("zoom-out", json!("zoom(1.35)")), ("orbit-back", json!("orbit(-65)")), ("orbit-back-2", json!("orbit(-65)"))]+ 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}))] }; let before = shot_window(hwnd).and_then(|p| digest(&p)); for (name, body) in plan { if pending_interrupt() { rep["aborted"] = json!(format!("stop requested before {name}")); break; } if !front_ok(hwnd) { rep["aborted"] = json!(format!("window not in front before {name} (user switched away)")); break; }- let o = if kind == "2d" { pass("electron_zoom", body) } else { script(body.as_str().unwrap_or("")) };+ let o = if kind == "2d" { view(pass, &json!({"space": "2d", "action": body["action"], "factor": body["factor"], "steps": body["steps"]})) } 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);@@ -595,7 +718,7 @@ pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value { let id = format!("show-{:08x}", u32::from_le_bytes(idb)); *CALLER.lock().unwrap() = Some(ctx.caller.clone()); let est: u64 = BEATS.iter().map(|b| b.2).sum();- *TOUR.lock().unwrap() = Some(json!({"demoId": id, "done": false, "failed": false, "step": "starting", "stepLabel": "Starting the tour", "beatIndex": 0, "beatCount": BEATS.len(), "percent": 0, "elapsedSec": 0, "estimatedSec": est, "steps": [], "shots": [], "presses": [], "captions": [], "audio": [], "muted": args["muted"].as_bool().unwrap_or(false), "paused": false, "stopping": false, "panel": {"shown": false}, "started": now()}));+ *TOUR.lock().unwrap() = Some(json!({"demoId": id, "done": false, "failed": false, "step": "starting", "stepLabel": "Starting the tour", "beatIndex": 0, "beatCount": BEATS.len(), "percent": 0, "elapsedSec": 0, "estimatedSec": est, "steps": [], "shots": [], "presses": [], "captions": [], "audio": [], "events": [], "muted": args["muted"].as_bool().unwrap_or(false), "paused": false, "stopping": false, "panel": {"shown": false}, "started": now()})); let (sd, caller, args2, my_id) = (ctx.state_dir.clone(), ctx.caller.clone(), args.clone(), id.clone()); std::thread::spawn(move || { let host = crate::host_for_diagnosis();@@ -604,7 +727,18 @@ pub fn start(ctx: &Ctx, args: &Value, env: Env) -> Value { let dlg = |r: Value| r; let ctx2 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids, sidecar: &side, dialogs: &dlg }; let pass2 = |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx2);+ let (sd_n, caller_n) = (sd.clone(), caller.clone());+ let narrator = std::thread::spawn(move || {+ let host = crate::host_for_diagnosis();+ let side = |_v: &str, _a: Value, _t: u64| -> Value { json!({"success": false, "error": "no sidecar"}) };+ let pids: Vec<u32> = host.fusion_processes().iter().map(|p| p.pid).collect();+ let dlg = |r: Value| r;+ let ctx3 = Ctx { host: &*host, fusion_exe: crate::host_for_diagnosis_paths(), short_path: |p| crate::host().short_path(p), state_dir: sd_n, caller: caller_n, pids, sidecar: &side, dialogs: &dlg };+ let pass3 = move |verb: &str, a: Value| crate::addin::pass_through(verb, &a, &ctx3);+ narrator_loop(&pass3);+ }); let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_show(&ctx2, &pass2, args2, &env)));+ let _ = narrator.join(); // only THIS tour's record: a later tour may own the live record by now let live = snap(); if live["demoId"].as_str() == Some(my_id.as_str()) && live["done"].as_bool() != Some(true) { rec(|s| { s["done"] = json!(true); s["failed"] = json!(true); s["error"] = json!("the tour thread ended unexpectedly"); }); panel_hide(); }