← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.358\ No newline at end of file+1.9.359\ No newline at end of file
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.358",+  "version": "1.9.359",   "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+32
@@ -836,6 +836,38 @@       "summary": "Smoothly ZOOM the schematic/2D board for a recording (the whole motion is one call). factor>1 zooms in, <1 out; or fit:true. The loop runs in the add-in with a repaint per frame.",       "timeoutSeconds": 60     },+    {+      "example": {+        "entity": "Element",+        "fields": [+          "name",+          "x",+          "y",+          "angle"+        ]+      },+      "input": {+        "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)"+      },+      "longRunning": false,+      "name": "fusion_electronics_query",+      "output": {+        "count": "int",+        "entity": "str",+        "rows": "[dict]",+        "source": "mcp|board",+        "success": "bool",+        "total": "int"+      },+      "statusVerb": "fusion_get_app_state",+      "summary": "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.",+      "timeoutSeconds": 120+    },     {       "example": {         "command": "Electron.ImmediateRepaint /full"
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.358"+version = "1.9.359" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.358"+version = "1.9.359" edition = "2021"  [dependencies]
describe.py+2
@@ -130,6 +130,8 @@ _T = [     ("fusion_show_3d_board", "Switch the OPEN electronics design to its 3D PCB view (populated board). A VIEW of the project, not a standalone file. Orbit it for demos via fusion_run_modeling_script (app.activeViewport.camera + vp.refresh()).",      {"capture": "optional bool - ONE call = ONE consent: the bridge settles the surface (waits for the add-in main thread + a paint beat) and returns proof inline as shot:{title,image,thumbnailDataUrl,maxWidth} (base64 frame + a ready-to-use data: URL for BROWSER callers who cannot pull a file; set captureMaxWidth to shrink it - 320 keeps a /command body small, default 640. The bridge encodes PNG; the relay may transcode to WebP, so sniff rather than assume). Use this instead of polling fusion_state after a show - page-side settle polling costs one human consent per poll (issue #30)."}, {"activeWorkspace": "str", "screenshots": "[...]"}, 70, None, True, {}),     ("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_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.",
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.358"+DASH_VERSION="1.9.359" 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.358",+  "version": "1.9.359",   "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.358",+  "version": "1.9.359",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,28 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "agent_permissions": {-    "allow_bash": [],-    "allow": [-      "adom-desktop-fusion-bridge read-only verbs (via the adom-bridge CLI): fusion_readiness, fusion_get_app_state, fusion_addin_status, fusion_check_dialogs - state queries; they change nothing.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_alerts (reads Fusion's own log for toasts), fusion_document_info, fusion_window_info, fusion_board_info, fusion_library_parts - inspect only.",-      "adom-desktop-fusion-bridge read-only verbs: fusion_prove_result (a persisted run report), fusion_plan_library_import (report-only preflight), fusion_list_cloud_projects, fusion_describe.",-      "adom-desktop-fusion-bridge evidence captures fusion_screenshot_fusion and fusion_capture_library_views read pixels and write local image files only."-    ],-    "soft_deny": [-      "adom-desktop-fusion-bridge lifecycle verbs fusion_stop, fusion_kill, fusion_restart_for_addin close or kill the Fusion application - keep the human prompt.",-      "adom-desktop-fusion-bridge fusion_install_fusion silently installs Autodesk Fusion on the user's machine.",-      "adom-desktop-fusion-bridge fusion_cleanup_cloud_files deletes files from the user's Autodesk cloud hub.",-      "adom-desktop-fusion-bridge managed-library verbs (fusion_managed_library_push/pull/create/link/unlink) publish to or overwrite the user's library.io libraries."-    ],-    "environment": [-      "adom-desktop-fusion-bridge is the Fusion 360 bridge for Adom Bridge; its fusion_* verbs are invoked through the adom-bridge CLI and drive Autodesk Fusion on the user's own desktop.",-      "adom-desktop-fusion-bridge desktop actions also pass Adom Bridge's own human-approval gate (caller identity, per-call reason, Activity Log) - a container approval is the first of two gates."-    ]-  },-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.9.358"+version = "1.9.359" edition = "2021" license = "MIT" 
rust/PORTING.md+14
@@ -478,3 +478,17 @@ python the only interpreter on the box. Note: `bridge_install` overlays the cach clearing it, so a box that had the Python bridge keeps a stale server.py on disk (inert: the exe spawns no sidecar unless a pref asks); a fresh box gets the 44-file zip only. Public promotion is `tools/promote.sh 1.9.357 --attest "<John's words>"`.++## 2026-09-04 (evening): APS proven on both boxes; the typed electronics read model++`fusion_aps_signin` native, end to end: on AdomLapper the remembered Chrome profile consented on a+warm SSO session and the listener captured a fresh token by itself ([email protected]); on+ConfRoomROG the org client id was set, the consent opened through the default browser handler+(no extension there), the email was typed over the OS, John typed the password at the box, and+the ten-minute listener captured [email protected]. `fusion_aps_search` answers on both.++`fusion_electronics_query` (electronics.rs) is the roadmap's typed read model: Autodesk's MCP+`electronics_read` when the server answers (all 49 classes, filters, fields, pagination), else+the board_info XML for Board/Element/Library/Package/Layer/Signal/Wire/Error, one row shape,+`source` naming which. The fallback parser is pinned by `tests/fixtures/board_info_sample.json`+(the generated test project on ConfRoomROG).
rust/bridge/src/electronics.rsadded+153
@@ -0,0 +1,153 @@+//! `fusion_electronics_query`: the typed electronics read model. One verb, one row shape, two+//! sources: Autodesk's own MCP `electronics_read` (49 entity classes, filters, pagination) when+//! its server answers, and the board's `board_info` XML for the common entities when it does not+//! (the free tier, or the server simply off). The caller never learns which; `source` says.+use crate::addin::Ctx;+use serde_json::{json, Value};++/// Entity classes Autodesk's MCP serves (the enum in its tool schema, 2026-09-04).+pub const ENTITIES: &[&str] = &["Attribute", "Bus", "Circle", "Contact", "Element", "Frame", "Gate", "Hole", "Grid", "Instance", "Junction", "Label", "Layer", "Module", "ModuleInstance", "Net", "Signal", "Pad", "Part", "Pin", "PinRef", "PolyCutout", "Spline", "PolyPour", "PolyShape", "Port", "PortRef", "Rectangle", "Segment", "Sheet", "Schematic", "Board", "Library", "Smd", "Symbol", "DeviceSet", "Device", "Package", "Package3D", "Technology", "Text", "VariantDef", "Variant", "Via", "ContactRef", "Wire", "Dimension", "NetClass", "Error", "PortNetMappingEntry"];+/// What the board_info fallback can answer without the MCP server.+const BOARD_ENTITIES: &[&str] = &["Board", "Element", "Library", "Package", "Layer", "Signal", "Net", "Error", "Wire"];++fn canonical(entity: &str) -> Option<&'static str> {+    let e = entity.trim().trim_start_matches("electronics.").to_lowercase();+    ENTITIES.iter().find(|x| x.to_lowercase() == e).copied()+}+fn attrs(tag: &str) -> Value {+    let re = regex::Regex::new(r#"([\w:-]+)="([^"]*)""#).unwrap();+    let mut o = serde_json::Map::new();+    for c in re.captures_iter(tag) { o.insert(c[1].to_string(), json!(c[2].to_string())); }+    Value::Object(o)+}+fn num(v: &Value) -> Option<f64> { v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) }+fn section<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {+    let open = format!("<{tag}"); let close = format!("</{tag}>");+    let i = xml.find(&open)?; let j = xml[i..].find(&close)? + i;+    Some(&xml[i..j])+}++/// Rows for one entity from the board_info payload (`data.rawXml` + `data.summary`).+pub fn rows_from_board_info(entity: &str, data: &Value) -> Result<Vec<Value>, String> {+    let xml = data["rawXml"].as_str().unwrap_or("");+    let summary = &data["summary"];+    let board = regex::Regex::new(r"<g ([^>]*boardThickness[^>]*)>").unwrap().captures(xml).map(|c| attrs(&c[1])).unwrap_or(json!({}));+    let unit = board["unit"].as_str().unwrap_or("mm").to_string();+    let g_re = regex::Regex::new(r"<g ([^>]*)/?>").unwrap();+    Ok(match entity {+        "Board" => vec![json!({"name": board["name"], "unit": unit, "boardThickness": num(&board["boardThickness"]), "topCopperThickness": num(&board["topCopperThickness"]), "bottomCopperThickness": num(&board["bottomCopperThickness"]), "layerSetup": board["layersetup"], "topLayer": num(&board["topLayer"]), "bottomLayer": num(&board["bottomLayer"]), "viaLimit": num(&board["viaLimit"]), "componentCount": summary["componentCount"], "netCount": summary["netCount"]})],+        "Element" => {+            // the summary carries placements; the libraries section links each to its package+            let mut rows: Vec<Value> = summary["components"].as_array().cloned().unwrap_or_default().into_iter().map(|c| json!({"name": c["name"], "value": c["value"], "library": c["library"], "package": c["package"], "x": num(&c["x"]), "y": num(&c["y"]), "unit": unit, "angle": c["rotation"].as_str().and_then(|r| r.trim_start_matches(|ch: char| ch.is_alphabetic()).parse::<f64>().ok()), "rotation": c["rotation"], "mirrored": c["rotation"].as_str().map(|r| r.starts_with('M')).unwrap_or(false), "locked": c["locked"]})).collect();+            if rows.is_empty() { if let Some(lib) = section(xml, "libraries") {+                let mut pkg = String::new();+                for c in g_re.captures_iter(lib) { let a = attrs(&c[1]); if a["package"].is_string() { pkg = a["package"].as_str().unwrap_or("").to_string(); } else if a["name"].is_string() { rows.push(json!({"name": a["name"], "package": pkg, "x": num(&a["x"]), "y": num(&a["y"]), "unit": unit, "rotation": a["rot"], "angle": a["rot"].as_str().and_then(|r| r.trim_start_matches(|ch: char| ch.is_alphabetic()).parse::<f64>().ok()), "locked": a["LOCKED"].as_str() == Some("true"), "objectId": a["id"]})); } }+            } }+            rows+        }+        "Library" | "Package" => {+            let mut rows = Vec::new();+            if let Some(lib) = section(xml, "libraries") { for c in g_re.captures_iter(lib) { let a = attrs(&c[1]); if a["package"].is_string() {+                let bounds: Vec<f64> = a["bounds"].as_str().unwrap_or("").split(',').filter_map(|s| s.trim().parse().ok()).collect();+                rows.push(if entity == "Library" { json!({"name": a["library"], "libraryUrn": a["library_urn"], "package": a["package"], "id": a["id"]}) } else { json!({"name": a["package"], "library": a["library"], "package3dUrn": a["package3d_urn"], "packageUrn": a["package_urn"], "type": a["type"], "usePlaceholder": a["use_placeholder"].as_str() == Some("true"), "version": a["version"], "bounds": bounds, "unit": unit, "id": a["id"]}) });+            } } }+            if entity == "Library" { let mut seen = std::collections::HashSet::new(); rows.retain(|r| seen.insert(r["name"].to_string())); }+            rows+        }+        "Layer" => {+            let copper: Vec<String> = regex::Regex::new(r#"<g id="(\d+)_copper""#).unwrap().captures_iter(xml).map(|c| c[1].to_string()).collect();+            let (top, bottom) = (num(&board["topLayer"]).unwrap_or(1.0) as i64, num(&board["bottomLayer"]).unwrap_or(16.0) as i64);+            let mut rows: Vec<Value> = copper.iter().filter_map(|n| n.parse::<i64>().ok()).map(|n| json!({"number": n, "name": if n == top { "Top" } else if n == bottom { "Bottom" } else if n == 29 { "tStop" } else if n == 30 { "bStop" } else { "" }, "kind": if n == top || n == bottom || (n > top && n < bottom) { "copper" } else { "drawing" }, "hasGeometry": true})).collect();+            rows.sort_by_key(|r| r["number"].as_i64().unwrap_or(0)); rows+        }+        "Signal" | "Net" => summary["nets"].as_array().cloned().unwrap_or_default().into_iter().map(|n| if n.is_string() { json!({"name": n}) } else { n }).collect(),+        "Error" => section(xml, "drc").map(|d| g_re.captures_iter(d).map(|c| attrs(&c[1])).collect()).unwrap_or_default(),+        "Wire" => {+            let mut rows = Vec::new();+            let cre = regex::Regex::new(r"<copper ([^>]*)/>").unwrap();+            for grp in regex::Regex::new(r#"(?s)<g id="(\d+)_copper"[^>]*>(.*?)</g>"#).unwrap().captures_iter(xml) {+                let layer: i64 = grp[1].parse().unwrap_or(0);+                for c in cre.captures_iter(&grp[2]) { let a = attrs(&c[1]); let pts: Vec<f64> = a["d"].as_str().unwrap_or("").split_whitespace().filter_map(|s| s.parse().ok()).collect(); rows.push(json!({"layer": layer, "signal": a["name"], "id": a["id"], "shape": if a["circle"].as_str().map(|s| !s.is_empty()).unwrap_or(false) { "circle" } else { "polygon" }, "points": pts.chunks(3).map(|p| json!([p.get(0), p.get(1)])).collect::<Vec<_>>(), "unit": unit})); }+            }+            rows+        }+        _ => return Err(format!("{entity} is not in the board_info fallback")),+    })+}++fn apply_query(mut rows: Vec<Value>, args: &Value) -> (Vec<Value>, usize) {+    if let Some(fs) = args["filters"].as_array() {+        for f in fs {+            let (p, op) = (f["property"].as_str().unwrap_or("").to_string(), f["op"].as_str().unwrap_or("eq").to_string());+            let want = f["value"].clone();+            rows.retain(|r| { let got = &r[&p]; match op.as_str() { "lt" => num(got).zip(num(&want)).map(|(a, b)| a < b).unwrap_or(false), "gt" => num(got).zip(num(&want)).map(|(a, b)| a > b).unwrap_or(false), _ => got == &want || (got.as_str().map(|s| Some(s) == want.as_str()).unwrap_or(false)) || num(got).zip(num(&want)).map(|(a, b)| a == b).unwrap_or(false) } });+        }+    }+    let total = rows.len();+    if let Some(fields) = args["fields"].as_array() { let keep: Vec<&str> = fields.iter().filter_map(|f| f.as_str()).collect(); rows = rows.into_iter().map(|r| { let mut o = json!({}); for k in &keep { o[*k] = r[*k].clone(); } o }).collect(); }+    let offset = args["offset"].as_u64().unwrap_or(0) as usize; let limit = args["limit"].as_u64().unwrap_or(100) as usize;+    (rows.into_iter().skip(offset).take(limit).collect(), total)+}++/// `fusion_electronics_query {entity, fields?, filters?, limit?, offset?, source?: auto|mcp|board}`.+pub fn query(ctx: &Ctx, args: &Value, version: &str, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let Some(entity) = args["entity"].as_str().or(args["entity_type"].as_str()).and_then(canonical) else {+        return json!({"success": false, "errorCode": "unknown_entity", "error": format!("entity must be one of Autodesk's electronics classes, got {}", fusion_lbr::py_repr_str(args["entity"].as_str().unwrap_or(""))), "entities": ENTITIES, "boardFallbackEntities": BOARD_ENTITIES, "_hint": "Pass entity:'Element' (board placements), 'Signal' (board nets), 'Net' (schematic nets), 'Layer', 'Error' (DRC/ERC), 'Via', 'Smd', 'Wire', 'Part', 'Instance', 'Package', 'Library'... The full list is `entities`."});+    };+    let source = args["source"].as_str().unwrap_or("auto");+    let _ = ctx;+    if source != "board" {+        let sess = crate::mcp::new_session(version);+        if sess["error"].is_null() {+            let mut object = json!({});+            if let Some(f) = args["fields"].as_array() { object["fields"] = json!(f); }+            if let Some(f) = args["filters"].as_array() { object["filters"] = json!(f); }+            object["pagination"] = json!({"limit": args["limit"].as_u64().unwrap_or(100), "offset": args["offset"].as_u64().unwrap_or(0)});+            let r = crate::mcp::rpc(version, "tools/call", Some(json!({"name": "fusion_mcp_electronics_read", "arguments": {"entity_type": format!("electronics.{entity}"), "object": object}})), args["timeout"].as_u64().unwrap_or(120));+            if r["_mcpError"].is_null() {+                let res = &r["result"];+                let text = res["content"].as_array().and_then(|c| c.iter().find_map(|x| x["text"].as_str())).unwrap_or("").to_string();+                let parsed: Value = serde_json::from_str(&text).unwrap_or(Value::Null);+                if res["isError"].as_bool() == Some(true) || parsed["success"].as_bool() == Some(false) {+                    let err = parsed["error"].as_str().map(str::to_string).unwrap_or_else(|| text.chars().take(300).collect());+                    if source == "mcp" || !BOARD_ENTITIES.contains(&entity) { return json!({"success": false, "source": "mcp", "entity": entity, "error": err, "_hint": "Autodesk's electronics_read needs an ACTIVE electronics document (fusion_show_2d_board / fusion_show_schematic first). Some classes exist only in one editor: board classes (Element, Signal, Via, Smd, Wire, Error) in the board, schematic classes (Part, Instance, Net, Sheet, Segment) in the schematic. resource://mcp.electronics_entity_types (fusion_mcp_resources) lists the workspace each wants."}); }+                } else {+                    let rows = parsed["rows"].as_array().or(parsed["data"].as_array()).or(parsed["results"].as_array()).or(parsed["items"].as_array()).cloned().or_else(|| parsed.as_array().cloned()).unwrap_or_default();+                    let total = parsed["total"].as_u64().or(parsed["count"].as_u64()).map(|n| n as usize).unwrap_or(rows.len());+                    let unit = parsed["coordinate_unit"].as_str().unwrap_or("document grid units");+                    return json!({"success": true, "source": "mcp", "entity": entity, "count": rows.len(), "total": total, "hasMore": parsed["pagination"]["hasMore"], "coordinateUnit": unit, "rows": rows, "units": format!("Autodesk MCP: x/y in {unit} (coordinate_unit of the active document), angles in degrees; per-class fields in resource://mcp.electronics_schema_<class>"),+                        "raw": if rows.is_empty() { parsed } else { Value::Null },+                        "_hint": "Typed rows from Autodesk's electronics_read over the ACTIVE electronics document. Filter with filters:[{property, op:'eq'|'lt'|'gt', value}] (strings: eq only), project with fields:[...], page with limit/offset. When the MCP server is off this verb falls back to the board_info XML for Board/Element/Library/Package/Layer/Signal/Wire/Error (source:'board', coordinates in mm)."});+                }+            } else if source == "mcp" { return json!({"success": false, "source": "mcp", "entity": entity, "error": r["_mcpError"], "_hint": crate::mcp::off_hint((false, false))}); }+        } else if source == "mcp" { return json!({"success": false, "source": "mcp", "entity": entity, "error": sess["error"], "_hint": crate::mcp::off_hint((false, false))}); }+    }+    if !BOARD_ENTITIES.contains(&entity) {+        return json!({"success": false, "errorCode": "needs_mcp", "source": "board", "entity": entity, "error": format!("{entity} is only readable through Autodesk's MCP server, which is not answering."), "boardFallbackEntities": BOARD_ENTITIES, "_hint": "fusion_mcp_enable turns the server on in ~10s (needs Fusion running); then re-run. The board_info fallback covers Board, Element, Library, Package, Layer, Signal, Wire and Error."});+    }+    let bi = pass("board_info", json!({}));+    if bi["success"].as_bool() != Some(true) { return json!({"success": false, "source": "board", "entity": entity, "error": bi["error"], "_hint": bi["_hint"].as_str().unwrap_or("Open a board first: fusion_show_2d_board (or fusion_open_board {filePath}), then retry.")}); }+    match rows_from_board_info(entity, &bi["data"]) {+        Ok(rows) => { let (page, total) = apply_query(rows, args); json!({"success": true, "source": "board", "entity": entity, "count": page.len(), "total": total, "rows": page, "units": "board_info: coordinates in the board's unit (mm), rotations as Eagle strings (R0, MR90) with `angle` in degrees",+            "_hint": "Rows from the board's own geometry export (Autodesk's MCP server was off or not asked). Element = placements with package and library, Wire = copper shapes per layer, Layer = layers that carry geometry, Error = DRC rows, Signal = board nets. For the schematic side (Part, Instance, Net, Pin, Sheet) and for pads, vias, attributes and variants, turn the MCP server on: fusion_mcp_enable."}) }+        Err(e) => json!({"success": false, "source": "board", "entity": entity, "error": e}),+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn board_fallback_reads_the_sample() {+        let d: Value = serde_json::from_str(include_str!("../tests/fixtures/board_info_sample.json")).unwrap();+        let el = rows_from_board_info("Element", &d["data"]).unwrap();+        assert_eq!(el.len(), 1); assert_eq!(el[0]["name"], "U1"); assert_eq!(el[0]["package"], "ADS8588SIPM"); assert_eq!(el[0]["x"], json!(9.476)); assert_eq!(el[0]["angle"], json!(0.0));+        let b = rows_from_board_info("Board", &d["data"]).unwrap(); assert_eq!(b[0]["boardThickness"], json!(1.5)); assert_eq!(b[0]["layerSetup"], "(1*16)");+        let p = rows_from_board_info("Package", &d["data"]).unwrap(); assert_eq!(p[0]["name"], "ADS8588SIPM"); assert!(p[0]["package3dUrn"].as_str().unwrap().starts_with("urn:adsk"));+        let l = rows_from_board_info("Layer", &d["data"]).unwrap(); assert_eq!(l.iter().map(|r| r["number"].as_i64().unwrap()).collect::<Vec<_>>(), vec![1, 29]);+        let w = rows_from_board_info("Wire", &d["data"]).unwrap(); assert_eq!(w.len(), 68); assert_eq!(w[0]["layer"], 1); assert_eq!(w[0]["points"].as_array().unwrap().len(), 4);+        let (page, total) = apply_query(rows_from_board_info("Wire", &d["data"]).unwrap(), &json!({"filters": [{"property": "layer", "op": "eq", "value": 29}], "limit": 3, "fields": ["id", "layer"]}));+        assert!(total > 0 && total < 68); assert_eq!(page.len(), 3); assert!(page[0]["points"].is_null());+        assert!(canonical("electronics.element").is_some() && canonical("VIA") == Some("Via") && canonical("nope").is_none());+    }+}
rust/bridge/src/main.rs+3−2
@@ -37,6 +37,7 @@ mod aps_signin; mod demo; mod addin_install; mod janitor;+mod electronics; use fusion_core::ab::Caller; use fusion_core::{HostPaths, HostProcess}; use serde_json::{json, Value};@@ -391,10 +392,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") 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") 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"]),-                            "demo_progress" => demo::demo_progress(&ctx.state_dir, &parsed["args"]), "demo_control" => demo::demo_control(&parsed["args"]),+                            "electronics_query" => electronics::query(&ctx, &parsed["args"], VERSION, &pass), "demo_progress" => demo::demo_progress(&ctx.state_dir, &parsed["args"]), "demo_control" => demo::demo_control(&parsed["args"]),                             "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) }                                 else { let readiness = || owned_readiness(&**h, &sidecar, &caller); let aps_state = || native_aps_status(&sidecar, &caller)["data"].clone(); let aps_search = |a: &Value| native_aps_search(a); let start = || launch::start(&ctx, &json!({})); let aps_pair = || { 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})); let signin_fn = |a: &Value| signin::signin(&ctx, a, &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_pair, aps_signin: &aps_signin }); demo::demo(&ctx, &parsed["args"], &demo::DemoEnv { readiness: &readiness, signin: &signin_fn, aps_state: &aps_state, aps_search: &aps_search }, &pass) }, "web_signin" => signin::web_signin(&ctx, &parsed["args"]),                             "library_manager" => managed::library_manager(&ctx, &parsed["args"], &pass), "managed_library_create" => managed::create(&ctx, &parsed["args"], &pass), "managed_library_link" => managed::link(&ctx, &parsed["args"], &pass), "managed_library_pull" => managed::pull(&ctx, &parsed["args"], &pass), "managed_library_push" => managed::push(&ctx, &parsed["args"], &pass), "managed_library_unlink" => managed::unlink(&ctx, &parsed["args"], &pass),
skills/fusion-electronics/SKILL.md+24
@@ -130,3 +130,27 @@ fusion_set_design_rules {}                                       // the bundled   `.edru` for that tier (the vendor publishes no limits for it). - Missing vendor or wrong limit? Fix it on the wiki page (`rules/records/<vendor>.json`, then   `python3 tools/generate.py`), not in the bridge.+++## The typed read model: `fusion_electronics_query` (1.9.359+)++One verb, one row shape, over the ACTIVE electronics document:++```jsonc+fusion_electronics_query {"entity":"Element","fields":["name","x","y","angle"]}+fusion_electronics_query {"entity":"Signal","filters":[{"property":"name","op":"eq","value":"GND"}]}+fusion_electronics_query {"entity":"Layer","filters":[{"property":"number","op":"lt","value":20}],"limit":50}+```++- `entity` is any of Autodesk's 49 electronics classes (Element, Signal, Net, Layer, Via, Smd, Pad,+  Wire, Part, Instance, Pin, Sheet, Package, Package3D, Library, Error, Variant, NetClass...), with+  or without the `electronics.` prefix; an unknown name answers the full list.+- `source: "mcp"` is Autodesk's own `electronics_read` (all classes, `coordinateUnit` per document,+  `hasMore` paging). It needs the Fusion MCP server on: `fusion_mcp_enable` (about 10 s, Fusion+  running). Board classes want the board in front, schematic classes the schematic.+- `source: "board"` is the fallback when the server is off: Board, Element, Library, Package, Layer,+  Signal, Wire and Error from the `board_info` geometry export, coordinates in mm.+- `source: "auto"` (default) tries the server first and falls back for the eight board classes.++Filters are `{property, op: eq|lt|gt, value}` (strings: `eq` only); `fields` projects; `limit`/`offset`+page. This replaces reading `fusion_board_info` XML by hand.