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 16 file(s)
16 files changed
+269−17
BRIDGE_VERSION+1−1CHANGELOG.md+9−1bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+2−2rust/Cargo.lock+5−5rust/Cargo.toml+1−1rust/bridge/src/lbr_use.py+1−1rust/bridge/src/main.rs+3rust/bridge/src/orchestrations.rs+99rust/bridge/src/small_verbs.rs+85rust/bridge/src/stdshapes.rs+6−1tests/open_refusal_drill.py+52BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.65+1.13.66
CHANGELOG.md+9−1@@ -3,6 +3,14 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.66 (2026-11-03)++`fusion_open_board` no longer reports success on a board Fusion refuses. A file with a part whose library or package isn't in it, or a net naming a part that isn't, is refused before Fusion sees it (`board_file_has_errors`, naming each problem), so no dialog is drawn. If Fusion refuses a board or schematic for another reason, the bridge closes its dialog and the empty design and answers `file_refused_by_fusion`.++The Select Library picker no longer blocks Fusion: when it comes up during `fusion_schematic_run`, `fusion_place_parts`, `fusion_sch_place_part` or `fusion_sch_swap_part`, the bridge closes it and answers `library_name_in_use`, naming both files (`librariesInUse`). Answering it wouldn't help: the part still doesn't land. To clear a clash, rename this file, or take the other one away and restart Fusion.++`fusion_board_summary` with no document named reads the board in front.+ ## 1.13.65 (2026-11-03) From AI Flow's run on a board an older bridge converted:@@ -13,7 +21,7 @@ The notice "The board file has errors and will be opened with warnings" has its `fusion_dismiss_blocking_dialogs`: `closed` now means the window is gone from the screen, and the verb reports `success: false` and `stillUp` if one isn't. It used to report a window closed when the close was only accepted, and that notice ignores WM_CLOSE: it now clicks OK. -Dialog resolutions and hints no longer name the retired `fusion_send_key`.+Dialog resolutions and the exe's hints no longer name the retired `fusion_send_key`. The add-in's own text still does, until its next release. ## 1.13.64 (2026-11-03)
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.65",+ "version": "1.13.66", "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.13.65"+version = "1.13.66" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.65"+version = "1.13.66" 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.13.65"+DASH_VERSION="1.13.66" 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.13.65",+ "version": "1.13.66", "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+2−2@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.65",+ "version": "1.13.66", "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": {@@ -152,4 +152,4 @@ "confirmed_user_skills": [ "fusion-design-efficiency" ]-}+}
rust/Cargo.lock+5−5@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.65"+version = "1.13.66" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.65"+version = "1.13.66" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.65"+version = "1.13.66" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.65"+version = "1.13.66" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.65"+version = "1.13.66" dependencies = [ "fusion-core", "windows-sys 0.59.0",
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.13.65"+version = "1.13.66" edition = "2021" license = "MIT"
rust/bridge/src/lbr_use.py+1−1@@ -102,7 +102,7 @@ def use_library(app, path, removed=None): used = _sys.__dict__.setdefault('_adom_lbr_used', {}) full, name = _P(path).as_posix(), _P(path).stem.lower() if used.get(name, full).lower() != full.lower():- raise RuntimeError('library_name_in_use: a library named %s is already in use from %s, and loading %s too would make Fusion ask which one to take (a dialog that blocks it). Use that path or give this file another name: restarting Fusion does not clear it, as its in-use list survives a restart' % (_P(path).stem, used[name], full))+ raise RuntimeError('library_name_in_use: a library named %s is already in use from %s, and loading %s too would make Fusion ask which one to take (a dialog that blocks it). Use that path or give this file another name. To clear it, take the other file away and restart Fusion: a restart alone does not, as its in-use list survives one' % (_P(path).stem, used[name], full)) try: changed = normalize_lbr(path, removed) except PermissionError:
rust/bridge/src/main.rs+3@@ -531,7 +531,10 @@ fn main() { 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 cmd=v.strip_prefix("fusion_").unwrap_or(v);+ let adds_from_library = matches!(cmd, "schematic_run" | "place_parts" | "sch_place_part" | "sch_swap_part") && parsed["args"]["validateOnly"].is_null() && parsed["args"]["runId"].is_null();+ let picker = if adds_from_library { small_verbs::watch_for_library_picker(&ctx.caller, &ctx.pids) } else { None }; let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "replicate_layout" => replicate::replicate_layout(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "silkscreen_prep" => checks::silkscreen_prep(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "list_installed_libraries" => appverbs::list_installed_libraries(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "update_footprints_from_library" => boardgen::update_footprints_from_library(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "simulate" => spice::simulate(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "jlcpcb_order" => order::jlcpcb_order(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if writers::owns(c) => writers::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "schematic_run" => routing::schematic_run(&ctx.state_dir,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ r=small_verbs::finish_picker_watch(picker, r); if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); } r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r },
rust/bridge/src/orchestrations.rs+99@@ -649,8 +649,77 @@ result = {{"wanted": want, "tabType": {want_pt:?}, "switchedTab": hit is not Non } /// `_handle_open_with_screenshot`: the add-in open or switch, then the post-open capture.+/// What Fusion would refuse in a .brd, checked before it sees the file. Each of these makes it draw its+/// "has errors and cannot be opened" modal, leaving an empty design behind (each tried live on 2706):+/// a part whose library or package isn't in the file, and a net naming a part that isn't.+pub fn board_file_problems(text: &str) -> Vec<String> {+ let Ok(doc) = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { return vec!["the file is not well-formed XML".into()] };+ let Some(board) = doc.descendants().find(|n| n.has_tag_name("board")) else { return vec![] };+ let low = |s: Option<&str>| s.unwrap_or("").to_lowercase();+ let mut packages = std::collections::HashSet::new();+ let mut libraries = std::collections::HashSet::new();+ for lib in board.descendants().filter(|n| n.has_tag_name("library")) {+ libraries.insert(low(lib.attribute("name")));+ for p in lib.descendants().filter(|n| n.has_tag_name("package")) { packages.insert((low(lib.attribute("name")), low(p.attribute("name")))); }+ }+ let mut out = Vec::new();+ let mut elements = std::collections::HashSet::new();+ for e in board.descendants().filter(|n| n.has_tag_name("element")) {+ elements.insert(low(e.attribute("name")));+ let (name, lib, pkg) = (e.attribute("name").unwrap_or("?"), e.attribute("library").unwrap_or(""), e.attribute("package").unwrap_or(""));+ if !libraries.contains(&lib.to_lowercase()) { out.push(format!("part {name} names library {lib}, which isn't in the file")); }+ else if !packages.contains(&(lib.to_lowercase(), pkg.to_lowercase())) { out.push(format!("part {name} names package {pkg}, which isn't in its library {lib}")); }+ }+ for c in board.descendants().filter(|n| n.has_tag_name("contactref")) {+ if !elements.contains(&low(c.attribute("element"))) {+ let net = c.parent().and_then(|p| p.attribute("name")).unwrap_or("?");+ out.push(format!("net {net} names part {}, which isn't on the board", c.attribute("element").unwrap_or("?")));+ }+ }+ out+}+ pub fn open_with_screenshot(ctx: &Ctx, cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let file = args["filePath"].as_str().unwrap_or("").to_string();+ if cmd == "open_board" {+ if let Ok(text) = std::fs::read_to_string(&file) {+ let problems = board_file_problems(&text);+ if !problems.is_empty() {+ return json!({"success": false, "errorCode": "board_file_has_errors", "filePath": file, "problems": problems.iter().take(10).collect::<Vec<_>>(), "problemCount": problems.len(), "mutated": false,+ "error": format!("Fusion can't open this board: {}{}. Nothing was opened.", problems[0], if problems.len() > 1 { format!(", and {} more", problems.len() - 1) } else { String::new() }),+ "_hint": "Fix the file, or build it again with fusion_board_seed. No dialog was drawn."});+ }+ }+ }+ // the backstop, for a refusal the check above doesn't know: Fusion's modal stops the open until it's+ // answered, so it's watched for from here, closed, and said out loud. The open itself then reports+ // success with an empty design behind it.+ let (stop, refused) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), std::sync::Arc::new(std::sync::Mutex::new(None::<String>)));+ if matches!(cmd, "open_board" | "open_schematic") {+ if let Some(ab) = fusion_core::ab::Loopback::from_env() {+ let (stop2, refused2, caller, pids) = (stop.clone(), refused.clone(), ctx.caller.clone(), ctx.pids.clone());+ std::thread::spawn(move || {+ use std::sync::atomic::Ordering;+ while !stop2.load(Ordering::SeqCst) {+ std::thread::sleep(Duration::from_secs(2));+ if let Some((h, text)) = crate::small_verbs::file_refused_dialog(&ab, &caller, &pids) {+ let (gone, _) = crate::small_verbs::close_native_dialog(&ab, &caller, h);+ *refused2.lock().unwrap() = Some(if gone { text } else { format!("{text} (the dialog did not close: Fusion is still blocked)") });+ break;+ }+ }+ });+ }+ } let mut r = show_with_document(cmd, args, pass);+ stop.store(true, std::sync::atomic::Ordering::SeqCst);+ if let Some(text) = refused.lock().unwrap().clone() {+ let stem = std::path::Path::new(&file.replace('\\', "/")).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let closed = pass("close_document", json!({"name": stem}));+ return json!({"success": false, "errorCode": "file_refused_by_fusion", "filePath": file, "mutated": false, "emptyDesignClosed": closed["success"].as_bool() == Some(true),+ "error": format!("Fusion refused the file: {text}"),+ "_hint": "Fusion drew its error dialog and the bridge closed it. Fix the file; for a .brd, fusion_board_seed builds one Fusion can open."});+ } if r["success"].as_bool() == Some(true) { let (shots, _) = crate::lifecycle::post_open_screenshot(ctx, 2.0); if !r["data"].is_object() { r["data"] = json!({}); }@@ -1695,3 +1764,33 @@ mod eagle_wait_tests { for w in ["Design", "FusionSolidEnvironment", "unknown", ""] { assert!(!super::electronics_editor(w), "{w}"); } } }++#[cfg(test)] mod board_file_tests {+ use super::board_file_problems;+ const GOOD: &str = r#"<eagle><drawing><board><libraries><library name="L"><packages><package name="R0603"/></packages></library></libraries><elements><element name="R1" library="L" package="r0603"/></elements><signals><signal name="N"><contactref element="R1" pad="1"/></signal></signals></board></drawing></eagle>"#;+ #[test] fn a_good_board_has_no_problems() { assert!(board_file_problems(GOOD).is_empty()); }+ #[test] fn each_thing_fusion_refuses_is_named() {+ assert!(board_file_problems(&GOOD.replace(r#"<package name="R0603"/>"#, ""))[0].contains("package r0603"));+ assert!(board_file_problems(&GOOD.replace(r#"library="L""#, r#"library="NOPE""#))[0].contains("library NOPE"));+ assert!(board_file_problems(&GOOD.replace(r#"element="R1""#, r#"element="R9""#))[0].contains("net N names part R9"));+ assert!(board_file_problems("<eagle>")[0].contains("well-formed"));+ }+ #[test] fn real_boards_in_this_repo_pass() {+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../demo/routing");+ let mut seen = 0;+ fn walk(d: &std::path::Path, seen: &mut i32) {+ for e in std::fs::read_dir(d).into_iter().flatten().flatten() {+ let p = e.path();+ if p.is_dir() { walk(&p, seen); }+ else if p.extension().map(|x| x == "brd").unwrap_or(false) {+ let t = std::fs::read_to_string(&p).unwrap();+ let bad = board_file_problems(&t);+ assert!(bad.is_empty(), "{}: {:?}", p.display(), &bad[..bad.len().min(3)]);+ *seen += 1;+ }+ }+ }+ walk(&root, &mut seen);+ assert!(seen > 3, "no boards found");+ }+}
rust/bridge/src/small_verbs.rs+85@@ -439,6 +439,71 @@ pub fn classify_blocking_dialogs(ab: &dyn fusion_core::ab::AbClient, caller: &fu out } +/// While a verb that ADDs from a library runs: Fusion's Select Library picker, which it opens when two+/// libraries in use share a name. Its in-use list survives a restart, so the bridge can't always see this+/// coming, and answering it doesn't help: picking a row and OK closes it, but that ADD lands nothing and the+/// next one asks again (live 2026-11-03). So it is closed, and the verb says why, naming both files.+pub struct PickerWatch { stop: std::sync::Arc<std::sync::atomic::AtomicBool>, found: std::sync::Arc<std::sync::Mutex<Option<Value>>> }++pub fn watch_for_library_picker(caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Option<PickerWatch> {+ let ab = fusion_core::ab::Loopback::from_env()?;+ let (stop, found) = (std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), std::sync::Arc::new(std::sync::Mutex::new(None)));+ let (stop2, found2, caller, pids) = (stop.clone(), found.clone(), caller.clone(), fusion_pids.to_vec());+ std::thread::spawn(move || {+ use fusion_core::ab::AbClient;+ while !stop2.load(std::sync::atomic::Ordering::SeqCst) {+ std::thread::sleep(std::time::Duration::from_secs(2));+ let mut args = json!({});+ if !pids.is_empty() { args["pids"] = json!(pids); }+ let Ok(rows) = ab.call("desktop_enum_windows", args, &caller, 10) else { continue };+ let rows = rows["windows"].as_array().or_else(|| rows["data"]["windows"].as_array()).cloned().unwrap_or_default();+ let Some(dlg) = fusion_core::dialogs::qt_dialog_rows(&rows).into_iter().find(|d| d["title"].as_str().map(|t| t.contains("Select Library")).unwrap_or(false)) else { continue };+ let Some(h) = dlg["hwnd"].as_i64() else { continue };+ let body = controls_cached(&ab, &caller, h).iter().filter_map(|c| c["name"].as_str().map(str::to_string)).collect::<Vec<_>>().join(" ");+ let (gone, how) = close_native_dialog(&ab, &caller, h);+ *found2.lock().unwrap() = Some(json!({"paths": library_picker_paths(&body), "closed": gone, "how": how}));+ break;+ }+ });+ Some(PickerWatch { stop, found })+}++/// The files the picker lists: each row reads "name (path)".+pub fn library_picker_paths(body: &str) -> Vec<String> {+ let mut out: Vec<String> = regex::Regex::new(r"\(([^()]+\.lbr)\)").unwrap().captures_iter(body).map(|c| c[1].to_string()).collect();+ out.dedup();+ out+}++/// A verb's reply when the picker came up during it: what ran stays reported, the reason replaces Fusion's assertion.+pub fn finish_picker_watch(watch: Option<PickerWatch>, mut r: Value) -> Value {+ let Some(w) = watch else { return r };+ w.stop.store(true, std::sync::atomic::Ordering::SeqCst);+ let Some(found) = w.found.lock().unwrap().clone() else { return r };+ let paths: Vec<String> = found["paths"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default();+ r["success"] = json!(false);+ r["errorCode"] = json!("library_name_in_use");+ r["librariesInUse"] = json!(paths);+ r["pickerClosed"] = found["closed"].clone();+ r["error"] = json!(format!("Fusion has two libraries with one name in use ({}) and opened its Select Library picker. The bridge closed it, and that part didn't land.{}", paths.join(" and "), if found["closed"] == true { "" } else { " The picker did not close, so Fusion is still blocked." }));+ r["_hint"] = json!("Give this file another name, or take the other file away and restart Fusion (fusion_restart_for_addin). A restart alone doesn't clear it: Fusion keeps its in-use list until the file is gone.");+ r+}++/// Fusion's "<file> has errors and cannot be opened" modal, by its text: (hwnd, what it says).+pub fn file_refused_dialog(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, fusion_pids: &[u32]) -> Option<(i64, String)> {+ let mut args = json!({});+ if !fusion_pids.is_empty() { args["pids"] = json!(fusion_pids); }+ let rows = ab.call("desktop_enum_windows", args, caller, 10).ok()?;+ let rows = rows["windows"].as_array().or_else(|| rows["data"]["windows"].as_array()).cloned().unwrap_or_default();+ for dlg in fusion_core::dialogs::qt_dialog_rows(&rows) {+ let Some(h) = dlg["hwnd"].as_i64() else { continue };+ let body: String = controls_cached(ab, caller, h).iter().filter_map(|c| c["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Vec<_>>().join(" ");+ if body.contains("has errors and cannot be opened") { return Some((h, body.chars().take(300).collect())); }+ }+ None+}+ /// Is this window still on screen (a visible top-level window with that hwnd)? fn window_present(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, hwnd: i64) -> bool { ab.call("desktop_list_windows", json!({}), caller, 10).ok()@@ -685,3 +750,23 @@ mod image_delivery_tests { let before = r.clone(); add_image_delivery(&mut r); assert_eq!(r, before); } }++#[cfg(test)] mod picker_tests {+ use super::*;+ fn watch(found: Option<Value>) -> Option<PickerWatch> { Some(PickerWatch { stop: Default::default(), found: std::sync::Arc::new(std::sync::Mutex::new(found)) }) }+ #[test] fn the_picker_rows_give_both_paths() {+ let body = "System There are multiple libraries named lane!\nPick one. lane (C:/a/lane.lbr) lane (C:/b/lane.lbr)";+ assert_eq!(library_picker_paths(body), vec!["C:/a/lane.lbr".to_string(), "C:/b/lane.lbr".to_string()]);+ }+ #[test] fn a_picker_turns_the_reply_into_the_reason() {+ let r = finish_picker_watch(watch(Some(json!({"paths": ["C:/a/l.lbr", "C:/b/l.lbr"], "closed": true}))), json!({"success": false, "errorCode": "fusion_assert", "commands": 3}));+ assert_eq!(r["errorCode"], "library_name_in_use");+ assert_eq!(r["commands"], 3);+ assert_eq!(r["pickerClosed"], true);+ assert!(r["error"].as_str().unwrap().contains("C:/a/l.lbr and C:/b/l.lbr"));+ }+ #[test] fn no_picker_leaves_the_reply_alone() {+ assert_eq!(finish_picker_watch(watch(None), json!({"success": true}))["success"], true);+ assert_eq!(finish_picker_watch(None, json!({"success": true}))["success"], true);+ }+}
rust/bridge/src/stdshapes.rs+6−1@@ -35,7 +35,12 @@ pub fn list_library(args: &Value) -> Value { /// `fusion_board_summary`: the standard's one-screen summary, shaped from one read_board round trip. pub fn board_summary(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let t0 = std::time::Instant::now();- let rb = crate::routing::call("read_board", args, pass);+ // a read names no document: the board in front is the one asked about+ let mut read_args = args.clone();+ if read_args["expectDocument"].is_null() && read_args.is_object() {+ if let Some(n) = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str() { read_args["expectDocument"] = json!(n); }+ }+ let rb = crate::routing::call("read_board", &read_args, pass); if rb["success"].as_bool() != Some(true) { return rb; } if args["raw"].as_bool() == Some(true) { return rb; } let d = &rb["data"];
tests/open_refusal_drill.pyadded+52@@ -0,0 +1,52 @@+#!/usr/bin/env python3+"""Live drill of what Fusion refuses, on test files of ours in --dir: a board with errors never reaches Fusion, one it refuses+anyway is reported, and a library name clash answers with both files rather than blocking Fusion.+ python3 tests/open_refusal_drill.py --target DESKTOP-BAR6S0F --dir <test dir> --lib <.lbr with RES> --clash <a .lbr> --other <its copy under the same name>+"""+import argparse, json, subprocess, sys, threading, time+ap = argparse.ArgumentParser()+for k in ('target', 'dir', 'lib', 'clash', 'other'): ap.add_argument('--' + k, required=True)+a = ap.parse_args(); A = ['adom-bridge', '--target', a.target, '--ai-thread', 'fusion-lane']; D = a.dir+def call(v, args=None):+ o = subprocess.run(A + [v, json.dumps(args or {})], capture_output=True, text=True).stdout+ try: return json.loads(o)+ except Exception: return {'error': o[:300], 'success': False}+results = []+def check(name, ok, detail=''):+ results.append(bool(ok)); print('%-52s %s %s' % (name, 'PASS' if ok else 'FAIL', '' if ok else str(detail)[:260]), flush=True)+def docs():+ d = call('fusion_get_app_state'); d = d.get('data', d); return [x.get('name') for x in d.get('documents') or []]+def script(s): return (call('fusion_run_modeling_script', {'script': s}).get('data') or {}).get('result')+def no_dialogs(tag): n = call('fusion_check_dialogs'); check('no dialog ' + tag, not n.get('count'), [x.get('title') for x in n.get('dialogs') or []])+r = call('fusion_board_seed', {'libraryPath': a.lib, 'outDir': D, 'name': 'ORef', 'title': 'o', 'outlineMm': [40, 30], 'fixed': [{'ref': 'R1', 'deviceset': 'RES', 'value': '1k', 'x': 12, 'y': 20, 'nets': {'A': 'MID', 'B': 'NB'}}]}); check('seed', r.get('success'), r.get('error'))+print(script("""+import xml.etree.ElementTree as ET+for name, edit in (('OB1', lambda b: b.find('libraries/library/packages').remove(b.find('libraries/library/packages')[0])), ('OB2', lambda b: b.find('elements/element').set('library', 'NoSuchLibrary')), ('OB3', lambda b: ET.SubElement(b.find('signals/signal'), 'contactref', {'element': 'NOPE', 'pad': '1'}))):+ t = ET.parse(r'%s/ORef.brd'); edit(t.getroot().find('drawing/board')); t.write(r'%s/%%s.brd' %% name, encoding='utf-8', xml_declaration=True)+t = ET.parse(r'%s/ORef.sch'); s = t.getroot().find('drawing/schematic'); s.find('parts/part').set('library', 'NoSuchLibrary'); t.write(r'%s/OS1.sch', encoding='utf-8', xml_declaration=True)+result = 'written'+""" % (D, D, D, D)))+before = docs()+for n in ('OB1', 'OB2', 'OB3'):+ r = call('fusion_open_board', {'filePath': '%s/%s.brd' % (D, n)})+ check(n + ' refused before Fusion sees it', r.get('errorCode') == 'board_file_has_errors' and r.get('problems'), {k: r.get(k) for k in ('success', 'errorCode', 'error')}); print(' ', (r.get('error') or '')[:120])+check('nothing opened', docs() == before, docs()); no_dialogs('after the refusals')+r = call('fusion_open_board', {'filePath': D + '/ORef.brd'}); check('the good board still opens', r.get('success'), r.get('error')); time.sleep(3)+s = call('fusion_board_summary', {}); check('board_summary with no document named', s.get('success'), s.get('error'))+for n in docs()[len(before):]: call('fusion_close_document', {'name': n})+r = call('fusion_open_schematic', {'filePath': D + '/OS1.sch'}); print(' a schematic Fusion refuses:', r.get('success'), r.get('errorCode'), (r.get('error') or '')[:150])+check('it is reported, or Fusion took it and said nothing', r.get('errorCode') == 'file_refused_by_fusion' or r.get('success'), r.get('errorCode')); no_dialogs('after the schematic')+if r.get('errorCode') == 'file_refused_by_fusion': check('the empty design is closed', r.get('emptyDesignClosed'), docs())+for n in [x for x in docs()[len(before):]]: call('fusion_close_document', {'name': n})+print('--- a library name clash')+r = call('fusion_project_create', {'name': 'OClash', 'parentDir': D, 'overwrite': True}); check('new design', r.get('success'), r.get('error')); call('fusion_show_schematic', {})+call('fusion_execute_text_command', {'command': "Electron.run USE '%s';" % a.other})+t0 = time.time(); r = call('fusion_sch_place_part', {'library': a.clash, 'symbol': '3X1_JUMP', 'designator': 'A1', 'x': 3000, 'y': 3000})+print(' answered in %.0f s: %s %s' % (time.time() - t0, r.get('errorCode'), (r.get('error') or '')[:200]))+check('answers library_name_in_use with both files', r.get('errorCode') == 'library_name_in_use' and len(r.get('librariesInUse') or []) == 2, {k: r.get(k) for k in ('success', 'errorCode', 'librariesInUse', 'error')})+check('the picker closed', r.get('pickerClosed') is True, r.get('pickerClosed')); time.sleep(3); no_dialogs('after the clash')+g = call('fusion_get_app_state'); check('Fusion still answers', g.get('success') is not False, g.get('error'))+r = call('fusion_sch_place_part', {'library': a.lib, 'symbol': 'RES', 'designator': 'R9', 'x': 3000, 'y': 4000}); check('another library still places', r.get('success'), r.get('error'))+for n in docs():+ if n != 'Untitled' or docs().count('Untitled') > 1: call('fusion_close_document', {'name': n})+print('%d PASS, %d FAIL' % (sum(results), len(results) - sum(results))); sys.exit(0 if all(results) else 1)