← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.55\ No newline at end of file+1.13.56\ No newline at end of file
CHANGELOG.md+10
@@ -3,6 +3,16 @@ 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.56 (2026-11-02)++The rulings on standard #29:+- `fusion_preferences` takes `key` (one setting). `fusion_select_items` takes `where` (board or sheet), refused when the other+  editor is in front. `fusion_cancel_run` with nothing running answers `cancelRequested: false` and a reason, not an error.+- New, the standard's 0.31 verbs: `fusion_sch_label`, `fusion_sch_wire`, `fusion_place_parts` (a board with no schematic), `fusion_edit_tracks`+  (routing_state's tracks now carry an id), `fusion_model_check` (does each part's cloud 3D model open), `fusion_extensions` (list).+  `sch_no_connect` answers not_supported with the reason: Fusion has no such marker.+- `license`: `use_license` takes the seat here, `release` gives it up, anything else reads the account. `state` takes `screenshots`.+ ## 1.13.55 (2026-11-02)  `fusion_sch_swap_part` keeps the designator when the new device's prefix differs (see 1.13.54), with a regression test on the command form.
bridge.json+7−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.55",+  "version": "1.13.56",   "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.",@@ -113,6 +113,7 @@     "fusion_edit_footprint",     "fusion_edit_parts",     "fusion_edit_pours",+    "fusion_edit_tracks",     "fusion_electron_pan",     "fusion_electron_run",     "fusion_electron_select",@@ -152,6 +153,7 @@     "fusion_export_step",     "fusion_export_stl",     "fusion_export_usdz",+    "fusion_extensions",     "fusion_fab_check",     "fusion_fetch_optimized_glb",     "fusion_find_space",@@ -215,6 +217,7 @@     "fusion_mech_capabilities",     "fusion_mech_operation_status",     "fusion_mechanical",+    "fusion_model_check",     "fusion_move_parts",     "fusion_narrate",     "fusion_net_classes",@@ -240,6 +243,7 @@     "fusion_physical_properties",     "fusion_pin_pad_check",     "fusion_place_decoupling",+    "fusion_place_parts",     "fusion_place_vias",     "fusion_placement_check",     "fusion_plan_fanout",@@ -312,6 +316,7 @@     "fusion_sch_hierarchy",     "fusion_sch_items",     "fusion_sch_junction",+    "fusion_sch_label",     "fusion_sch_move_region",     "fusion_sch_page",     "fusion_sch_pins",@@ -324,6 +329,7 @@     "fusion_sch_trace_point",     "fusion_sch_update_from_library",     "fusion_sch_update_from_library",+    "fusion_sch_wire",     "fusion_sch_zoom",     "fusion_schematic",     "fusion_schematic_data",
contracts/describe.json+155−1
@@ -1079,6 +1079,7 @@       "name": "fusion_cancel_run",       "output": {         "cancelRequested": "bool",+        "reason": "no_run_in_flight when false",         "runId": "str",         "stage": "str at the time of the request",         "statusVerb": "fusion_prove_result"@@ -1991,6 +1992,26 @@       "summary": "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",       "timeoutSeconds": 120     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "expectDocument": "the board",+        "expectedRevision": "optional",+        "tracks": "[{id, width?, layer?, start?, end?}] mm (required)"+      },+      "longRunning": false,+      "name": "fusion_edit_tracks",+      "output": {+        "changes": "[{id, before, after}]",+        "results": "[ok|err:...]",+        "revision": "str",+        "verified": "bool"+      },+      "statusVerb": "fusion_routing_state",+      "summary": "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. Change a track's layer and its ends in two calls.",+      "timeoutSeconds": 120+    },     {       "example": {         "x1": 0,@@ -2806,6 +2827,21 @@       "summary": "Export to USDZ (AR / Hydrogen 3D).",       "timeoutSeconds": 300     },+    {+      "example": {},+      "input": {+        "action": "list|install|remove, default list"+      },+      "longRunning": false,+      "name": "fusion_extensions",+      "output": {+        "count": "int",+        "extensions": "[{id, name, version, kind}]"+      },+      "statusVerb": "fusion_get_app_state",+      "summary": "The Fusion add-ins and scripts installed for this user (the standard's extensions). Install and remove are refused: Fusion has no API for them.",+      "timeoutSeconds": 30+    },     {       "example": {         "vendor": "jlcpcb"@@ -19814,6 +19850,25 @@       "summary": "Guarded main-thread parametric parts and assemblies: sketches, solid features, atomic parameter edits, component definitions/copies/placement, seven as-built joint motions and limits, rigid groups, interference, materials and assembly mass/center of mass, local supplier STEP/SAT import with SHA/provenance, native McMaster command, structured/flat BOM CSV, topology readback, health and safe exports. Geometry queries refuse ambiguous selections; root-space distance/angle measurements return witness points. Typed sketch trim/extend/split/break and signed modern offsets, reference-defined construction axes and native sketch constraint diagnostics are available through exact operation schemas. Regular geometry-solving joints, joint origins and native motion links have guarded typed operations, exact attachment/parameter readback and timeline restoration. Read-only configuration inspection and active-row BOM provenance are available; configuration lifecycle and configured-design model writes remain disabled pending native recovery/archive gates. Incomplete configuration scope also blocks complete BOM output. Typed model mutations use runtime-proven PTransaction recovery; transaction_probe verifies the backend (new_design does this automatically). Writes require sessionId,operationId and current target {documentId,revision}. Durable admission must commit before dispatch. Retry exact args with the same ID; query durable status after timeout. Old-session history is readable but never replayed. Storage failure after execution returns reconciliation-required, preserving available evidence without claiming rollback. References expire on model changes. Files/document lifecycle are outside model transactions.",       "timeoutSeconds": 300     },+    {+      "example": {},+      "input": {+        "expectDocument": "the board",+        "footprint": "one footprint",+        "library": "a .lbr (with footprint), default the open board",+        "limit": "default 50"+      },+      "longRunning": false,+      "name": "fusion_model_check",+      "output": {+        "checked": "int",+        "ok": "bool",+        "parts": "[{name, models: [{path, location, found, hasGeometry}], issues}]"+      },+      "statusVerb": "fusion_routing_state",+      "summary": "Per part, is there a 3D model and does its cloud file open for this account (the standard's model_check), for a board or a .lbr footprint. Fusion can't say whether one holds geometry without opening it, so hasGeometry is null. Read only.",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {@@ -20317,6 +20372,36 @@       "summary": "Plan decoupling capacitors beside an IC's power pins (the standard's place_decoupling), from the shared checks engine: each cap gapMm outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin, slid along that side when the spot is taken; the plan is checked with placement_check. A plan: moveArgs is fusion_move_parts' arguments, boardId and expectedRevision included.",       "timeoutSeconds": 150     },+    {+      "example": {+        "dryRun": true,+        "parts": [+          {+            "footprint": "R0603",+            "library": "C:/lib/parts.lbr",+            "ref": "R1",+            "x": 10,+            "y": 10+          }+        ]+      },+      "input": {+        "dryRun": "bool",+        "expectDocument": "the board",+        "expectedRevision": "optional",+        "parts": "[{library, footprint, ref?, value?, x, y, rotation?, side?}] mm (required)"+      },+      "longRunning": false,+      "name": "fusion_place_parts",+      "output": {+        "placed": "[{ref, x, y, rotation, side}]",+        "results": "[ok|err:...]",+        "revision": "str"+      },+      "statusVerb": "fusion_routing_state",+      "summary": "Footprints from a .lbr onto a board with no schematic (the standard's place_parts), with position, rotation and side, read back. A board linked to a schematic is refused: Fusion takes its parts from the sheet (fusion_sch_place_part, then fusion_move_parts).",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {@@ -20518,6 +20603,7 @@         }       },       "input": {+        "key": "optional: one setting",         "set": "optional {key: value}: the keys fusion_set_preference takes"       },       "longRunning": false,@@ -22064,6 +22150,36 @@       "summary": "Add junction dots at points, or remove dots by id or point. A point no wire passes is skipped; one where fewer than three items meet is listed as notNeeded and left (force:true adds it anyway). Read back, or undone.",       "timeoutSeconds": 120     },+    {+      "example": {+        "at": [+          40.64,+          132.08+        ],+        "text": "VIN"+      },+      "input": {+        "at": "[x, y] mm, a point on the wire",+        "dryRun": "bool",+        "expectDocument": "optional",+        "id": "a label id from sch_items (with remove)",+        "kind": "local|global",+        "remove": "bool",+        "rotation": "0|90|180|270",+        "sheet": "int",+        "text": "the net name (required to add)"+      },+      "longRunning": false,+      "name": "fusion_sch_label",+      "output": {+        "label": "{id, text, kind, at, rotation}",+        "removed": "[id]",+        "saved": "false"+      },+      "statusVerb": "fusion_get_app_state",+      "summary": "A net label on an existing wire at a point, or delete one (the standard's sch_label). A label shows its wire's net, so text must name that net (renaming is fusion_sch_edit_label). kind local or global (an XREF label); Fusion has no hierarchical label. Read back; a mismatch undoes it.",+      "timeoutSeconds": 60+    },     {       "example": {         "box": [@@ -22382,6 +22498,43 @@       "summary": "Compare placed parts with their library (the standard's sch_update_from_library), the plan: each part's component as the schematic holds it (Fusion keeps a copy of every library a design uses) against the same-named deviceset in the .lbr files named: gates added or removed, each symbol's pins added, removed or changed (position, length, direction, turn), its drawing, and the device's attributes (MPN ...). Each part is up_to_date, differs or library_not_found; droppedWiredPins names pins the library's symbol drops that are wired on the sheet. apply is refused with the plan, because Fusion's UPDATE asks about each part in a dialog.",       "timeoutSeconds": 60     },+    {+      "example": {+        "points": [+          [+            20,+            100+          ],+          [+            40,+            100+          ],+          [+            40,+            120+          ]+        ]+      },+      "input": {+        "dryRun": "bool",+        "expectDocument": "optional",+        "junctions": "bool, default true",+        "net": "optional name",+        "points": "[[x, y], ...] mm, two or more (required)",+        "sheet": "int"+      },+      "longRunning": false,+      "name": "fusion_sch_wire",+      "output": {+        "junctionsAdded": "[{id, at}]",+        "net": "str",+        "saved": "false",+        "wires": "[{id, from, to}]"+      },+      "statusVerb": "fusion_get_app_state",+      "summary": "A wire through given points, mm (the standard's sch_wire). Fusion names a wire's net: net names it, else it takes the net it starts on, else a free name. junctions (default true) adds a junction where it ends on another wire. Read back; a mismatch undoes it.",+      "timeoutSeconds": 60+    },     {       "example": {         "refs": [@@ -22568,7 +22721,8 @@       },       "input": {         "clear": "bool",-        "refs": "[designator]"+        "refs": "[designator]",+        "where": "board|sheet: refused when the other editor is in front"       },       "longRunning": false,       "name": "fusion_select_items",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.55"+version = "1.13.56" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.55"+version = "1.13.56" edition = "2021"  [dependencies]
describe.py+20−3
@@ -669,9 +669,26 @@ _T = [      {"expectDocument":"the design (default the one in front)","execute":"bool, default false","allowRemovals":"bool, default false"},      {"executed":"bool","changeOrder":"[] (nothing to run)","preview":"{addParts, removeParts, sheetParts, boardParts}"},60,"fusion_get_app_state",False,{}),     ("fusion_preferences", "Read Fusion's own settings (theme, scroll direction, orbit scheme, orientation, length unit), or change them with set, live with no restart. The standard's preferences: fusion_get_preferences and fusion_set_preference as one verb.",-     {"set":"optional {key: value}: the keys fusion_set_preference takes"},{"current":"obj","applied":"obj (with set)"},30,"fusion_get_app_state",False,{"set":{"lengthUnit":"mm"}}),+     {"key":"optional: one setting","set":"optional {key: value}: the keys fusion_set_preference takes"},{"current":"obj","applied":"obj (with set)"},30,"fusion_get_app_state",False,{"set":{"lengthUnit":"mm"}}),     ("fusion_select_items", "Select parts in the open schematic or board by designator, or clear the selection (the standard's select_items). Fusion does not report its electronics selection back, so the reply cannot verify it (verified:false): look at the view.",-     {"refs":"[designator]","clear":"bool"},{"selected":"[ref]","failed":"[{ref, error}]","verified":"false"},30,"fusion_get_app_state",False,{"refs":["R1","R2"]}),+     {"refs":"[designator]","clear":"bool","where":"board|sheet: refused when the other editor is in front"},{"selected":"[ref]","failed":"[{ref, error}]","verified":"false"},30,"fusion_get_app_state",False,{"refs":["R1","R2"]}),+    ("fusion_sch_label", "A net label on an existing wire at a point, or delete one (the standard's sch_label). A label shows its wire's net, so text must name that net (renaming is fusion_sch_edit_label). kind local or global (an XREF label); Fusion has no hierarchical label. Read back; a mismatch undoes it.",+     {"text":"the net name (required to add)","at":"[x, y] mm, a point on the wire","kind":"local|global","rotation":"0|90|180|270","remove":"bool","id":"a label id from sch_items (with remove)","dryRun":"bool","expectDocument":"optional","sheet":"int"},+     {"label":"{id, text, kind, at, rotation}","removed":"[id]","saved":"false"},60,"fusion_get_app_state",False,{"text":"VIN","at":[40.64,132.08]}),+    ("fusion_sch_wire", "A wire through given points, mm (the standard's sch_wire). Fusion names a wire's net: net names it, else it takes the net it starts on, else a free name. junctions (default true) adds a junction where it ends on another wire. Read back; a mismatch undoes it.",+     {"points":"[[x, y], ...] mm, two or more (required)","net":"optional name","junctions":"bool, default true","dryRun":"bool","expectDocument":"optional","sheet":"int"},+     {"wires":"[{id, from, to}]","junctionsAdded":"[{id, at}]","net":"str","saved":"false"},60,"fusion_get_app_state",False,{"points":[[20,100],[40,100],[40,120]]}),+    ("fusion_place_parts", "Footprints from a .lbr onto a board with no schematic (the standard's place_parts), with position, rotation and side, read back. A board linked to a schematic is refused: Fusion takes its parts from the sheet (fusion_sch_place_part, then fusion_move_parts).",+     {"parts":"[{library, footprint, ref?, value?, x, y, rotation?, side?}] mm (required)","dryRun":"bool","expectDocument":"the board","expectedRevision":"optional"},+     {"results":"[ok|err:...]","placed":"[{ref, x, y, rotation, side}]","revision":"str"},120,"fusion_routing_state",False,{"parts":[{"library":"C:/lib/parts.lbr","footprint":"R0603","ref":"R1","x":10,"y":10}],"dryRun":True}),+    ("fusion_edit_tracks", "Change given tracks by the id routing_state returns: width, layer, ends (the standard's edit_tracks). Read back: only those tracks may differ, or every change is undone. Change a track's layer and its ends in two calls.",+     {"tracks":"[{id, width?, layer?, start?, end?}] mm (required)","dryRun":"bool","expectDocument":"the board","expectedRevision":"optional"},+     {"results":"[ok|err:...]","changes":"[{id, before, after}]","verified":"bool","revision":"str"},120,"fusion_routing_state",False,{}),+    ("fusion_model_check", "Per part, is there a 3D model and does its cloud file open for this account (the standard's model_check), for a board or a .lbr footprint. Fusion can't say whether one holds geometry without opening it, so hasGeometry is null. Read only.",+     {"library":"a .lbr (with footprint), default the open board","footprint":"one footprint","expectDocument":"the board","limit":"default 50"},+     {"parts":"[{name, models: [{path, location, found, hasGeometry}], issues}]","checked":"int","ok":"bool"},120,"fusion_routing_state",False,{}),+    ("fusion_extensions", "The Fusion add-ins and scripts installed for this user (the standard's extensions). Install and remove are refused: Fusion has no API for them.",+     {"action":"list|install|remove, default list"},{"extensions":"[{id, name, version, kind}]","count":"int"},30,"fusion_get_app_state",False,{}),     ("fusion_board_bom", "The bill of materials from the live board's own part attributes: one line per MPN with designators, quantity, value, package, manufacturer and the Mouser, DigiKey and LCSC numbers, plus the JLCPCB form (Comment, Designator, Footprint, LCSC). Board-only parts (test pads) are listed as such, and any part missing a distributor number is named. Read-only; with outputDir it also writes bom.csv and bom-jlcpcb.csv there.",      {"expectDocument":"required exact board name","outputDir":"optional folder on this machine for bom.csv + bom-jlcpcb.csv"},      {"lines":"[{designators,quantity,value,package,mpn,manufacturer,mouser,digikey,lcsc}]","csv":"str","jlcpcbCsv":"str","files":"{name:path}","boardOnly":"[str]","missingPartNumbers":"{mouser,digikey,lcsc:[str]}"},60,"fusion_routing_state",False,{}),@@ -1011,7 +1028,7 @@ _T = [      20, None, True, {"runId": "a3f1c2d4e5b6"}),     ("fusion_cancel_run", "STOP a library import that is in flight (fusion_install_library_bundle / fusion_create_library) - the user's escape hatch from a long run, wired to a Cancel button in web control. Cooperative, not a kill: the run finishes the part it is on (a STEP import cannot be interrupted mid-command without leaving a half-made design, up to ~60s), keeps every 3D package already minted and binds them into the library, closes its scratch documents, and leaves the library open in Fusion (not saved to the cloud). The run then reports stage:cancelled, verdict:cancelled, errorCode cancelled_by_user, cancelledAt (which stage) and how many parts were done, on the same fusion_prove_result poll the caller already makes; cancelRequested:true shows on the very next poll before the boundary is reached. Omit runId to cancel the latest run still in flight. Idempotent; a run that already ended answers alreadyFinished.",      {"runId": "optional str (omit for the latest run in flight)"},-     {"cancelRequested": "bool", "runId": "str", "stage": "str at the time of the request", "statusVerb": "fusion_prove_result"},+     {"cancelRequested": "bool", "reason": "no_run_in_flight when false", "runId": "str", "stage": "str at the time of the request", "statusVerb": "fusion_prove_result"},      10, "fusion_prove_result", True, {"runId": "a3f1c2d4e5b6"}),     ("fusion_addin_status", "Is the AdomBridge add-in loaded and answering inside Fusion? Reports addinReady, port and version, plus busy when a long command holds the main thread. If Fusion is running but this says not ready, the add-in was installed while Fusion was open - Fusion loads add-ins only at launch - so call fusion_restart_for_addin (ask the user first when one is present).",      {}, {"addinReady": "bool", "port": "int", "version": "str", "busy": "bool"}, 15, None, True, {}),
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.55"+DASH_VERSION="1.13.56" 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.55",+  "version": "1.13.56",   "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.55",+  "version": "1.13.56",   "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"   ]-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.55"+version = "1.13.56" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.55"+version = "1.13.56" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.55"+version = "1.13.56" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.55"+version = "1.13.56" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.55"+version = "1.13.56" 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.55"+version = "1.13.56" edition = "2021" license = "MIT" 
rust/bridge/src/board_model.py+3−1
@@ -531,7 +531,9 @@ def board_model(board, meta=None):             arcs.append({'center': {'x': _r(center[0]), 'y': _r(center[1])}, 'radius': _r(r), 'startAngle': a1,                          'endAngle': a2, 'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})         else:-            tracks.append({'start': {'x': _r(p1[0]), 'y': _r(p1[1])}, 'end': {'x': _r(p2[0]), 'y': _r(p2[1])},+            # the handle edit_tracks takes: Fusion stores no ids, so it is the track's own net, layer and ends+            tracks.append({'id': 'track:%s:%d:%g,%g,%g,%g' % (net, layer, _r(p1[0]), _r(p1[1]), _r(p2[0]), _r(p2[1])),+                           'start': {'x': _r(p1[0]), 'y': _r(p1[1])}, 'end': {'x': _r(p2[0]), 'y': _r(p2[1])},                            'width': _f(w, 'width'), 'layer': layers.name(layer), 'layerId': layer, 'net': net})      def pour(p, net):
rust/bridge/src/fusion_prefs.rs+11
@@ -56,6 +56,17 @@ pub fn get_preferences(call: &dyn Fn(&str, Value, u64) -> Value) -> Value {     shape(&run_pref_script(script, 20, call), false) } +/// The standard's preferences read: all of them, or one by key (the key names current lists).+pub fn get_preference_key(args: &Value, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {+    let mut r = get_preferences(call);+    let Some(key) = args["key"].as_str().filter(|k| !k.is_empty()) else { return r };+    let Some(cur) = r["current"].as_object().cloned() else { return r };+    match cur.get(key) {+        Some(v) => { r["current"] = json!({key: v}); r }+        None => json!({"success": false, "errorCode": "unknown_key", "error": format!("Fusion has no setting {key}"), "keys": cur.keys().collect::<Vec<_>>()}),+    }+}+ pub fn set_preference(args: &Value, call: &dyn Fn(&str, Value, u64) -> Value) -> Value {     let reqs: Map<String, Value> = args.as_object().map(|o| o.iter().filter(|(k, _)| k.as_str() != "settle").map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default();     if reqs.is_empty() {
rust/bridge/src/main.rs+12−3
@@ -121,9 +121,18 @@ fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) {         "preferences" => {             match a["set"].as_object().filter(|m| !m.is_empty()).cloned() {                 Some(m) => { parsed["args"] = Value::Object(m); parsed["command"] = json!("fusion_set_preference"); ("fusion_set_preference".to_string(), parsed) }-                None => { parsed["args"] = json!({}); parsed["command"] = json!("fusion_get_preferences"); ("fusion_get_preferences".to_string(), parsed) }+                // key is applied to the reply, in the dispatch's post step (see preferences_key)+                None => { parsed["args"] = json!({"key": a["key"]}); parsed["command"] = json!("fusion_get_preferences"); ("fusion_get_preferences".to_string(), parsed) }             }         }+        // state's screenshots is the standard's name for this bridge's screenshot flag+        "state" if !a["screenshots"].is_null() => { parsed["args"]["screenshot"] = a["screenshots"].clone(); (verb.to_string(), parsed) }+        // the seat, by the standard's license actions: use_license takes it here, release gives it up, and+        // anything else (or nothing) reads the account and licence+        "license" => {+            let v = match a["action"].as_str().unwrap_or("") { "use_license" | "claim" => "fusion_claim_seat", "release" => "fusion_release_seat", _ => "fusion_account" };+            parsed["command"] = json!(v); (v.to_string(), parsed)+        }         // the shared writer for the families it makes (no Fusion needed); Fusion's generator for the rest         "generate_body" if body::engine_makes(&a) => ("fusion_generate_body_engine".into(), parsed),         "generate_body" => {@@ -574,7 +583,7 @@ fn main() {                         r["shell"] = json!("rust"); r },                     "fusion_start" | "start" | "launch" 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 sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_open_lbr" | "open_lbr" 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 t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },-                    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_library_views" | "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_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_render_board_layers" | "render_board_layers" | "fusion_bom" | "bom" | "fusion_export_manufacturing" | "export_manufacturing" | "fusion_update_pcb" | "update_pcb" | "fusion_select_items" | "select_items" | "fusion_pcb_report" | "pcb_report" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") 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), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "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), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "render_board_layers" => stdshapes::render_board_layers(&parsed["args"], &pass), "bom" => stdshapes::bom(&parsed["args"], &pass), "export_manufacturing" => stdshapes::export_manufacturing(&parsed["args"], &pass), "pcb_report" => stdshapes::pcb_report(&parsed["args"], &pass), "update_pcb" => stdshapes::update_pcb(&parsed["args"], &pass), "select_items" => stdshapes::select_items(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&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), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_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), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&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); small_verbs::add_image_delivery(&mut r); 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 },+                    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_library_views" | "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_export_3d" | "export_3d" | "fusion_board_summary" | "board_summary" | "fusion_read_schematic" | "read_schematic" | "fusion_export_pos" | "export_pos" | "fusion_render_board_layers" | "render_board_layers" | "fusion_bom" | "bom" | "fusion_export_manufacturing" | "export_manufacturing" | "fusion_model_check" | "model_check" | "fusion_extensions" | "extensions" | "fusion_update_pcb" | "update_pcb" | "fusion_select_items" | "select_items" | "fusion_pcb_report" | "pcb_report" | "fusion_import_step" | "import_step" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_project_create" | "project_create" | "fusion_test_board" | "test_board" | "fusion_build_board" | "build_board" | "fusion_swap_footprint" | "swap_footprint" | "fusion_panelize" | "panelize" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") 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), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "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), "board_summary" => stdshapes::board_summary(&parsed["args"], &pass), "read_schematic" => stdshapes::read_schematic(&parsed["args"], &pass), "export_pos" => stdshapes::export_pos(&parsed["args"], &pass), "render_board_layers" => stdshapes::render_board_layers(&parsed["args"], &pass), "bom" => stdshapes::bom(&parsed["args"], &pass), "export_manufacturing" => stdshapes::export_manufacturing(&parsed["args"], &pass), "pcb_report" => stdshapes::pcb_report(&parsed["args"], &pass), "update_pcb" => stdshapes::update_pcb(&parsed["args"], &pass), "model_check" => stdshapes::model_check(&parsed["args"], &pass), "extensions" => stdshapes::extensions(&parsed["args"]), "select_items" => stdshapes::select_items(&parsed["args"], &pass), "export_3d" => services::export_3d(&parsed["args"], &pass), "import_step" => if parsed["args"]["into"].as_str() == Some("new") { pass("import_step", parsed["args"].clone()) } else { services::import_step_with_board(&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), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&ctx, &parsed["args"], &pass), &caller, &pids), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "swap_footprint" => small_verbs::merge_dialog_array(boardgen::swap_footprint(&ctx, &parsed["args"], &pass), &caller, &pids), "panelize" => small_verbs::merge_dialog_array(boardgen::panelize(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_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), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&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); small_verbs::add_image_delivery(&mut r); 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" | "fusion_import_part" | "import_part") 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), "import_part" => partimport::import_part(&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_tool_update" | "tool_update" | "fusion_window_info" | "window_info" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") 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), "tool_update" => maintenance::tool_update(&ctx, &parsed["args"], &pass), "window_info" => stdshapes::window_info(&ctx), "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),@@ -648,7 +657,7 @@ fn main() {                         let mut r = match cmd { "stop" => lifecycle::stop(&ctx, &parsed["args"]), "kill" => lifecycle::kill(&ctx, &parsed["args"]), _ => lifecycle::restart_for_addin(&ctx, &parsed["args"], &|verb, a| addin::pass_through(verb, &a, &ctx)) };                         timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false));                         r["shell"] = json!("rust"); r },-                    "fusion_get_preferences" | "get_preferences" 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 sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let _ = &ctx; let mut r = fusion_prefs::get_preferences(&|v, a, t| addin::addin_http(v, &a, t.max(20))); r["shell"] = json!("rust"); r },+                    "fusion_get_preferences" | "get_preferences" 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 sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let _ = &ctx; let mut r = fusion_prefs::get_preference_key(&parsed["args"], &|v, a, t| addin::addin_http(v, &a, t.max(20))); r["shell"] = json!("rust"); r },                     "fusion_set_preference" | "set_preference" 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 sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::set_preference(&parsed["args"], &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },                     "fusion_reset_workspace" | "reset_workspace" 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 sd = h.bridge_state_dir(); 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: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = fusion_prefs::reset_workspace(&sd, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx), &|v, a, _t| addin::pass_through(v, &a, &ctx)); r["shell"] = json!("rust"); r },                     "fusion_describe" | "describe" => {
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"), "\n", include_str!("lbr_use.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" | "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" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule" | "read_board") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "edit_tracks" | "place_parts" | "routing_state" | "route_net" | "routing_drc" | "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" | "diff_pairs" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count" | "board_info_compat" | "footprint_primitives" | "place_planned_vias" | "place_teardrops" | "board_features" | "add_rule" | "read_board") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let mut args_v = args.clone();     if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }
rust/bridge/src/routing_script.py+127
@@ -1172,6 +1172,131 @@ def _wire_key(x):     return (x['net'], x['layer'], min(a, b), max(a, b), round(x['curve'], 3))  +def edit_tracks(app, root, board, meta, args):+    """The standard's edit_tracks: given tracks by their routing_state handle: width, layer, ends. Each is picked+    with only its layer displayed, at the point farthest from other copper. An end moves with MOVE at that end.+    Read back: only the asked tracks may differ, and only as asked, or every change is undone."""+    _guard(meta, args)+    edits = args.get('tracks') or []+    if not edits:+        fail('bad_args', 'tracks is [{id, width?, layer?, start?, end?}]')+    _, pads, wires, _ = objects(board)+    def handle(x):+        return 'track:%s:%d:%g,%g,%g,%g' % (x['net'], x['layer'], round(x['start'][0], 4), round(x['start'][1], 4), round(x['end'][0], 4), round(x['end'][1], 4))+    by = {handle(x): x for x in wires if not x['curve']}+    results, plan, cmds = [], [], []+    for ed in edits:+        x = by.get(ed.get('id'))+        if x is None:+            results.append('err:no such track %s: read routing_state again' % ed.get('id'))+            continue+        others = [o for o in wires if o['layer'] == x['layer'] and _wire_key(o) != _wire_key(x)]+        pick = _pick_points(x, others, [p['at'] for p in pads.values() if x['layer'] in p['layers']])[0]+        step, want = [], dict(x)+        step.append('DISPLAY NONE %d;' % x['layer'])+        if ed.get('width') is not None:+            want['width'] = number(ed['width'], True)+            step.append('CHANGE WIDTH %.6fmm (%.6fmm %.6fmm);' % (want['width'], pick[0], pick[1]))+        for key in ('start', 'end'):+            if ed.get(key) is not None:+                to = [number(ed[key][0]), number(ed[key][1])]+                step.append('MOVE (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (x[key][0], x[key][1], to[0], to[1]))+                want[key] = to+        if ed.get('layer') is not None:+            want['layer'] = _layer_number(board, ed['layer'])+            if ed.get('start') is None and ed.get('end') is None:+                step.append('CHANGE LAYER %d (%.6fmm %.6fmm);' % (want['layer'], pick[0], pick[1]))+            else:+                results.append('err:change a track\'s layer and its ends in two calls')+                continue+        plan.append({'id': ed['id'], 'before': x, 'want': want})+        cmds += step+        results.append('ok')+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'results': results, 'commands': cmds}+    if not plan:+        return {'success': False, 'mutated': False, **meta, 'errorCode': 'nothing_to_do', 'results': results, 'error': 'No track could be edited'}+    visible = _visible_layers(root)+    try:+        for c in cmds:+            app.executeTextCommand('Electron.run ' + c)+    finally:+        _restore_display(app, visible)+    _, after, meta2 = snapshot(app, args)+    now = objects(after)[2]+    keys = {_wire_key(x) for x in now}+    gone = {_wire_key(p_['before']) for p_ in plan}+    fresh = {_wire_key(p_['want']) for p_ in plan}+    ok = fresh <= keys and {_wire_key(x) for x in wires} - gone <= keys+    width = {_wire_key(x): x['width'] for x in now}+    ok = ok and all(abs(width[_wire_key(p_['want'])] - p_['want']['width']) < 1e-6 for p_ in plan if _wire_key(p_['want']) in width)+    if not ok:+        _undo_until(app, args, meta['revision'], sum(1 for c in cmds if not c.startswith('DISPLAY')) + 2)+        fail('readback_mismatch', 'The copper after the edit was not exactly the asked tracks; every change was undone')+    app.executeTextCommand('Electron.run RATSNEST;')+    return {'success': True, 'mutated': True, **meta2, 'results': results, 'verified': True,+            'changes': [{'id': p_['id'], 'before': {k: p_['before'][k] for k in ('width', 'layer', 'start', 'end')}, 'after': {k: p_['want'][k] for k in ('width', 'layer', 'start', 'end')}} for p_ in plan],+            '_hint': 'Run fusion_run_drc to check clearances. A moved end leaves the track\'s old connection open unless it lands on the same net.'}+++def place_parts(app, root, board, meta, args):+    """The standard's place_parts on a board with no schematic (fusion_open_board, fusion_build_board): footprints+    from a .lbr with position, rotation and side. A board linked to a schematic gets its parts from the sheet+    (fusion_sch_place_part, then fusion_move_parts), since Fusion keeps the two in step, so it is refused."""+    _guard(meta, args)+    parts = args.get('parts') or []+    if not parts:+        fail('bad_args', 'parts is [{library, footprint, ref?, value?, x, y, rotation?, side?}]')+    doc = app.activeDocument+    linked = False+    for i in range(app.documents.count):+        d = app.documents.item(i)+        try:+            if d.name == doc.name and d.products.itemByProductType('SchematicProductType') is not None:+                linked = True+        except Exception:+            pass+    if linked:+        fail('linked_board', 'This board is linked to a schematic, and Fusion takes its parts from there: fusion_sch_place_part, then fusion_move_parts')+    names = {e.get('name') for e in board.findall('elements/element')}+    results, placed, cmds, used = [], [], [], set()+    for k, pp in enumerate(parts):+        lbr, fp = pp.get('library'), pp.get('footprint')+        if not lbr or not Path(lbr).is_file() or not fp:+            results.append('err:library (a .lbr on this machine) and footprint are required'); continue+        lroot = ET.parse(lbr).getroot()+        if not any(q.get('name', '').upper() == str(fp).upper() for q in lroot.iter('package')):+            results.append('err:no footprint %s in %s' % (fp, Path(lbr).name)); continue+        ref = identifier(pp.get('ref') or 'P%d' % (k + 1))+        if ref in names:+            results.append('err:designator %s is already taken' % ref); continue+        x, y = number(pp.get('x')), number(pp.get('y'))+        if max(abs(x), abs(y)) > SHEET_LIMIT_MM:+            results.append('err:off the sheet (keep within %g mm)' % SHEET_LIMIT_MM); continue+        side = pp.get('side') or 'top'+        rot = float(pp.get('rotation') or 0) % 360+        used.add(lbr)+        cmds.append("SET CONFIRM YES; ADD '%s@%s' '%s' %sR%g (%.6fmm %.6fmm); SET CONFIRM OFF;" % (fp, Path(lbr).stem, ref, 'M' if side == 'bottom' else '', rot, x, y))+        if pp.get('value'):+            cmds.append("SET CONFIRM YES; VALUE '%s' '%s'; SET CONFIRM OFF;" % (ref, str(pp['value']).replace("'", "''")))+        names.add(ref)+        placed.append({'ref': ref, 'x': x, 'y': y, 'rotation': rot, 'side': side})+        results.append('ok')+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'results': results, 'placed': placed, 'commands': cmds}+    for lbr in sorted(used):+        use_library(app, lbr)+    for c in cmds:+        app.executeTextCommand('Electron.run ' + c)+    _, after, meta2 = snapshot(app, args)+    have = {e.get('name'): e for e in after.findall('elements/element')}+    missing = [q['ref'] for q in placed if q['ref'] not in have]+    if missing:+        _undo_until(app, args, meta['revision'], len(cmds) + 2)+        fail('readback_mismatch', 'Not on the board after ADD: %s; every part was undone' % ', '.join(missing))+    return {'success': all(r == 'ok' for r in results), 'mutated': True, **meta2, 'results': results, 'placed': placed}++ def read_board(app, root, board, meta, args):     """The standard's read_board: the board in one round trip, as sections (components, nets,     layers, copper, rules, occupancy, outline; sections picks some), each list capped at limit@@ -3965,6 +4090,8 @@ def handle(app,args,action):         if action=='set_layer_visibility': return set_layer_visibility(app,root,board,meta,args)         if action=='select_net': return select_net(app,root,board,meta,args)         if action=='set_track_width': return set_track_width(app,root,board,meta,args)+        if action=='edit_tracks': return edit_tracks(app,root,board,meta,args)+        if action=='place_parts': return place_parts(app,root,board,meta,args)         if action=='add_rule': return add_rule(app,root,board,meta,args)         if action=='read_board': return read_board(app,root,board,meta,args)         if action=='remove_items': return remove_items(app,root,board,meta,args)
rust/bridge/src/sch.rs+1−1
@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("sch_script.py"), "\n", include_str!("lbr_use.py"));-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_batch" | "sch_update_from_library" | "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_label" | "sch_wire" | "sch_batch" | "sch_update_from_library" | "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part" | "export_spice" | "simulate" | "sch_zoom" | "jlcpcb_order" | "variants") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     if cmd == "render_schematic" { return render_schematic(args, pass); }     if cmd == "sch_batch" && args["dryRun"].as_bool() != Some(true) { return sch_batch(args, pass); }
rust/bridge/src/sch_script.py+91−1
@@ -1194,6 +1194,96 @@ def sch_swap_part(app, args):                    _hint='Fusion updates the board footprint with the part. A pin left in lostConnections has no pin of that pad number on the new part, or could not be joined: wire it with fusion_sch_connect.')  +def sch_label(app, args):+    """The standard's sch_label: a net label on an existing wire at a point (LABEL; kind global is an XREF+    label), or delete one by its sch_items id, or by text with at. A label shows its wire's net, so text must+    name that net: renaming a net is sch_edit_label's. Fusion has no hierarchical label. Read back; a mismatch+    undoes it."""+    e = Edit(app, args)+    if args.get('remove'):+        at = args.get('at')+        lid = args.get('id')+        if lid:+            if not str(lid).startswith('label:'):+                sfail('bad_args', 'id is a label id from sch_items (label:x,y)')+            at = [float(v) for v in str(lid).split(':', 1)[1].split(',')]+        if not at or len(at) != 2:+            sfail('bad_args', 'remove needs id, or text with at')+        q = (round(float(at[0]), 3), round(float(at[1]), 3))+        rows = [r for r in label_rows(e.sheet) if (r[1], r[2]) == q and (lid or not args.get('text') or r[0] == args.get('text'))]+        if not rows:+            sfail('not_found', 'No label at %g,%g' % q)+        e.cmds.append('DELETE %s;' % mm(q))+        if dry(args):+            return plan_reply(e, removed=['label:%s' % _key(q)])+        e.run()+        if set(label_rows(e.sheet2)) != set(label_rows(e.sheet)) - set(rows) or not e.same_except(labels=False):+            e.undo('The label did not read back as removed')+        return e.reply(removed=['label:%s' % _key(q)])+    text, at = args.get('text'), args.get('at')+    if not text:+        sfail('bad_args', 'text is the net name (required to add)')+    if not at or len(at) != 2:+        sfail('bad_args', 'at is [x, y] mm, a point on the wire')+    p_ = (float(at[0]), float(at[1]))+    kind = str(args.get('kind') or 'local').lower()+    if kind == 'hierarchical':+        sfail('not_supported', 'Fusion has no hierarchical label: a module port carries a net across a hierarchy')+    if kind not in ('local', 'global'):+        sfail('bad_args', 'kind is local, global or hierarchical')+    nets = nets_at(e.sheet, p_)+    if not nets:+        sfail('no_wire', 'No wire at %g,%g: draw one first (fusion_sch_wire), or use fusion_sch_connect to label a pin' % p_)+    if text not in nets:+        sfail('net_mismatch', 'The wire at %g,%g is on %s, and a label shows its wire\'s net: pass that name, or rename the net with fusion_sch_edit_label' % (p_[0], p_[1], ' and '.join(sorted(nets))))+    rot = int(args.get('rotation') or 0)+    if rot not in (0, 90, 180, 270):+        sfail('bad_args', 'rotation is 0, 90, 180 or 270')+    e.cmds.append('LABEL %s%s R%d %s;' % ('XREF ' if kind == 'global' else '', mm(p_), rot, mm(p_)))+    if dry(args):+        return plan_reply(e)+    e.run()+    fresh = sorted(set(label_rows(e.sheet2)) - set(label_rows(e.sheet)))+    if len(fresh) != 1 or fresh[0][0] != text or set(label_rows(e.sheet)) - set(label_rows(e.sheet2)) or not e.same_except(labels=False):+        e.undo('The label did not read back as the one added')+    r = fresh[0]+    return e.reply(label={'id': 'label:%s' % _key((r[1], r[2])), 'text': r[0], 'kind': kind, 'at': [r[1], r[2]], 'rotation': rot})+++def sch_wire(app, args):+    """The standard's sch_wire: a wire through given points, mm. Fusion names a wire's net: it takes the net of a+    wire or pin it starts on, net names one, else Fusion gives it a free name. junctions (default true) adds a+    junction where the wire ends on another wire. Read back; a mismatch undoes it."""+    e = Edit(app, args)+    pts = args.get('points') or []+    if len(pts) < 2 or any(len(p_) != 2 for p_ in pts):+        sfail('bad_args', 'points is [[x, y], ...] mm, two or more')+    pts = [(float(p_[0]), float(p_[1])) for p_ in pts]+    if max(abs(v) for p_ in pts for v in p_) > 1950:+        sfail('off_the_sheet', 'Keep points within 1950 mm: Fusion drops drawing past about 2 m')+    name = args.get('net')+    e.cmds.append('SET CONFIRM YES; NET %s%s; SET CONFIRM OFF;' % (quote(str(name)) + ' ' if name else '', ' '.join(mm(p_) for p_ in pts)))+    if dry(args):+        return plan_reply(e)+    e.run()+    want = set(zip(pts, pts[1:]))+    have = wire_cover(e.sheet2)+    if have == wire_cover(e.sheet) or not e.same_except(wires=False, junctions=False, labels=False):+        e.undo('The wire did not read back as drawn')+    added = []+    if args.get('junctions', True):+        pp = pin_points(e.after, e.sheet2)+        for q in (pts[0], pts[-1]):+            r = (round(q[0], 3), round(q[1], 3))+            if ends_at(e.sheet2, q, pins=pp) >= 3 and r not in junction_points(e.sheet2) and len(nets_at(e.sheet2, q)) == 1:+                added.append(r)+        if added:+            e.again(['SET CONFIRM NO; JUNCTION %s; SET CONFIRM OFF;' % mm(q) for q in added])+    return e.reply(wires=[{'id': wire_id(a[0], a[1], b[0], b[1]), 'from': list(a), 'to': list(b)} for a, b in zip(pts, pts[1:])],+                   junctionsAdded=[{'id': 'junction:%s' % _key(q), 'at': list(q)} for q in added], net=name or sorted(nets_at(e.sheet2, pts[0]))[0] if nets_at(e.sheet2, pts[0]) else None)+++ def sch_split_wire(app, args):     if args.get('junction') is False:         sfail('unsupported', 'Fusion splits a wire by placing a junction on it (SPLIT at one point does nothing); call with junction true, or delete the junction afterwards with sch_junction {remove}')@@ -2354,7 +2444,7 @@ def sch_batch(app, args):             '_hint': 'Every op ran and read back. save:true saves the design as a new version.'}  -EDITS = {'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,+EDITS = {'sch_label': sch_label, 'sch_wire': sch_wire, 'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,          'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region,          'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part, 'variants': variants} 
rust/bridge/src/small_verbs.rs+1−1
@@ -108,7 +108,7 @@ pub fn cancel_run(args: &Value, banner: &dyn Fn(Value)) -> Value {         if let Some(r) = live.last() { rid = r["runId"].as_str().unwrap_or("").to_string(); }     }     if rid.is_empty() {-        return json!({"success": false, "errorCode": "no_run_in_flight", "error": "No import is running on this box, so there is nothing to cancel.", "_hint": "fusion_prove_result {} shows the latest run and its stage."});+        return json!({"success": true, "cancelRequested": false, "reason": "no_run_in_flight", "note": "No import is running on this box, so there is nothing to cancel.", "_hint": "fusion_prove_result {} shows the latest run and its stage."});     }     let Some(mut st) = runs.get(&rid).cloned() else {         return json!({"success": false, "errorCode": "unknown_run_id", "error": format!("no run with runId {} on this box", fusion_lbr::py_repr_str(&rid))});
rust/bridge/src/stdshapes.rs+84
@@ -442,6 +442,17 @@ pub fn update_pcb(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { /// `fusion_select_items` (the standard's select_items): select parts by designator, or clear the selection. /// Fusion does not report its electronics selection back (see selection), so nothing can read it back. pub fn select_items(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    // where: board or sheet. Fusion selects in the editor in front, and nothing here moves the user to another+    // editor, so the other one is refused by name+    if let Some(w) = args["where"].as_str() {+        if !matches!(w, "board" | "sheet") { return json!({"success": false, "errorCode": "bad_args", "error": "where is board or sheet"}); }+        let st = pass("get_app_state", json!({}));+        let ws = st["data"]["activeWorkspace"].as_str().unwrap_or("").to_lowercase();+        let front = if ws.contains("schematic") { "sheet" } else if ws.contains("pcb") || ws.contains("board") { "board" } else { "" };+        if !front.is_empty() && front != w {+            return json!({"success": false, "errorCode": "wrong_editor", "front": front, "error": format!("The {front} editor is in front, and Fusion selects only there: show the {w} first (fusion_show_{}).", if w == "board" { "2d_board" } else { "schematic" })});+        }+    }     if args["clear"].as_bool() == Some(true) {         let r = pass("run_modeling_script", json!({"script": "app.userInterface.activeSelections.clear()\nresult={'cleared':True}"}));         return json!({"success": r["success"].as_bool() != Some(false), "cleared": true, "verified": false, "error": r["error"]});@@ -460,3 +471,76 @@ pub fn select_items(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value     json!({"success": failed.is_empty(), "selected": selected, "failed": failed, "verified": false,         "_hint": "Fusion does not report its electronics selection back, so this cannot be read back: look at the view (fusion_take_screenshot)."}) }++/// `fusion_extensions` (the standard's extensions): the Fusion add-ins and scripts installed for this user. Fusion+/// has no API to install or remove one, so those are refused by name.+pub fn extensions(args: &Value) -> Value {+    match args["action"].as_str().unwrap_or("list") {+        "list" => {}+        a @ ("install" | "remove") => return json!({"success": false, "errorCode": "not_supported", "error": format!("Fusion has no way to {a} an add-in from code: use its Scripts and Add-Ins dialog. The bridge deploys its own add-in itself.")}),+        o => return json!({"success": false, "errorCode": "bad_args", "error": format!("action is list, install or remove, not {o}")}),+    }+    let roaming = std::env::var("APPDATA").unwrap_or_default();+    let mut out = Vec::new();+    for (rel, kind) in [("Autodesk/FusionAddins", "addin"), ("Autodesk/Autodesk Fusion 360/API/AddIns", "addin"), ("Autodesk/Autodesk Fusion 360/API/Scripts", "script")] {+        for e in std::fs::read_dir(format!("{roaming}/{rel}")).into_iter().flatten().flatten() {+            let name = e.file_name().to_string_lossy().to_string();+            if !e.path().is_dir() || out.iter().any(|x: &Value| x["id"] == json!(name)) { continue; }+            let manifest = std::fs::read_to_string(e.path().join(format!("{name}.manifest"))).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or(json!({}));+            out.push(json!({"id": name, "name": name, "version": manifest["version"], "kind": kind}));+        }+    }+    json!({"success": true, "extensions": out, "count": out.len(), "_hint": "Fusion publishes no update feed for add-ins, so updateAvailable is not reported."})+}++/// `fusion_model_check` (the standard's model_check): per part, is there a 3D model, and does it resolve. Fusion's+/// models are cloud 3D packages, so "found" means the URN opens as a file for this account. Fusion can't say+/// whether one holds geometry without opening it, so hasGeometry is null. Read only.+pub fn model_check(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let (mut rows, limit): (Vec<(String, Vec<String>)>, usize) = (Vec::new(), args["limit"].as_u64().unwrap_or(50) as usize);+    let grab = |text: &str, tag: &str| -> Vec<(String, String)> {+        // (footprint name, wip urn or urn) for each <package3d>+        let re = regex::Regex::new(&format!(r#"(?s)<{tag}\b([^>]*)>(.*?)</{tag}>"#)).unwrap();+        let attr = |a: &str, k: &str| regex::Regex::new(&format!(r#"\b{k}="([^"]*)""#)).unwrap().captures(a).map(|c| c[1].to_string()).unwrap_or_default();+        let mut v = Vec::new();+        for c in re.captures_iter(text) {+            let u = { let w = attr(&c[1], "wip_urn"); if w.is_empty() { attr(&c[1], "urn") } else { w } };+            for i in regex::Regex::new(r#"<packageinstance name="([^"]+)""#).unwrap().captures_iter(&c[2]) { v.push((i[1].to_string(), u.clone())); }+        }+        v+    };+    if let Some(lib) = args["library"].as_str() {+        let Ok(t) = std::fs::read_to_string(lib) else { return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {lib}")}) };+        let want = args["footprint"].as_str().unwrap_or("");+        let models = grab(&t, "package3d");+        for c in regex::Regex::new(r#"<package name="([^"]+)""#).unwrap().captures_iter(&t) {+            if !want.is_empty() && !c[1].eq_ignore_ascii_case(want) { continue; }+            rows.push((c[1].to_string(), models.iter().filter(|(n, _)| n == &c[1]).map(|(_, u)| u.clone()).collect()));+        }+        if rows.is_empty() && !want.is_empty() { return json!({"success": false, "errorCode": "unknown_footprint", "error": format!("no footprint {want} in {lib}")}); }+    } else {+        let src = crate::routing::call("board_source", args, pass);+        let Some(t) = src["source"].as_str().or(src["data"]["source"].as_str()).map(str::to_string) else { return src };+        let models = grab(&t, "package3d");+        for c in regex::Regex::new(r#"<element name="([^"]+)"[^>]*?package="([^"]+)""#).unwrap().captures_iter(&t) {+            rows.push((c[1].to_string(), models.iter().filter(|(n, _)| n == &c[2]).map(|(_, u)| u.clone()).collect()));+        }+    }+    let checked = rows.len();+    rows.truncate(limit);+    let urns: Vec<&String> = rows.iter().flat_map(|(_, u)| u.iter()).collect();+    let script = format!("import adsk.core,json\nout={{}}\nfor u in json.loads({}):\n    try:\n        lin=u.split('?')[0].replace('fs.file:vf.','dm.lineage:')\n        out[u]=app.data.findFileById(lin) is not None\n    except Exception:\n        out[u]=False\nresult={{'found':out}}", serde_json::to_string(&serde_json::to_string(&urns).unwrap()).unwrap());+    let found = pass("run_modeling_script", json!({"script": script}));+    let f = &found["data"]["result"]["found"];+    let mut all_ok = true;+    let parts: Vec<Value> = rows.iter().map(|(name, us)| {+        let models: Vec<Value> = us.iter().map(|u| json!({"path": u, "location": "cloud", "found": f[u.as_str()].as_bool().unwrap_or(false), "hasGeometry": Value::Null})).collect();+        let mut issues = Vec::new();+        if us.is_empty() { issues.push(json!("no 3D model")); }+        if models.iter().any(|m| m["found"] != json!(true)) { issues.push(json!("a 3D model's cloud file does not open for this account")); }+        if !issues.is_empty() { all_ok = false; }+        json!({"name": name, "models": models, "issues": issues})+    }).collect();+    json!({"success": true, "parts": parts, "checked": checked, "ok": all_ok,+        "_hint": "found means the cloud file opens for this account. hasGeometry is null: Fusion can't tell without opening each model. Whether a body sits in the right place is fusion_bodies."})+}
rust/bridge/src/unsupported.rs+1
@@ -17,6 +17,7 @@ pub const TABLE: &[(&str, &str)] = &[     ("extract_intlib", "a Fusion library is one .lbr holding symbols, footprints and components together: there is nothing to extract"),     ("outjob_info", "Fusion has no OutJob: each output is its own verb (fusion_export_gerbers with the JLCPCB CAM job, fusion_export_bom, fusion_export_pdf, fusion_export_odb ...)"),     ("run_outjob", "Fusion has no OutJob: each output is its own verb (fusion_export_gerbers with the JLCPCB CAM job, fusion_export_bom, fusion_export_pdf, fusion_export_odb ...)"),+    ("sch_no_connect", "Fusion has no no-connect marker: its text commands have none (checked live on Fusion 2706), and an EAGLE pin is marked unused by its direction in the library (nc). Leave the pin open and approve its ERC message (fusion_run_erc lists it)"),     ("agent_start", "Fusion has no script agent to start: the bridge's add-in loads with Fusion (fusion_start), and fusion_restart_for_addin reloads it"),     ("agent_stop", "Fusion has no script agent: the add-in lives as long as Fusion does"),     ("agent_recover", "Fusion has no script engine to halt: a blocking dialog is cleared with fusion_dismiss_blocking_dialogs, and fusion_addin_status says whether the add-in answers"),
tests/sch_drill.py+30
@@ -120,6 +120,21 @@ wires = items('wires').get('wires') or [] step('sch_delete_wire', 'fusion_sch_delete_wire', {'ids': [(wires or [{}])[-1].get('id')]},      lambda _r: expect(len(items('wires').get('wires') or []) < len(wires), 'wire still there')) +# ── wires and labels by point ──────────────────────────────────────────────────────────────────────+step('sch_wire', 'fusion_sch_wire', {'points': [[20, 200], [40, 200], [40, 220]], 'net': 'WIRED'},+     lambda r: expect(any(w.get('net') == 'WIRED' for w in items('wires').get('wires') or []) and len(r.get('wires') or []) == 2, 'wire not on the sheet'))+step('sch_label add', 'fusion_sch_label', {'text': 'WIRED', 'at': [30, 200]},+     lambda _r: expect(any(l.get('text') == 'WIRED' for l in items('labels').get('labels') or []), 'label not on the sheet'))+r = call('fusion_sch_label', {'text': 'OTHER', 'at': [30, 200]})+results.append(('sch_label wrong net refused', expect(r.get('errorCode') == 'net_mismatch', str(r.get('errorCode')))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL  ' + str(results[-1][1])))+lab = next((l for l in items('labels').get('labels') or [] if l.get('text') == 'WIRED'), {})+step('sch_label remove', 'fusion_sch_label', {'remove': True, 'id': lab.get('id')},+     lambda _r: expect(not any(l.get('id') == lab.get('id') for l in items('labels').get('labels') or []), 'label still there'))+r = call('fusion_sch_no_connect', {'pin': 'R4.1'})+results.append(('sch_no_connect not_supported', expect(r.get('errorCode') in ('not_supported', 'unsupported') or r.get('supported') is False, json.dumps({k: r.get(k) for k in ('errorCode', 'success')}))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL  ' + str(results[-1][1])))+ # ── moving and editing ───────────────────────────────────────────────────────────────────────────── a0 = pins('R6')[('R6', 'A')]['at'] step('sch_move_region', 'fusion_sch_move_region', {'box': [a0[0] - 15, a0[1] - 6, a0[0] + 40, a0[1] + 6], 'by': [10.16, 0]},@@ -161,6 +176,21 @@ def sheet_count(): n = sheet_count() step('sch_create', 'fusion_sch_create', {}, lambda _r: expect(sheet_count() == n + 1, 'sheets %d, was %d' % (sheet_count(), n))) +# ── the board: tracks, models, add-ins ─────────────────────────────────────────────────────────────+call('fusion_show_2d_board', {'expectDocument': 'Untitled'})+step('model_check', 'fusion_model_check', {'expectDocument': 'Untitled'},+     lambda r: expect(r.get('checked', 0) >= 2 and all('models' in p_ for p_ in r.get('parts') or []), json.dumps(r.get('parts'))[:120]))+step('extensions list', 'fusion_extensions', {},+     lambda r: expect(any(x.get('id') == 'AdomBridge' for x in r.get('extensions') or []), 'AdomBridge not listed'))+r = call('fusion_extensions', {'action': 'install', 'id': 'x'})+results.append(('extensions install refused', expect(r.get('errorCode') == 'not_supported', str(r.get('errorCode')))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL  ' + str(results[-1][1])))+r = call('fusion_place_parts', {'expectDocument': 'Untitled', 'parts': [{'library': LBR, 'footprint': 'R0603', 'ref': 'R50', 'x': 5, 'y': 5}]})+results.append(('place_parts linked refused', expect(r.get('errorCode') == 'linked_board', '%s %s' % (r.get('errorCode'), str(r.get('error'))[:90]))))+print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL  ' + str(results[-1][1])))+state = call('fusion_routing_state', {'expectDocument': 'Untitled'})+print('tracks on the board:', len(state.get('tracks') or []), '(edit_tracks needs one: see tests/sch_drill.py)')+ dialogs = call('fusion_check_dialogs', {}).get('count') results.append(('no dialog left', expect(not dialogs, '%s dialog(s) open' % dialogs))) if not opts.keep: