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
Save electronics designs in place: fusion_save_document, a safe fusion_save_to_cloud (issue #125); routing and view verbs act on the named document's UI tab
13 files changed
+391−15
CHANGELOG.md+19README.md+4−3bridge.json+1contracts/describe.json+30−5describe.py+6−3rust/bridge/src/main.rs+2−1rust/bridge/src/orchestrations.rs+6−1rust/bridge/src/routing.rs+1−1rust/bridge/src/routing_script.py+12rust/bridge/src/savedoc.rs+47rust/bridge/src/savedoc_script.py+255skills/fusion-routing/SKILL.md+6tests/run_verb_tests.py+2−1CHANGELOG.md+19@@ -64,6 +64,25 @@ The board model, to the [EDA Bridge Standard](https://wiki.adom.inc/adom/eda-bri export, in the background, and switches to the board itself when the schematic or 3D view is in front. The same names now feed `fusion_board_bom` (which read only `LCSC_PN`) and `fusion_component_data`. Live on BQ25792: 109 parts, 45 lines, LCSC on 63 parts, MPN on 28.+- **Fixed: a routing read or edit could act on another board than the one named.** `doc.activate()` moves+ Fusion's API to a document without always moving its UI, and the board export and every Electron command act+ on the UI's board. With the two apart, `fusion_routing_state {expectDocument:"A"}` passed its name check and+ returned board B's copper, and `fusion_route_net` drew on B (caught live 2026-10-26, scratch boards only).+ Every routing verb now puts the UI on the named document's tab first (`UI.SwitchToTab`; a tab's index is the+ document's index), and so do the view verbs (`fusion_show_2d_board` and friends reported success while the+ UI stayed put).+- **Saving an electronics design** (page issue #125):+ - `fusion_save_to_cloud` on an electronics design saves the DESIGN (.fprj with its .fsch and .fbrd, linked;+ the 3D PCB beside them when open), whichever of its tabs is in front. A Save As by name with the board in+ front used to write a standalone .fbrd the design never linked.+ - It refuses a design that is already saved (`already_saved_use_save_document`) unless `asCopy:true`, refuses+ a name already in the folder unless `overwrite:true`, creates missing folders on `folderPath`, and needs+ `projectName` when no project is open in Fusion's data panel (Fusion cannot read the active project then).+ - New `fusion_save_document {documentName?, comment?}`: saves the open design in place as a new version and+ reads back each changed document's version before and after and the board the design links.+ - Verified live: first save with the board in front (fprj, fsch, fbrd v1, linked), route a net, save in place+ (board v1 to v2), a second Save As refused; closed and reopened from the cloud, the board shows the routed+ net and the folder holds exactly one .fprj, .fsch and .fbrd. - **DisplayLink warning** (page issue #121): when Fusion has detected an indirect display adapter (`IndirectDisplayAdapterDetected=1` in its `NMachineSpecificOptions.xml`), Fusion Electronics can die in its D3D12 canvas on any monitor. `fusion_readiness` now carries `displayWarning`, and the guided tour and
README.md+4−3@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_200 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_201 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Lifecycle and readiness** @@ -581,7 +581,7 @@ _200 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_open_by_urn` - Open a cloud ELECTRONICS DESIGN by URN. PCB/ELECTRONICS NAV RULE: a board is NOT a standalone file - the schematic, 2D board and 3D board are VIEWS inside ONE... - `fusion_open_by_urn_note` - (see fusion_open_by_urn) - `fusion_open_cloud_file` - Open a CLOUD file by name (the front door to every design flow).-- `fusion_save_to_cloud` - Save the ACTIVE document to Autodesk cloud storage with an explicit name and destination.+- `fusion_save_to_cloud` - First save of the ACTIVE document to Autodesk cloud storage with an explicit name and destination, dialog-free. - `fusion_search_cloud_files` - DISABLED (returns an error): the in-app add-in search took 30+ min and CRASHED Fusion (WinError 10054). - `fusion_walk_cloud_tree` - DISABLED (returns an error): the in-app add-in walk took 30+ min and CRASHED Fusion (WinError 10054). @@ -682,6 +682,7 @@ _200 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_document_info` - Metadata for the ACTIVE document: name, product type, save state, version, parent project. - `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... - `fusion_live_feed` - A ~1 fps look INTO Fusion while the bridge drives it, kept as a BOX-SIDE ring buffer (~10 min).+- `fusion_save_document` - Save the open electronics design IN PLACE as a new version (no Save As, no fork): resolves the design behind whichever tab is in front (or documentName), saves... - `fusion_screenshot_fusion` - Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific... - `fusion_set_driving_badge` - Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). - `fusion_set_window_sizing` - Read/toggle auto-sizing Fusion's WINDOW after launch (STICKY, default ON).@@ -789,7 +790,7 @@ adom-wiki pkg install adom/fusion-bridge # the skills pack for your AI Auto-updates via `updateManifestUrl` (ab polls on launch + every 4h). Every release lands on the insiders manifest first, and the public manifest moves only after the pre-production gate, so the-two tiers can serve different versions. 200 verbs -+two tiers can serve different versions. 201 verbs - `fusion_describe '{}'` returns the full machine-readable catalog. More demos: <video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/fusion-bridge/files/library2-3d-montage.mp4"></video>
bridge.json+1@@ -186,6 +186,7 @@ "fusion_rules_by_kind", "fusion_run_drc", "fusion_run_modeling_script",+ "fusion_save_document", "fusion_save_to_cloud", "fusion_schematic", "fusion_schematic_plan",
contracts/describe.json+30−5@@ -3203,26 +3203,51 @@ "summary": "Run an adsk.fusion modeling script in the live session (sketches/extrudes/params). Free programmatic CAD.", "timeoutSeconds": 180 },+ {+ "example": {+ "comment": "routed the power nets"+ },+ "input": {+ "comment": "optional version comment",+ "documentName": "optional: the design's name (default the active document)"+ },+ "longRunning": false,+ "name": "fusion_save_document",+ "output": {+ "files": "{...}",+ "linkedBoard": "{name, ext, fileId, version, project, folder}",+ "saved": "bool",+ "success": "bool",+ "versions": "{design, schematic, board, 3dPcb: {before, after}}"+ },+ "statusVerb": null,+ "summary": "Save the open electronics design IN PLACE as a new version (no Save As, no fork): resolves the design behind whichever tab is in front (or documentName), saves it, and reads back each changed document's version before and after, the board the design links, and keeps the 3D PCB beside the design. Answers saved:false when Fusion reports nothing unsaved, and not_saved_yet for a design that was never saved (use fusion_save_to_cloud for that first save).",+ "timeoutSeconds": 180+ }, { "example": { "name": "TJA1050", "projectName": "Main" }, "input": {+ "asCopy": "optional bool: a separate copy of an already-saved design", "description": "optional str",- "folderPath": "optional str",+ "folderPath": "optional str, created if missing", "name": "required str",- "projectName": "optional str"+ "overwrite": "optional bool",+ "projectName": "str (required when no project is open in Fusion's data panel)" }, "longRunning": false, "name": "fusion_save_to_cloud", "output": {+ "files": "{design, schematic, board, 3dPcb: {name, ext, fileId, version, project, folder}}",+ "foldersCreated": "[str]", "success": "bool",- "urn": "str"+ "urn": "str (mechanical)" }, "statusVerb": null,- "summary": "Save the ACTIVE document to Autodesk cloud storage with an explicit name and destination. Dialog-free precisely because you supply what the Save box would ask for - use it before closing or restarting when a document has never been saved (fusion_close_all_documents refuses such a document with unsaved_named_document rather than discarding it).",- "timeoutSeconds": 180+ "summary": "First save of the ACTIVE document to Autodesk cloud storage with an explicit name and destination, dialog-free. On an electronics design it saves the DESIGN (.fprj with its .fsch and .fbrd, linked; the 3D PCB beside them), whichever of its tabs is in front, and puts the same kind of tab back in front. A design that is already saved is REFUSED (already_saved_use_save_document: a Save As by name forks the board into an unlinked .fbrd) unless asCopy:true. Missing folders on folderPath are created; a name already in the folder is refused unless overwrite:true. A mechanical document is saved as before.",+ "timeoutSeconds": 240 }, { "example": {
describe.py+6−3@@ -423,9 +423,12 @@ _T = [ {"success": "bool", "activeDocument": "str"}, 60, None, False, {"name": "TJA1050"}), ("fusion_action_ledger", "Read the append-only record of what the bridge did in the BACKGROUND on this machine - dialogs it answered, Untitled scratch docs it swept, Fusion auto-starts, add-in restarts. Survives bridge respawns (~/.adom/fusion-bridge/actions.jsonl). This is the audit trail for why something changed on the user screen.", {"limit": "optional int (default 50)"}, {"actions": "[{at, action, detail}]"}, 15, None, True, {"limit": 20}),- ("fusion_save_to_cloud", "Save the ACTIVE document to Autodesk cloud storage with an explicit name and destination. Dialog-free precisely because you supply what the Save box would ask for - use it before closing or restarting when a document has never been saved (fusion_close_all_documents refuses such a document with unsaved_named_document rather than discarding it).",- {"name": "required str", "projectName": "optional str", "folderPath": "optional str", "description": "optional str"},- {"success": "bool", "urn": "str"}, 180, None, False, {"name": "TJA1050", "projectName": "Main"}),+ ("fusion_save_to_cloud", "First save of the ACTIVE document to Autodesk cloud storage with an explicit name and destination, dialog-free. On an electronics design it saves the DESIGN (.fprj with its .fsch and .fbrd, linked; the 3D PCB beside them), whichever of its tabs is in front, and puts the same kind of tab back in front. A design that is already saved is REFUSED (already_saved_use_save_document: a Save As by name forks the board into an unlinked .fbrd) unless asCopy:true. Missing folders on folderPath are created; a name already in the folder is refused unless overwrite:true. A mechanical document is saved as before.",+ {"name": "required str", "projectName": "str (required when no project is open in Fusion's data panel)", "folderPath": "optional str, created if missing", "description": "optional str", "asCopy": "optional bool: a separate copy of an already-saved design", "overwrite": "optional bool"},+ {"success": "bool", "files": "{design, schematic, board, 3dPcb: {name, ext, fileId, version, project, folder}}", "foldersCreated": "[str]", "urn": "str (mechanical)"}, 240, None, False, {"name": "TJA1050", "projectName": "Main"}),+ ("fusion_save_document", "Save the open electronics design IN PLACE as a new version (no Save As, no fork): resolves the design behind whichever tab is in front (or documentName), saves it, and reads back each changed document's version before and after, the board the design links, and keeps the 3D PCB beside the design. Answers saved:false when Fusion reports nothing unsaved, and not_saved_yet for a design that was never saved (use fusion_save_to_cloud for that first save).",+ {"documentName": "optional: the design's name (default the active document)", "comment": "optional version comment"},+ {"success": "bool", "saved": "bool", "versions": "{design, schematic, board, 3dPcb: {before, after}}", "linkedBoard": "{name, ext, fileId, version, project, folder}", "files": "{...}"}, 180, None, False, {"comment": "routed the power nets"}), ("fusion_take_screenshot", "Screenshot the Fusion window in the BACKGROUND (no focus steal, reads through occlusion). Use it to VERIFY your own work: geometry at the wrong Z, an empty view, or a Placeholder 3D body all render successfully and only a look at the pixels catches them.", {"outputPath": "optional str"}, {"success": "bool", "fullPath": "str (auto-pulled by ab, 1.10.37)", "path": "str"}, 60, None, True, {}), ("fusion_verb_times", "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.",
rust/bridge/src/main.rs+2−1@@ -43,6 +43,7 @@ mod routing; mod sheet; mod bodies; mod display;+mod savedoc; mod fusionpath; mod tour; mod palette;@@ -413,7 +414,7 @@ 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 mut r=match cmd { "bodies" => bodies::bodies(&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)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+ let mut r=match cmd { "bodies" => bodies::bodies(&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)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) }; 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+6−1@@ -616,7 +616,12 @@ if not (app.activeDocument and app.activeDocument.name.lower() == want and cur_p hit = d break if hit is not None:- hit.activate()+ # the UI's tab too: activate() can leave the UI (and so every editor switch) on another tab+ idx = next((k for k in range(app.documents.count) if app.documents.item(k) == hit), None)+ if idx is None:+ hit.activate()+ else:+ app.executeTextCommand('UI.SwitchToTab %d' % idx) result = {{"wanted": want, "tabType": {want_pt:?}, "switchedTab": hit is not None, "activeNow": app.activeDocument.name if app.activeDocument else None}} "#); // straight to the add-in: this is the bridge's own bookkeeping, not a user script, so it skips
rust/bridge/src/routing.rs+1−1@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"), "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n", serde_json::to_string(SOURCE).unwrap(),
rust/bridge/src/routing_script.py+12@@ -53,10 +53,22 @@ def identifier(v): return v +def show_tab_of(app, doc):+ """Put Fusion's UI on the tab of `doc`. doc.activate() moves the API's active document but not+ always the UI, and the board export and every Electron command act on the UI's board: with the+ two apart, a read named one board and returned another, and an edit landed on the wrong one+ (live 2026-10-26). A tab's index is the document's index in app.documents."""+ docs = [app.documents.item(i) for i in range(app.documents.count)]+ i = next((k for k, d in enumerate(docs) if d == doc), None)+ if i is not None:+ app.executeTextCommand('UI.SwitchToTab %d' % i)++ def snapshot(app, args): doc = app.activeDocument if not doc or app.activeProduct.productType != 'BoardProductType': fail('wrong_document', 'Activate a PCB board before reading or routing it')+ show_tab_of(app, doc) if args.get('expectDocument') != doc.name: fail('wrong_document', 'expectDocument must match the active board name exactly') identity = str(doc.creationId)
rust/bridge/src/savedoc.rsadded+47@@ -0,0 +1,47 @@+//! Saving an electronics design (page issue #125): `fusion_save_document` saves the open design in+//! place as a new version, and `fusion_save_to_cloud` on an electronics design saves the DESIGN (not+//! the board in front) and refuses a Save As of a design already saved. The logic is+//! savedoc_script.py, run through the add-in's script transport so it needs no add-in update. A+//! mechanical document goes to the add-in's own save.+use serde_json::{json, Value};++const SOURCE: &str = include_str!("savedoc_script.py");++fn run(action: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let script = format!("import json\nexec({})\nresult=sd_handle(app,json.loads({}),{})\n",+ serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(action).unwrap());+ let r = pass("run_modeling_script", json!({"script": script}));+ match r["data"]["result"].as_object() {+ Some(_) => r["data"]["result"].clone(),+ None if r["success"].as_bool() == Some(false) => r,+ None => json!({"success": false, "errorCode": "invalid_save_response", "error": "Fusion did not return a structured save result", "mutationMayHaveOccurred": true,+ "_hint": "Read the design's files (fusion_list_cloud_files) before saving again."}),+ }+}++/// `fusion_save_document {documentName?, comment?}`+pub fn save_document(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { run("save_document", args, pass) }++/// `fusion_save_to_cloud {name, projectName?, folderPath?, description?, asCopy?, overwrite?}`+pub fn save_to_cloud(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let r = run("save_to_cloud", args, pass);+ if r["notElectronics"].as_bool() == Some(true) { pass("save_to_cloud", args.clone()) } else { r }+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn mechanical_documents_go_to_the_addins_own_save() {+ let seen = std::cell::RefCell::new(vec![]);+ let pass = |v: &str, _a: Value| { seen.borrow_mut().push(v.to_string()); if v == "run_modeling_script" { json!({"success": true, "data": {"result": {"notElectronics": true}}}) } else { json!({"success": true, "via": "addin"}) } };+ assert_eq!(save_to_cloud(&json!({"name": "x"}), &pass)["via"], "addin");+ assert_eq!(*seen.borrow(), vec!["run_modeling_script", "save_to_cloud"]);+ }+ #[test]+ fn electronics_answers_come_back_as_they_are() {+ let pass = |_v: &str, _a: Value| json!({"success": true, "data": {"result": {"success": false, "errorCode": "already_saved_use_save_document"}}});+ assert_eq!(save_to_cloud(&json!({"name": "x"}), &pass)["errorCode"], "already_saved_use_save_document");+ assert_eq!(save_document(&json!({}), &|_v: &str, _a: Value| json!({"success": false, "errorCode": "timeout"}))["errorCode"], "timeout");+ }+}
rust/bridge/src/savedoc_script.pyadded+255@@ -0,0 +1,255 @@+"""Saving an electronics design the way Fusion means it to be saved (page issue #125).++Embedded by savedoc.rs and run on Fusion's main thread. A design is up to four documents with one+name: the design itself (EcadDesignProductType, the .fprj), its schematic (.fsch), its board (.fbrd)+and its 3D PCB (.f3d). What was learned live on DESKTOP-BAR6S0F, 2026-10-26:++- Save As on the BOARD document writes a new standalone .fbrd the design never links (the fork in+ the issue). Saving the DESIGN document saves .fprj, .fsch and .fbrd together, linked, and the 3D+ PCB beside them when it is open.+- Saving the design document in place moves each changed document to its next version in the+ same lineage (the board 1 -> 2), right after a first save as well as on a design opened from+ the cloud.+- doc.activate() can move the API's active document without moving Fusion's UI, and edits go to+ the UI's board: an edit then lands on another design and the one saved has nothing to save.+ Tabs are switched with UI.SwitchToTab (sd_activate), which moves both.+- Right after a first save, a DataFile's parentProject is not readable yet (InternalValidationError),+ so the reply names the project and folder the design was saved into.+"""+import time++SD_ECAD, SD_SCH, SD_BRD, SD_3D = 'EcadDesignProductType', 'SchematicProductType', 'BoardProductType', 'DesignProductType'+++class SaveError(Exception):+ def __init__(self, code, message, **extra):+ super().__init__(message)+ self.code, self.extra = code, extra+++def sd_pt(d):+ try:+ return d.products.item(0).productType if d.products.count else ''+ except Exception:+ return ''+++def sd_wait(cond, secs):+ import adsk+ end = time.time() + secs+ while time.time() < end:+ adsk.doEvents()+ try:+ if cond():+ return True+ except Exception:+ pass+ time.sleep(0.4)+ return False+++def sd_docs(app):+ return [app.documents.item(i) for i in range(app.documents.count)]+++def sd_family(app, anchor):+ """The design's documents by product type. A saved design's documents share their cloud name+ and folder; an unsaved one's are told apart only by order: Fusion opens a design's tabs right+ after its design document, so the family is the design document before the anchor and the+ unsaved, same-named documents that follow it."""+ docs = sd_docs(app)+ fam = {}+ if anchor.dataFile is not None:+ ref = anchor.dataFile+ folder = ref.parentFolder.id+ for d in docs:+ pt = sd_pt(d)+ if pt in (SD_ECAD, SD_SCH, SD_BRD) and d.dataFile is not None and d.dataFile.name == ref.name and d.dataFile.parentFolder.id == folder:+ fam.setdefault(pt, d)+ for d in docs:+ if sd_pt(d) == SD_3D and d.name == anchor.name and (d.dataFile is None or (d.dataFile.name == ref.name and d.dataFile.parentFolder.id == folder)):+ fam.setdefault(SD_3D, d)+ return fam+ i = next(k for k, d in enumerate(docs) if d == anchor)+ start = next((k for k in range(i, -1, -1) if sd_pt(docs[k]) == SD_ECAD and docs[k].dataFile is None and docs[k].name == anchor.name), None)+ if start is None:+ return {sd_pt(anchor): anchor}+ fam[SD_ECAD] = docs[start]+ for d in docs[start + 1:]:+ pt = sd_pt(d)+ if pt not in (SD_SCH, SD_BRD, SD_3D) or d.dataFile is not None or d.name != anchor.name or pt in fam:+ break+ fam[pt] = d+ return fam+++def sd_activate(app, d):+ """Bring a document's tab to the front in the UI too (doc.activate() alone can leave the UI on+ another tab; see show_tab_of in routing_script.py)."""+ docs = sd_docs(app)+ i = next((k for k, x in enumerate(docs) if x == d), None)+ if i is None:+ d.activate()+ else:+ app.executeTextCommand('UI.SwitchToTab %d' % i)+++def sd_anchor(app, name):+ if not name:+ if app.activeDocument is None:+ raise SaveError('no_document', 'No document is open')+ return app.activeDocument+ hits = [d for d in sd_docs(app) if d.name.lower() == name.lower() and sd_pt(d) in (SD_ECAD, SD_SCH, SD_BRD, SD_3D)]+ if not hits:+ raise SaveError('no_such_document', 'No open electronics document is named %s' % name)+ return next((d for d in hits if d == app.activeDocument), hits[0])+++def sd_file(d, project=None, folder=None):+ df = d.dataFile+ if df is None:+ return None+ row = {'name': df.name, 'ext': df.fileExtension, 'fileId': df.id, 'version': df.versionNumber}+ for key, get, fallback in (('project', lambda: df.parentProject.name, project), ('folder', lambda: df.parentFolder.name, folder)):+ try:+ row[key] = get()+ except Exception: # not readable yet right after a first save+ row[key] = fallback+ return row+++def sd_latest(app, d):+ """The open document's own version: the cloud's latestVersionNumber lags a fresh save by+ seconds (it read 3 while the document already said 4, live 2026-10-26)."""+ return d.dataFile.versionNumber if d.dataFile is not None else None+++def sd_project(app, name):+ hub = app.data.activeHub+ projects = [hub.dataProjects.item(i) for i in range(hub.dataProjects.count)]+ if name:+ hit = next((p for p in projects if p.name.lower() == name.lower()), None)+ if hit is None:+ raise SaveError('project_not_found', 'No project named %s in the %s hub' % (name, hub.name), projects=[p.name for p in projects])+ return hit+ try:+ if app.data.activeProject is not None:+ return app.data.activeProject+ except Exception:+ pass # no project open in the data panel: Fusion answers InternalValidationError+ raise SaveError('project_required', 'No project is open in Fusion\'s data panel, so name one with projectName',+ projects=[p.name for p in projects])+++def sd_folder(project, path):+ """The folder at path under the project's root, creating the missing ones (never a project)."""+ folder, made = project.rootFolder, []+ for part in [p for p in (path or '').replace('\\', '/').split('/') if p.strip()]:+ sub = next((folder.dataFolders.item(i) for i in range(folder.dataFolders.count) if folder.dataFolders.item(i).name == part), None)+ if sub is None:+ sub = folder.dataFolders.add(part)+ made.append(part)+ folder = sub+ return folder, made+++def sd_save_document(app, args):+ anchor = sd_anchor(app, args.get('documentName') or args.get('expectDocument'))+ fam = sd_family(app, anchor)+ ecad = fam.get(SD_ECAD)+ if ecad is None:+ raise SaveError('not_an_electronics_design', '%s is not part of an open electronics design' % anchor.name)+ if ecad.dataFile is None:+ raise SaveError('not_saved_yet', 'The design %s has never been saved' % ecad.name)+ members = {pt: d for pt, d in fam.items() if d.dataFile is not None or pt == SD_3D}+ before = {pt: (sd_latest(app, d) if d.dataFile is not None else None) for pt, d in members.items()}+ modified = {pt: d.isModified for pt, d in members.items()}+ if not any(modified.values()):+ return {'success': True, 'saved': False, 'design': sd_file(ecad), 'versions': before,+ '_hint': 'Fusion reports no unsaved change in this design, so there was nothing to save. If you edited it, check the edit landed on this design: fusion_routing_state names the board it read (expectDocument), and before 1.12.0 an edit could land on the board showing in Fusion instead of the one named.'}+ front = app.activeDocument+ ok = ecad.save(str(args.get('comment') or 'Saved by Adom'))+ sd_wait(lambda: not ecad.isModified and all(not d.isModified for d in members.values()), 90)+ # each changed board or schematic reports its new version once the upload is done+ sd_wait(lambda: all(sd_latest(app, d) != before.get(pt) for pt, d in sd_family(app, ecad).items()+ if modified.get(pt) and pt in (SD_SCH, SD_BRD)), 60)+ fam = sd_family(app, ecad)+ try:+ if front is not None and front != app.activeDocument:+ sd_activate(app, front)+ except Exception:+ pass+ after = {pt: (sd_latest(app, d) if d.dataFile is not None else None) for pt, d in fam.items()}+ stuck = [pt for pt, m in modified.items() if m and pt in (SD_SCH, SD_BRD) and after.get(pt) == before.get(pt)]+ ok = bool(ok) and not stuck+ names = {SD_ECAD: 'design', SD_SCH: 'schematic', SD_BRD: 'board', SD_3D: '3dPcb'}+ return {'success': ok, 'saved': True, 'errorCode': None if ok else 'readback_mismatch',+ 'error': None if ok else 'Fusion did not write a new version of the %s' % ', '.join(names[p] for p in stuck),+ 'design': sd_file(ecad), 'files': {names[pt]: sd_file(d) for pt, d in fam.items()},+ 'versions': {names[pt]: {'before': before.get(pt), 'after': after.get(pt)} for pt in fam},+ 'linkedBoard': sd_file(fam[SD_BRD]) if SD_BRD in fam else None,+ '_hint': 'Saved in place: each changed document moved to its next version in the same lineage (versions.*.after), the design still links the board it was edited in (linkedBoard), and the 3D PCB is kept beside the design. Nothing was forked.'}+++def sd_save_new(app, args):+ anchor = app.activeDocument+ if anchor is None:+ raise SaveError('no_document', 'No document is open')+ fam = sd_family(app, anchor)+ ecad = fam.get(SD_ECAD)+ if ecad is None:+ return {'notElectronics': True}+ name = str(args.get('name') or '').strip()+ if not name:+ raise SaveError('bad_args', 'name is required for a first save')+ if ecad.dataFile is not None and args.get('asCopy') is not True:+ raise SaveError('already_saved_use_save_document',+ 'This electronics design is already saved as %s in %s/%s: a Save As by name would fork it' % (ecad.dataFile.name, ecad.dataFile.parentProject.name, ecad.dataFile.parentFolder.name),+ design=sd_file(ecad))+ project = sd_project(app, args.get('projectName'))+ folder, made = sd_folder(project, args.get('folderPath'))+ clash = [folder.dataFiles.item(i).fileExtension for i in range(folder.dataFiles.count) if folder.dataFiles.item(i).name == name]+ if clash and args.get('overwrite') is not True:+ raise SaveError('name_taken', '%s/%s already holds %s named %s' % (project.name, folder.name, ', '.join(sorted(set(clash))), name), existing=clash)+ front_pt = sd_pt(anchor)+ ok = ecad.saveAs(name, folder, str(args.get('description') or ''), '')+ # the first save REPLACES the design's document object (a new creationId; the old handle throws+ # InternalValidationError), so it is found again by the file it was just saved as+ def saved_design():+ for d in sd_docs(app):+ try:+ if sd_pt(d) == SD_ECAD and d.dataFile is not None and d.dataFile.name == name and d.dataFile.parentFolder.id == folder.id:+ return d+ except Exception:+ pass+ return None+ sd_wait(lambda: saved_design() is not None, 90)+ ecad = saved_design()+ if ecad is None:+ raise SaveError('save_not_confirmed', 'Fusion did not report the design saved as %s in %s/%s within 90 s' % (name, project.name, folder.name))+ sd_wait(lambda: all(d.dataFile is not None for pt, d in sd_family(app, ecad).items() if pt != SD_3D), 60)+ fam = sd_family(app, ecad)+ names = {SD_ECAD: 'design', SD_SCH: 'schematic', SD_BRD: 'board', SD_3D: '3dPcb'}+ saved = {names[pt]: sd_file(d, project.name, folder.name) for pt, d in fam.items()}+ if front_pt in fam:+ sd_activate(app, fam[front_pt]) # the same kind of tab as before, in the UI too+ return {'success': bool(ok) and saved.get('design') is not None, 'saved': True, 'files': saved, 'project': project.name,+ 'folder': folder.name, 'foldersCreated': made,+ '_hint': 'The DESIGN was saved: .fprj with its .fsch and .fbrd, linked (and the 3D PCB beside them when it is open). Save later changes in place with fusion_save_document; fusion_save_to_cloud refuses this design from now on unless asCopy:true.'}+++def sd_handle(app, args, action):+ try:+ if action == 'save_document':+ return sd_save_document(app, args)+ if action == 'save_to_cloud':+ return sd_save_new(app, args)+ return {'success': False, 'errorCode': 'unknown_action', 'error': action}+ except SaveError as e:+ hints = {'not_saved_yet': 'Save it the first time with fusion_save_to_cloud {name, projectName, folderPath}; it saves the whole design, linked.',+ 'already_saved_use_save_document': 'Save the changes in place with fusion_save_document {comment}. Pass asCopy:true only when you really want a second, separate copy of the design.',+ 'project_required': 'Pass projectName (one of projects); folderPath is created if missing.',+ 'project_not_found': 'Pass one of projects as projectName.',+ 'name_taken': 'Pick another name, or open the existing design and save into it with fusion_save_document.',+ 'not_an_electronics_design': 'Bring a design, schematic or board to the front (fusion_show_2d_board {documentName}).'}+ return {'success': False, 'errorCode': e.code, 'error': str(e), **e.extra, '_hint': hints.get(e.code, '')}
skills/fusion-routing/SKILL.md+6@@ -72,6 +72,12 @@ not invented bridge verbs. These paths need their own identity checks, clearance native readback and DRC; they do not inherit the dedicated routing verb's preflight guarantees. Never transplant a worked example's layer numbers, dimensions or net names without inspection. +## Save the work++Save a design's first time with `fusion_save_to_cloud {name, projectName, folderPath}` (it saves the+design, linked, whichever tab is in front), and every later change in place with+`fusion_save_document {comment}`. Never Save As a design that is already saved: it forks the board.+ ## Commit, inspect, and recover Inspect semantic `success`, `verified`, `mutationAttempted`, returned copper and revision.
tests/run_verb_tests.py+2−1@@ -340,6 +340,7 @@ for v in ["fusion_apply_fusion_update", "fusion_resolve_seat_dialog", "fusion_in t("fusion_schematic#guard", "negative", {}, {"pred": "must_refuse"}, "no action: must refuse with the menu, never open a dialog") for v in ["fusion_launch", "fusion_export_gerber", "fusion_dismiss_dialogs", "fusion_board_summary", "fusion_install"]: t(v, "manual", note="EDA Bridge Standard alias of an existing verb (the reply carries aliasOf); tested through the verb it names")+t("fusion_save_document", "manual", note="saves a cloud design in place (a new version): exercised live on a scratch design in a test folder") t("fusion_read_sheet_file#guard", "negative", {"path": "C:/no/such/file.sch"}, {"pred": "must_refuse"}, "a missing file refuses with file_not_found, never an empty parts list") t("fusion_schematic#missing", "negative", {"action": "open", "schPath": "C:/nope/missing.sch"}, {"pred": "must_refuse"}, "a missing local .sch must refuse (via open_schematic) with no dialog") t("fusion_schematic", "manual", note="open a cloud design's schematic / create a blank one: exercised by the weekly tour")@@ -354,7 +355,7 @@ _SECTION_BY_VERB = { "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom", "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom", "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",- "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle",+ "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom", "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom", "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",