← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.0-dev.1+1.13.0\ No newline at end of file
CHANGELOG.md+48−32
@@ -3,10 +3,14 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` -## Unreleased: mechanical layer, 1.13.0-dev.1 (2026-10-28)+## 1.13.0 (2026-10-28, insiders)++Drew's typed mechanical layer, Fusion 2706 support, his safe-restart work, and library authoring.++### Mechanical layer (merged #22, Drew Owens)  The typed mechanical layer (`fusion_mechanical`, 61 operations; `fusion_mech_capabilities`;-`fusion_mech_operation_status`) on 1.12.8 with the restart fixes of 1.12.9. Development build, not released.+`fusion_mech_operation_status`), with the exe owning validation, the durable journal and replay. Its development history is in [docs/MECHANICAL-HISTORY.md](docs/MECHANICAL-HISTORY.md).  - The exe owns the layer's validation, durable journal and replies, as it does for the rest of the bridge; the@@ -31,37 +35,49 @@ Its development history is in [docs/MECHANICAL-HISTORY.md](docs/MECHANICAL-HISTO - Not yet run live: `joint_origin_*` and `motion_link_*` (their campaign groups were cut off by the restart bug   that 1.12.9 fixes) and `mcmaster_open` (interactive by design). -## 1.12.9 (unreleased)--The bridge no longer restarts Fusion over a busy add-in, and a restart it does need no longer kills Fusion over-unsaved work.+### Fusion 2706, safe restarts (merged #21), library authoring -- A busy add-in is never mistaken for a missing one. While a long native Fusion call holds the add-in's main-  thread, not even `/status` or `/health` answers, and the gate read that silence as "the add-in was installed-  while Fusion was running" and restarted Fusion. On a person's own PC that killed Fusion with their documents-  open six times between 2026-10-14 and 2026-10-28 (a 93 s script was in flight on 10-28). Now silence counts as-  work whenever there is evidence the add-in is loaded: one of the exe's own requests to it is still in flight,-  or it answered earlier inside the same Fusion process and its port still takes connections. The verb gate, the-  timeout diagnosis, the readiness launch observer (which restarted Fusion after about 3 minutes of silence) and-  a warm `fusion_start` all ask for that evidence first and answer `fusion_addin_busy`. `fusion_readiness` and-  `fusion_state` report it as `addinBusy`, and the dashboard no longer restarts over it. A request in flight also-  counts as a long operation for the hang verdict.-- `restartPolicy` `auto` (the default) stops short of losing work. When the add-in cannot save, Fusion is closed-  the way a person closes it instead of killed: it closes at once when nothing is unsaved, and a save prompt for-  a document the bridge did not make (or a never-saved document) ends the restart with `restart_needs_decision`,-  with nothing killed or discarded. For 30 minutes after that the restart is asked for instead of retried. A stop-  that did not finish is no longer escalated to a kill. The old behaviour is `restartPolicy: force`, for a machine-  only an AI drives. The ledger's restart summary now says what happened: it said "Documents were saved first"-  when the save step had failed and Fusion was killed.-- `fusion_stop` waits for heavy designs: up to `closeTimeoutSec` (default 120; it was 15 s). A save prompt for a-  document the bridge made is answered Don't Save; one for anyone else's work is cancelled and the stop fails-  with `unsaved_work`.-- Documents a caller opens are no longer registered as bridge scratch. Any document that appeared during a-  window-raising verb was registered, so a user's assembly reopened with `fusion_open_by_urn` became "the-  bridge's": its save prompt would be answered Don't Save and a restart could discard it. Now only documents a-  verb made (not saved cloud documents, which were opened) and the bridge's own Save As are registered, and-  nothing Fusion opens at launch. The registry evicts its oldest entry when full; it used to evict the-  alphabetically first.+- Fixed for Fusion 2706, which removed the `Electron.boardInfo` text command. On released 1.12.8 that broke+  `fusion_board_info`, `fusion_board_summary`, `fusion_export_cpl` and electronics_read's board fallback. The exe+  now builds the same answer from the board's own export: the summary, plus an XML in boardInfo's layout, so+  every consumer reads it unchanged. It also writes the pick-and-place CSV itself. On older Fusion the add-in+  path is used as before.+- Merged #21 (Drew Owens), in his words:+  The bridge no longer restarts Fusion over a busy add-in, and a restart it does need no longer kills Fusion over+  unsaved work.+  - A busy add-in is never mistaken for a missing one. While a long native Fusion call holds the add-in's main+    thread, not even `/status` or `/health` answers, and the gate read that silence as "the add-in was installed+    while Fusion was running" and restarted Fusion. On a person's own PC that killed Fusion with their documents+    open six times between 2026-10-14 and 2026-10-28 (a 93 s script was in flight on 10-28). Now silence counts as+    work whenever there is evidence the add-in is loaded: one of the exe's own requests to it is still in flight,+    or it answered earlier inside the same Fusion process and its port still takes connections. The verb gate, the+    timeout diagnosis, the readiness launch observer (which restarted Fusion after about 3 minutes of silence) and+    a warm `fusion_start` all ask for that evidence first and answer `fusion_addin_busy`. `fusion_readiness` and+    `fusion_state` report it as `addinBusy`, and the dashboard no longer restarts over it. A request in flight also+    counts as a long operation for the hang verdict.+  - `restartPolicy` `auto` (the default) stops short of losing work. When the add-in cannot save, Fusion is closed+    the way a person closes it instead of killed: it closes at once when nothing is unsaved, and a save prompt for+    a document the bridge did not make (or a never-saved document) ends the restart with `restart_needs_decision`,+    with nothing killed or discarded. For 30 minutes after that the restart is asked for instead of retried. A stop+    that did not finish is no longer escalated to a kill. The old behaviour is `restartPolicy: force`, for a machine+    only an AI drives. The ledger's restart summary now says what happened: it said "Documents were saved first"+    when the save step had failed and Fusion was killed.+  - `fusion_stop` waits for heavy designs: up to `closeTimeoutSec` (default 120; it was 15 s). A save prompt for a+    document the bridge made is answered Don't Save; one for anyone else's work is cancelled and the stop fails+    with `unsaved_work`.+  - Documents a caller opens are no longer registered as bridge scratch. Any document that appeared during a+    window-raising verb was registered, so a user's assembly reopened with `fusion_open_by_urn` became "the+    bridge's": its save prompt would be answered Don't Save and a restart could discard it. Now only documents a+    verb made (not saved cloud documents, which were opened) and the bridge's own Save As are registered, and+    nothing Fusion opens at launch. The registry evicts its oldest entry when full; it used to evict the+    alphabetically first.+- New, offline on .lbr files, each change preflighted before it is written (the old file kept as .bak):+  - `fusion_create_footprint`, `fusion_create_symbol`, `fusion_read_symbol`, `fusion_find_symbol`;+  - `fusion_copy_footprint`, `fusion_rename_footprint`, `fusion_remove_footprint`, `fusion_xcopy_footprint`;+  - `fusion_clone_symbol`, `fusion_rename_symbol`, `fusion_remove_symbol`;+  - `fusion_set_params`: attributes, and joining a symbol's pins to a footprint's pads by number.+- The daily verb suite runs on fusion-lane's copy of BQ25792 (Default Project/fusion-lane-tests), never on the+  owner's design.  ## 1.12.8 (2026-10-28, insiders) 
bridge.json+13−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.0-dev.1",+  "version": "1.13.0",   "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.",@@ -67,6 +67,7 @@     "fusion_check_recovery",     "fusion_claim_seat",     "fusion_cleanup_cloud_files",+    "fusion_clone_symbol",     "fusion_close_all_documents",     "fusion_close_document",     "fusion_close_window",@@ -74,9 +75,12 @@     "fusion_component_pins",     "fusion_copper_balance",     "fusion_copper_layers",+    "fusion_copy_footprint",     "fusion_copy_routing",     "fusion_create_cloud_folder",+    "fusion_create_footprint",     "fusion_create_library",+    "fusion_create_symbol",     "fusion_cross_probe",     "fusion_current_capacity",     "fusion_delete_cloud_file",@@ -126,6 +130,7 @@     "fusion_export_usdz",     "fusion_fab_check",     "fusion_fetch_optimized_glb",+    "fusion_find_symbol",     "fusion_generate_body",     "fusion_generate_package",     "fusion_get_app_state",@@ -206,12 +211,17 @@     "fusion_read_footprint",     "fusion_read_rules",     "fusion_read_sheet_file",+    "fusion_read_symbol",     "fusion_readiness",     "fusion_release_seat",+    "fusion_remove_footprint",     "fusion_remove_graphics",     "fusion_remove_items",     "fusion_remove_parts",     "fusion_remove_pours",+    "fusion_remove_symbol",+    "fusion_rename_footprint",+    "fusion_rename_symbol",     "fusion_render_board_3d",     "fusion_render_schematic",     "fusion_repour",@@ -265,6 +275,7 @@     "fusion_set_layer_visibility",     "fusion_set_outline",     "fusion_set_parameter",+    "fusion_set_params",     "fusion_set_preference",     "fusion_set_seat_takeover",     "fusion_set_track_width",@@ -294,6 +305,7 @@     "fusion_view",     "fusion_walk_cloud_tree",     "fusion_web_signin",+    "fusion_xcopy_footprint",     "fusion_zoom_to"   ],   "dependencies": {},
contracts/describe.json+246
@@ -918,6 +918,25 @@       "summary": "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).",       "timeoutSeconds": 1800     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "from": "str",+        "libraryPath": "a .lbr on the Fusion machine",+        "to": "str"+      },+      "longRunning": false,+      "name": "fusion_clone_symbol",+      "output": {+        "from": "str",+        "primitives": "int",+        "to": "str"+      },+      "statusVerb": null,+      "summary": "Copy a component of a .lbr under a new name with copies of its symbols (the standard's clone_symbol).",+      "timeoutSeconds": 30+    },     {       "example": {         "includeScratch": true@@ -1051,6 +1070,25 @@       "summary": "The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each.",       "timeoutSeconds": 60     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "from": "str",+        "libraryPath": "a .lbr on the Fusion machine",+        "to": "str"+      },+      "longRunning": false,+      "name": "fusion_copy_footprint",+      "output": {+        "from": "str",+        "primitives": "int",+        "to": "str"+      },+      "statusVerb": null,+      "summary": "Duplicate a footprint inside a .lbr under a new name (the standard's copy_footprint), offline and preflighted.",+      "timeoutSeconds": 30+    },     {       "example": {         "box": [@@ -1119,6 +1157,30 @@       "summary": "Create a subfolder in a cloud project through the signed-in Fusion session's Data API (no APS needed). Idempotent: an existing folder of that name is returned with alreadyExisted:true instead of a duplicate. Folder names are matched case-insensitively. For an APS-side create without Fusion running, use fusion_aps_create_folder.",       "timeoutSeconds": 60     },+    {+      "example": {+        "libraryPath": "C:/libs/new.lbr",+        "name": "SOT-23",+        "pads": "1,-37,-40,24,28,rect,0;2,37,-40,24,28,rect,0;3,0,40,24,28,rect,0"+      },+      "input": {+        "description": "str",+        "dryRun": "bool",+        "footprints": "[{name, pads, ...}]",+        "libraryPath": "a .lbr on the Fusion machine (required; created if missing)",+        "name": "str (required)",+        "pads": "str or list (required)"+      },+      "longRunning": false,+      "name": "fusion_create_footprint",+      "output": {+        "footprints": "[{footprint, pads}]",+        "libraryState": "opened|created"+      },+      "statusVerb": null,+      "summary": "Create a footprint (an EAGLE package) from a pad list in a .lbr, offline (the standard's create_footprint): pads as 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...' (hole 0 = SMD) or [{number, at, sizeMm, shape, drillMm}], with a documentation outline and >NAME/>VALUE. The library is created when the path does not exist; several at once with footprints. Preflighted before writing.",+      "timeoutSeconds": 30+    },     {       "example": {         "libraryPath": "C:/Temp/PART.lbr",@@ -1143,6 +1205,33 @@       "summary": "ONE-CALL library creation for web-control (one call = one consent). EVERY LIBRARY IS MULTI-PART - a single-part library is simply parts:[one entry], and there is no separate single-part verb to choose. Pass parts:[{package, lbrPath, modelPath}] for any number of parts; the older {libraryPath, stepPath} shape still works and is normalised into a one-element parts list, so both run the identical code path and the ETA scales with the part count either way. TWO entry shapes: A) {libraryPath, stepPath} - a ready .lbr plus the 3D; B) {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?} - KiCad sources; needs adom-lbr, which is Linux-container-only by platform rule, so on Windows/Mac boxes shape B refuses IMMEDIATELY (adom_lbr_unavailable, before any staging or Fusion launch - fusion_readiness reports kicadIngest up front). Convert at AUTHORING time in a container and use shape A. pinMap = explicit {pinName: padName} for when pin numbers do not match pad names (BGA-style). Chains convert -> bind 3D -> prove (four evidence frames + verdict) in the BACKGROUND and returns immediately; poll fusion_prove_result (stage narrates; top-level `verdict` proven/not_proven is the score - `stage:done` only means the chain finished). EVERY PART IS PROVED, not just the first: a 10-component library streams 40 evidence frames, FOUR per component (symbol, footprint, 3d, ganged) in assembly order, each tagged with `part` so a front end can group them as they arrive, and `provedParts[]` reports per-part pass/fail. The library is proven only if EVERY part is - one good part does not vouch for nine unchecked ones. On the FINAL fetch pass {inlineFrames:true} to get each evidence frame as a base64 thumbnailDataUrl a browser can render (plain polls return box paths only). The chain closes its library document on success AND failure - batches need no between-part sweeping. stepPath is required for a PROVEN library - no real 3D body without it.",       "timeoutSeconds": 30     },+    {+      "example": {+        "libraryPath": "C:/libs/new.lbr",+        "name": "LM358",+        "pins": "1,OUTA,right,100,1;2,-INA,left,100,1;3,+INA,left,200,1;8,V+,left,100,0;4,V-,left,200,0"+      },+      "input": {+        "bodyHeightMil": "default 600",+        "bodyWidthMil": "default 800",+        "description": "str",+        "designator": "default U",+        "dryRun": "bool",+        "libraryPath": "a .lbr on the Fusion machine (required; created if missing)",+        "name": "str (required)",+        "pins": "str (required)",+        "symbols": "[{name, pins, ...}]"+      },+      "longRunning": false,+      "name": "fusion_create_symbol",+      "output": {+        "libraryState": "opened|created",+        "symbols": "[{symbol, pins, parts}]"+      },+      "statusVerb": null,+      "summary": "Create a component in a .lbr, offline (the standard's create_symbol): an EAGLE deviceset with one rectangular symbol per part and its pins on the sides asked ('des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...'); part 0 and hidden-net pins go on a power gate placed on request. Pin numbers are kept so set_params {footprint} can join pins to pads.",+      "timeoutSeconds": 30+    },     {       "example": {},       "input": {@@ -2147,6 +2236,27 @@       "summary": "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.",       "timeoutSeconds": 150     },+    {+      "example": {+        "folder": "C:/libs",+        "query": "LM358"+      },+      "input": {+        "folder": "a folder of .lbr files",+        "kind": "symbol|footprint",+        "libraries": "[paths]",+        "query": "str (required)"+      },+      "longRunning": false,+      "name": "fusion_find_symbol",+      "output": {+        "best": "{name, library, exact}",+        "matches": "[{name, library, exact}]"+      },+      "statusVerb": null,+      "summary": "Find components (or footprints with kind:'footprint') by part of their name across .lbr files, offline (the standard's find_symbol): the exact match first, else the shortest name that contains the query. Fusion keeps no installed-library list a script can read, so name the libraries or a folder.",+      "timeoutSeconds": 30+    },     {       "example": {         "dimensionsMm": {@@ -17749,6 +17859,27 @@       "summary": "Read a schematic straight from its file, without Fusion running: every part of an EAGLE .sch (or the .sch inside a Fusion .fsch) with its designator, library reference (deviceset + device), value, description, part number, library, sheet, position and orientation, parameters on request, and its pins: number (the pad), name, gate and electrical type (input, output, io, open_collector, passive, hiz, power, supply, not_connected) for a schematic review. Milliseconds. The saved file, not the live sheet.",       "timeoutSeconds": 30     },+    {+      "example": {+        "libraryPath": "C:/libs/new.lbr",+        "symbolName": "LM358"+      },+      "input": {+        "libraryPath": "a .lbr on the Fusion machine (required)",+        "symbolName": "str (required)"+      },+      "longRunning": false,+      "name": "fusion_read_symbol",+      "output": {+        "libref": "str",+        "models": "[{type, name}]",+        "params": "[{name, value}]",+        "pins": "[{des, name, gate, electrical, hidden}]"+      },+      "statusVerb": null,+      "summary": "Read a component of a .lbr back (the standard's read_symbol): description, its pins with number, name, gate and electrical type (0 input, 1 io, 2 output, 3 open collector, 4 passive, 5 hi-z, 7 power), its attributes and its footprints (devices).",+      "timeoutSeconds": 30+    },     {       "example": {},       "input": {},@@ -17781,6 +17912,22 @@       "summary": "Give up the Autodesk seat from THIS machine so another box can claim it with no modal at all: closes Fusion cleanly (documents are NOT discarded; an unsaved one refuses and says so) and leaves the account signed in, so this box can claim the seat straight back. The counterpart of fusion_claim_seat.",       "timeoutSeconds": 180     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "footprintName": "str",+        "libraryPath": "a .lbr on the Fusion machine"+      },+      "longRunning": false,+      "name": "fusion_remove_footprint",+      "output": {+        "removed": "str"+      },+      "statusVerb": null,+      "summary": "Delete a footprint from a .lbr (the standard's remove_footprint); refused while a component uses it.",+      "timeoutSeconds": 30+    },     {       "example": {         "dryRun": true,@@ -17883,6 +18030,58 @@       "summary": "Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.",       "timeoutSeconds": 120     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "libraryPath": "a .lbr on the Fusion machine",+        "symbolName": "str"+      },+      "longRunning": false,+      "name": "fusion_remove_symbol",+      "output": {+        "removed": "str",+        "symbolsRemoved": "[str]"+      },+      "statusVerb": null,+      "summary": "Delete a component from a .lbr, and the symbols no other component uses (the standard's remove_symbol).",+      "timeoutSeconds": 30+    },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "from": "str",+        "libraryPath": "a .lbr on the Fusion machine",+        "to": "str"+      },+      "longRunning": false,+      "name": "fusion_rename_footprint",+      "output": {+        "devicesUpdated": "int",+        "renamed": "str"+      },+      "statusVerb": null,+      "summary": "Rename a footprint in a .lbr (the standard's rename_footprint): every device that uses it and its 3D package instances follow.",+      "timeoutSeconds": 30+    },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "from": "str",+        "libraryPath": "a .lbr on the Fusion machine",+        "to": "str"+      },+      "longRunning": false,+      "name": "fusion_rename_symbol",+      "output": {+        "renamed": "str"+      },+      "statusVerb": null,+      "summary": "Rename a component in a .lbr (the standard's rename_symbol).",+      "timeoutSeconds": 30+    },     {       "example": {         "background": "transparent",@@ -19144,6 +19343,34 @@       "summary": "Set one parameter's expression by name in the ACTIVE 3D Design document (user parameters first, then model parameters). MUTATES the design: the model regenerates and the result carries the post-op dialog array. Requires a Design document to be active (a 2D board or library editor refuses). Read names and current expressions with fusion_get_parameters; units must match the parameter (\"10 mm\", \"5 deg\").",       "timeoutSeconds": 30     },+    {+      "example": {+        "footprint": "SOIC-8",+        "libraryPath": "C:/libs/new.lbr",+        "params": {+          "MPN": "LM358DR"+        },+        "symbolName": "LM358"+      },+      "input": {+        "comment": "str",+        "dryRun": "bool",+        "footprint": "a package in the same library",+        "libraryPath": "a .lbr on the Fusion machine (required)",+        "params": "{MPN: ..., Manufacturer: ...}",+        "symbolName": "default the first component"+      },+      "longRunning": false,+      "name": "fusion_set_params",+      "output": {+        "component": "str",+        "footprint": "str|null",+        "paramsSet": "int"+      },+      "statusVerb": null,+      "summary": "Set attributes on a component of a .lbr (the standard's set_params): params as {key: value} or 'Key=Value;...' on every device, a COMMENT, and with footprint a device on that package whose pins join the pads with the same numbers.",+      "timeoutSeconds": 30+    },     {       "example": {         "theme": "dark"@@ -19729,6 +19956,25 @@       "summary": "Sign a pup browser window into an Autodesk WEB surface (library.io, the hub portal at <hub>.autodesk360.com, Autodesk Account) via Google SSO. Fusion's DESKTOP app being signed in does NOT sign these in - they are separate web sessions. NEVER guesses a credential: a missing password or a 2FA challenge comes back as a typed stop (human_credential_required / second_factor_required / google_sso_unavailable) naming the one-time command the USER runs. The password never passes through this bridge or the AI - it is stored once with ab's credential_set and filled by pup in the background.",       "timeoutSeconds": 240     },+    {+      "example": {},+      "input": {+        "dryRun": "bool",+        "from": "str",+        "fromLib": "source .lbr",+        "to": "default same name",+        "toLib": "destination .lbr (required)"+      },+      "longRunning": false,+      "name": "fusion_xcopy_footprint",+      "output": {+        "primitives": "int",+        "to": "str"+      },+      "statusVerb": null,+      "summary": "Copy a footprint from one .lbr into another (the standard's xcopy_footprint), optionally under a new name; the destination is created when missing.",+      "timeoutSeconds": 30+    },     {       "example": {         "expectDocument": "fusion-routing"
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.12.8"+version = "1.13.0" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.8"+version = "1.13.0" edition = "2021"  [dependencies]
describe.py+48
@@ -314,6 +314,54 @@ _T = [      {"project": "a .sch, .brd or the folder holding them, on the Fusion machine (required)", "newName": "str (required)", "dryRun": "bool"},      {"renamed": "[{from, to}]", "changed": "[]"},      30, None, False, {"project": "C:/Boards/Sensor-Hub", "newName": "Sensor-Hub-v2", "dryRun": True}),+    ('fusion_create_footprint', "Create a footprint (an EAGLE package) from a pad list in a .lbr, offline (the standard's create_footprint): pads as 'des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;...' (hole 0 = SMD) or [{number, at, sizeMm, shape, drillMm}], with a documentation outline and >NAME/>VALUE. The library is created when the path does not exist; several at once with footprints. Preflighted before writing.",+     {"libraryPath": "a .lbr on the Fusion machine (required; created if missing)", "name": "str (required)", "pads": "str or list (required)", "description": "str", "footprints": "[{name, pads, ...}]", "dryRun": "bool"},+     {"footprints": "[{footprint, pads}]", "libraryState": "opened|created"},+     30, None, False, {'libraryPath': 'C:/libs/new.lbr', 'name': 'SOT-23', 'pads': '1,-37,-40,24,28,rect,0;2,37,-40,24,28,rect,0;3,0,40,24,28,rect,0'}),+    ('fusion_create_symbol', "Create a component in a .lbr, offline (the standard's create_symbol): an EAGLE deviceset with one rectangular symbol per part and its pins on the sides asked ('des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...'); part 0 and hidden-net pins go on a power gate placed on request. Pin numbers are kept so set_params {footprint} can join pins to pads.",+     {"libraryPath": "a .lbr on the Fusion machine (required; created if missing)", "name": "str (required)", "pins": "str (required)", "designator": "default U", "bodyWidthMil": "default 800", "bodyHeightMil": "default 600", "description": "str", "symbols": "[{name, pins, ...}]", "dryRun": "bool"},+     {"symbols": "[{symbol, pins, parts}]", "libraryState": "opened|created"},+     30, None, False, {'libraryPath': 'C:/libs/new.lbr', 'name': 'LM358', 'pins': '1,OUTA,right,100,1;2,-INA,left,100,1;3,+INA,left,200,1;8,V+,left,100,0;4,V-,left,200,0'}),+    ('fusion_read_symbol', "Read a component of a .lbr back (the standard's read_symbol): description, its pins with number, name, gate and electrical type (0 input, 1 io, 2 output, 3 open collector, 4 passive, 5 hi-z, 7 power), its attributes and its footprints (devices).",+     {"libraryPath": "a .lbr on the Fusion machine (required)", "symbolName": "str (required)"},+     {"libref": "str", "pins": "[{des, name, gate, electrical, hidden}]", "params": "[{name, value}]", "models": "[{type, name}]"},+     30, None, False, {'libraryPath': 'C:/libs/new.lbr', 'symbolName': 'LM358'}),+    ('fusion_find_symbol', "Find components (or footprints with kind:'footprint') by part of their name across .lbr files, offline (the standard's find_symbol): the exact match first, else the shortest name that contains the query. Fusion keeps no installed-library list a script can read, so name the libraries or a folder.",+     {"query": "str (required)", "kind": "symbol|footprint", "libraries": "[paths]", "folder": "a folder of .lbr files"},+     {"best": "{name, library, exact}", "matches": "[{name, library, exact}]"},+     30, None, False, {'query': 'LM358', 'folder': 'C:/libs'}),+    ('fusion_copy_footprint', "Duplicate a footprint inside a .lbr under a new name (the standard's copy_footprint), offline and preflighted.",+     {"libraryPath": "a .lbr on the Fusion machine", "from": "str", "to": "str", "dryRun": "bool"},+     {"from": "str", "to": "str", "primitives": "int"},+     30, None, False, {}),+    ('fusion_rename_footprint', "Rename a footprint in a .lbr (the standard's rename_footprint): every device that uses it and its 3D package instances follow.",+     {"libraryPath": "a .lbr on the Fusion machine", "from": "str", "to": "str", "dryRun": "bool"},+     {"renamed": "str", "devicesUpdated": "int"},+     30, None, False, {}),+    ('fusion_remove_footprint', "Delete a footprint from a .lbr (the standard's remove_footprint); refused while a component uses it.",+     {"libraryPath": "a .lbr on the Fusion machine", "footprintName": "str", "dryRun": "bool"},+     {"removed": "str"},+     30, None, False, {}),+    ('fusion_xcopy_footprint', "Copy a footprint from one .lbr into another (the standard's xcopy_footprint), optionally under a new name; the destination is created when missing.",+     {"fromLib": "source .lbr", "from": "str", "toLib": "destination .lbr (required)", "to": "default same name", "dryRun": "bool"},+     {"to": "str", "primitives": "int"},+     30, None, False, {}),+    ('fusion_clone_symbol', "Copy a component of a .lbr under a new name with copies of its symbols (the standard's clone_symbol).",+     {"libraryPath": "a .lbr on the Fusion machine", "from": "str", "to": "str", "dryRun": "bool"},+     {"from": "str", "to": "str", "primitives": "int"},+     30, None, False, {}),+    ('fusion_rename_symbol', "Rename a component in a .lbr (the standard's rename_symbol).",+     {"libraryPath": "a .lbr on the Fusion machine", "from": "str", "to": "str", "dryRun": "bool"},+     {"renamed": "str"},+     30, None, False, {}),+    ('fusion_remove_symbol', "Delete a component from a .lbr, and the symbols no other component uses (the standard's remove_symbol).",+     {"libraryPath": "a .lbr on the Fusion machine", "symbolName": "str", "dryRun": "bool"},+     {"removed": "str", "symbolsRemoved": "[str]"},+     30, None, False, {}),+    ('fusion_set_params', "Set attributes on a component of a .lbr (the standard's set_params): params as {key: value} or 'Key=Value;...' on every device, a COMMENT, and with footprint a device on that package whose pins join the pads with the same numbers.",+     {"libraryPath": "a .lbr on the Fusion machine (required)", "symbolName": "default the first component", "params": "{MPN: ..., Manufacturer: ...}", "comment": "str", "footprint": "a package in the same library", "dryRun": "bool"},+     {"component": "str", "paramsSet": "int", "footprint": "str|null"},+     30, None, False, {'libraryPath': 'C:/libs/new.lbr', 'symbolName': 'LM358', 'params': {'MPN': 'LM358DR'}, 'footprint': 'SOIC-8'}),     ('fusion_read_footprint', "Every detail of one library footprint (the standard's read_footprint): pads with number, type, shape, position, size, drill, layers and turn; the layers used; the courtyard box (component exclude layers 39/40, else pads and placement outline); graphics per layer; the 3D packages that instance it. From a .lbr (libraryPath + name) or, by libId 'Lib:Name', from the libraries the open board embeds.",      {"libraryPath": "a .lbr on the Fusion machine (with name)", "name": "the package's name", "libId": "'Library:Package' from the board's embedded libraries", "expectDocument": "the board's name, for libId"},      {"name": "str", "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]", "layers": "[str]", "courtyardMm": "[x1, y1, x2, y2]", "graphics": "{layer: count}", "attributes": "{mount}", "models": "[{name, urn, type}]"},
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.12.8"+DASH_VERSION="1.13.0" 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.0-dev.1",+  "version": "1.13.0",   "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.12.8",+  "version": "1.13.0",   "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": {@@ -143,4 +143,4 @@     "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.13.0-dev.1"+version = "1.13.0" edition = "2021" license = "MIT" 
rust/bridge/src/addin.rs+38
@@ -302,6 +302,44 @@ impl<'a> Ctx<'a> { #[deprecated(note = "stage with crate::fusionpath::for_fusion  /// The whole pass-through for one verb. pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value {+    let r = pass_through_inner(cmd, args, ctx);+    // Fusion 2706 dropped the Electron.boardInfo text command (live 2026-10-28): the same answer+    // is built from the board's own export, so board_info and everything built on it keeps working+    if cmd == "board_info" && r["success"].as_bool() == Some(false) && r["error"].as_str().map(|e| e.contains("no command Electron.boardInfo")).unwrap_or(false) {+        let c = crate::routing::call("board_info_compat", &json!({}), &|v: &str, a: Value| pass_through_inner(v, &a, ctx));+        if c["success"].as_bool() == Some(true) { return c; }+        let mut out = r; out["compatError"] = c["error"].clone(); return out;+    }+    if cmd == "export_cpl" && r["success"].as_bool() == Some(false) && r["error"].as_str().map(|e| e.contains("no command Electron.boardInfo")).unwrap_or(false) {+        return cpl_from_board_info(args, &pass_through("board_info", &json!({}), ctx));+    }+    r+}++/// fusion_export_cpl from the board_info answer (the compat one on Fusion 2706): the add-in's own+/// CSV (Designator, Mid X, Mid Y, Layer, Rotation), written here because its path read boardInfo.+pub fn cpl_from_board_info(args: &Value, bi: &Value) -> Value {+    if bi["success"].as_bool() != Some(true) { return bi.clone(); }+    let out = args["outputPath"].as_str().unwrap_or("C:/tmp/adom-cpl.csv").replace('\\', "/");+    let side = args["side"].as_str().unwrap_or("all").to_lowercase();+    let mut rows: Vec<(String, String, String, String, f64)> = bi["data"]["summary"]["components"].as_array().cloned().unwrap_or_default().iter().map(|c| {+        let rot = c["rotation"].as_str().unwrap_or("R0");+        let s = if rot.starts_with('M') { "Bottom" } else { "Top" };+        let deg = rot.replace(['M', 'R', 'S'], "").parse::<f64>().unwrap_or(0.0);+        (c["name"].as_str().unwrap_or("").to_string(), c["x"].as_str().unwrap_or("0").to_string(), c["y"].as_str().unwrap_or("0").to_string(), s.to_string(), deg)+    }).filter(|r| side == "all" || r.3.eq_ignore_ascii_case(&side)).collect();+    if rows.is_empty() { return json!({"success": false, "error": if side == "all" { "No components found in board. Is a populated .brd file open?".to_string() } else { format!("No {side}-side components found.") }}); }+    rows.sort_by(|a, b| a.0.cmp(&b.0));+    let mut csv = String::from("Designator,Mid X,Mid Y,Layer,Rotation\r\n");+    for (n, x, y, s, d) in &rows { csv.push_str(&format!("{n},{x},{y},{s},{d:?}\r\n")); }+    if let Some(dir) = std::path::Path::new(&out).parent() { let _ = std::fs::create_dir_all(dir); }+    if let Err(e) = std::fs::write(&out, csv) { return json!({"success": false, "error": format!("could not write {out}: {e}")}); }+    let top = rows.iter().filter(|r| r.3 == "Top").count();+    json!({"success": true, "output": format!("CPL exported: {} placements ({top} top, {} bottom) -> {out}", rows.len(), rows.len() - top),+           "data": {"outputPath": out, "totalPlacements": rows.len(), "topCount": top, "bottomCount": rows.len() - top, "sideFilter": side, "source": "board export (Electron.boardInfo is gone in this Fusion)"}})+}++fn pass_through_inner(cmd: &str, args: &Value, ctx: &Ctx) -> Value {     if let Some((title, detail)) = crate::palette::for_verb(cmd, args) { crate::palette::say(&title, &detail, None, crate::palette::eta_for_verb(&ctx.state_dir, cmd)); }     let sidecar = ctx.sidecar;     let dialogs = ctx.dialogs;
rust/bridge/src/electronics.rs+24
@@ -137,6 +137,30 @@ pub fn query(ctx: &Ctx, args: &Value, version: &str, pass: &dyn Fn(&str, Value) #[cfg(test)] mod tests {     use super::*;+    #[test] fn reads_the_board_export_compat_layout() {+        // what board_info_compat writes when Fusion has no Electron.boardInfo (2706 on)+        let xml = r#"<boardinfo>+<g name="B" unit="mm" boardThickness="1.57" topCopperThickness="0.035" bottomCopperThickness="0.035" layersetup="(1*16)" topLayer="1" bottomLayer="16" viaLimit="2">+<libraries>+<g library="L" package="R0603" library_urn="" package3d_urn="">+<g name="R1" x="1" y="2" rot="R90" LOCKED="false"/>+</g>+</libraries>+<g id="1_copper">+<copper name="GND" id="w1" d="0 0 0 5 0 0"/>+</g>+<drc/>+</g>+</boardinfo>"#;+        let data = json!({"rawXml": xml, "summary": {"components": [{"name": "R1", "library": "L", "package": "R0603", "value": "10k", "x": "1", "y": "2", "rotation": "R90"}], "nets": ["GND"]}});+        let b = rows_from_board_info("Board", &data).unwrap();+        assert_eq!(b[0]["boardThickness"], json!(1.57)); assert_eq!(b[0]["layerSetup"], "(1*16)");+        assert_eq!(rows_from_board_info("Element", &data).unwrap()[0]["name"], "R1");+        assert_eq!(rows_from_board_info("Package", &data).unwrap()[0]["name"], "R0603");+        assert_eq!(rows_from_board_info("Layer", &data).unwrap()[0]["number"], 1);+        assert_eq!(rows_from_board_info("Wire", &data).unwrap()[0]["signal"], "GND");+        assert_eq!(rows_from_board_info("Signal", &data).unwrap()[0]["name"], "GND");+    }     #[test]     fn board_fallback_reads_the_sample() {         let d: Value = serde_json::from_str(include_str!("../tests/fixtures/board_info_sample.json")).unwrap();
rust/bridge/src/main.rs+65
@@ -653,6 +653,9 @@ fn main() {                     "fusion_library_parts" | "library_parts" => native_library_parts(&parsed["args"]),                     "fusion_export_library_part" | "export_library_part" => native_export_library_part(&parsed["args"]),                     "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]),+                    v @ ("fusion_create_footprint" | "create_footprint" | "fusion_create_symbol" | "create_symbol" | "fusion_read_symbol" | "read_symbol" | "fusion_find_symbol" | "find_symbol"+                        | "fusion_copy_footprint" | "copy_footprint" | "fusion_rename_footprint" | "rename_footprint" | "fusion_remove_footprint" | "remove_footprint" | "fusion_xcopy_footprint" | "xcopy_footprint"+                        | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),                     "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]),                     // Native since Phase 2: the raw authenticated APS GET (never-charge guarded).                     "fusion_aps_get" | "aps_get" => native_aps_get(&parsed["args"]),@@ -954,6 +957,68 @@ fn native_edit_footprint(args: &Value) -> Value {            "_hint": "Edited in place (the old file is the .bak beside it) and preflighted. A library already open in Fusion does not see the change until it is reopened (fusion_open_lbr); a design's embedded copy updates when its parts are updated from the library."}) } +/// The standard's library-authoring verbs on .lbr files, offline (fusion_lbr::authoring). A change+/// is preflighted before it is written; an existing library keeps its old text as .bak; a library+/// that does not exist yet is created by create_footprint, create_symbol and xcopy_footprint.+fn native_lib_authoring(verb: &str, args: &Value) -> Value {+    use fusion_lbr::authoring as a;+    let s = |k: &str| args[k].as_str().filter(|v| !v.is_empty()).map(str::to_string);+    if verb == "find_symbol" {+        let query = s("query").unwrap_or_default();+        if query.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "query is required", "shell": "rust"}); }+        let mut paths: Vec<String> = args["libraries"].as_array().map(|l| l.iter().filter_map(|x| x.as_str().map(|p| p.replace('\\', "/"))).collect()).unwrap_or_default();+        if let Some(dir) = s("folder").or(s("libraryPath")) {+            let d = dir.replace('\\', "/");+            if std::path::Path::new(&d).is_dir() { if let Ok(rd) = std::fs::read_dir(&d) { for e in rd.flatten() { let p = e.path(); if p.extension().map(|x| x.eq_ignore_ascii_case("lbr")).unwrap_or(false) { paths.push(p.to_string_lossy().replace('\\', "/")); } } } } else { paths.push(d); }+        }+        if paths.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "libraries [paths] or folder is required (Fusion keeps no installed-library list a script can read)", "shell": "rust"}); }+        let libs: Vec<(String, String)> = paths.iter().filter_map(|p| std::fs::read_to_string(p).ok().map(|t| (p.clone(), t))).collect();+        let mut r = a::find_symbol(&libs, &query, s("kind").as_deref() == Some("footprint"));+        r["success"] = json!(true); r["searched"] = json!(libs.len()); r["shell"] = json!("rust");+        return r;+    }+    let path = match s(if verb == "xcopy_footprint" { "toLib" } else { "libraryPath" }) { Some(p) => p.replace('\\', "/"), None => return json!({"success": false, "errorCode": "bad_args", "error": format!("{} is required", if verb == "xcopy_footprint" { "toLib (the destination .lbr)" } else { "libraryPath" }), "shell": "rust"}) };+    let existing = std::fs::read_to_string(&path).ok();+    let creates = matches!(verb, "create_footprint" | "create_symbol" | "xcopy_footprint");+    if existing.is_none() && !creates { return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {path}"), "libraryPath": path, "shell": "rust"}); }+    if verb == "read_symbol" {+        let name = s("symbolName").or(s("name")).unwrap_or_default();+        return match a::read_symbol(existing.as_deref().unwrap(), &name) { Ok(mut v) => { v["success"] = json!(true); v["libraryPath"] = json!(path); v["shell"] = json!("rust"); v }, Err(e) => json!({"success": false, "errorCode": "symbol_not_found", "error": e, "shell": "rust"}) };+    }+    let text = existing.clone();+    let r: Result<(String, Value), String> = match verb {+        "create_footprint" => a::create_footprint(text.as_deref(), args),+        "create_symbol" => a::create_symbol(text.as_deref(), args),+        "copy_footprint" => a::copy_footprint(text.as_deref().unwrap(), None, &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),+        "xcopy_footprint" => {+            let from_lib = s("fromLib").unwrap_or_default().replace('\\', "/");+            match std::fs::read_to_string(&from_lib) {+                Ok(src) => { let from = s("from").unwrap_or_default(); let to = s("to").unwrap_or(from.clone()); let dest = text.clone().unwrap_or_else(|| a::new_library("made by fusion-bridge")); a::copy_footprint(&dest, Some(&src), &from, &to) }+                Err(e) => Err(format!("fromLib {from_lib}: {e}")),+            }+        }+        "rename_footprint" => a::rename_footprint(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),+        "remove_footprint" => a::remove_footprint(text.as_deref().unwrap(), &s("footprintName").or(s("name")).unwrap_or_default()),+        "clone_symbol" => a::clone_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),+        "rename_symbol" => a::rename_symbol(text.as_deref().unwrap(), &s("from").unwrap_or_default(), &s("to").unwrap_or_default()),+        "remove_symbol" => a::remove_symbol(text.as_deref().unwrap(), &s("symbolName").or(s("name")).unwrap_or_default()),+        "set_params" => a::set_params(text.as_deref().unwrap(), s("symbolName").as_deref(), &args["params"], s("comment").as_deref(), s("footprint").as_deref()),+        _ => Err(format!("unknown verb {verb}")),+    };+    let (new_text, summary) = match r { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "edit_refused", "error": e, "shell": "rust"}) };+    let tmp = std::env::temp_dir().join(format!("adom-lib-{}-{}.lbr", verb, std::process::id()));+    let _ = std::fs::write(&tmp, &new_text);+    let (ok, errors) = a::preflight_text(&tmp);+    let _ = std::fs::remove_file(&tmp);+    if !ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": "the library would not open cleanly in Fusion; nothing was written", "preflight": errors, "summary": summary, "shell": "rust"}); }+    if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "data": summary, "libraryPath": path, "shell": "rust"}); }+    if let Some(parent) = std::path::Path::new(&path).parent() { let _ = std::fs::create_dir_all(parent); }+    let backup = if existing.is_some() { let b = format!("{path}.bak"); let _ = std::fs::copy(&path, &b); Some(b) } else { None };+    if let Err(e) = std::fs::write(&path, &new_text) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string(), "shell": "rust"}); }+    json!({"success": true, "mutated": true, "data": summary, "libraryPath": path, "backup": backup, "shell": "rust",+           "_hint": "Written and preflighted. A Fusion library is opened with fusion_open_lbr (or fusion_show_footprint / fusion_show_symbol to see one item); a library already open in Fusion does not see the change until it is reopened."})+}+ /// `fusion_preflight_lbr`: will this .lbr open cleanly in Fusion? Offline, instant. fn native_preflight_lbr(args: &Value) -> Value {     let path = lbr_path_arg(args, &["libraryPath", "filePath"]);
rust/bridge/src/routing.rs+1−1
@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "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") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "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") } 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+58
@@ -3268,13 +3268,71 @@ def export_gencad(app, root, board, meta, args):             '_hint': 'Outline, pads and padstacks, footprint shapes, components (bottom parts mirrored), devices, signals with their nodes, and routes with vias. uniquePins, flipBottomPads and useDrillOrigin are not offered.'}  +def board_info_compat(board, meta):+    """Fusion 2706 dropped the Electron.boardInfo text command (live 2026-10-28). This writes the+    same summary and an XML in its layout from the board's own export, so fusion_board_info,+    fusion_board_summary and electronics_read's board fallback keep answering on any Fusion."""+    from xml.sax.saxutils import quoteattr as qa+    L = Layers(board)+    stack = layer_stack(board)+    order = list(L.order)+    pk = {(l.get('name'), p_.get('name')): p_ for l in board.findall('libraries/library') for p_ in l.findall('packages/package')}+    lib_urn = {l.get('name'): l.get('urn', '') for l in board.findall('libraries/library')}+    rows = stack.get('layers') or []+    cu_um = lambda i: (rows[i].get('copperUm') if len(rows) > i and rows[i].get('copperUm') else 35.0) / 1000.0+    setup = board.find("designrules/param[@name='layerSetup']")+    head = '<g name=%s unit="mm" boardThickness="%g" topCopperThickness="%g" bottomCopperThickness="%g" layersetup=%s topLayer="%d" bottomLayer="%d" viaLimit="%d">' % (+        qa(meta.get('documentName') or ''), round((stack.get('totalUm') or 1600) / 1000.0, 4), cu_um(0), cu_um(len(rows) - 1 if rows else 0),+        qa(setup.get('value') if setup is not None else ''), order[0] if order else 1, order[-1] if order else 16, len(order))+    parts = ['<boardinfo>', head, '<libraries>']+    comps = []+    groups = {}+    for el in board.findall('elements/element'):+        groups.setdefault((el.get('library'), el.get('package')), []).append(el)+        comps.append({'name': el.get('name'), 'library': el.get('library'), 'package': el.get('package'), 'value': el.get('value', ''),+                      'x': el.get('x'), 'y': el.get('y'), 'rotation': el.get('rot', 'R0'), 'locked': el.get('locked') == 'yes'})+    for (lib, pkg), els in groups.items():+        p_ = pk.get((lib, pkg))+        parts.append('<g library=%s package=%s library_urn=%s package3d_urn=%s>' % (qa(lib or ''), qa(pkg or ''), qa(lib_urn.get(lib, '')), qa(els[0].get('package3d_urn', ''))))+        for el in els:+            parts.append('<g name=%s x=%s y=%s rot=%s LOCKED="%s"/>' % (qa(el.get('name')), qa(el.get('x')), qa(el.get('y')), qa(el.get('rot', 'R0')), 'true' if el.get('locked') == 'yes' else 'false'))+        parts.append('</g>')+    parts.append('</libraries>')+    nets = set()+    by_layer = {}+    for s_ in board.findall('signals/signal'):+        for w in s_.findall('wire'):+            if w.get('layer') == '19':+                continue+            by_layer.setdefault(int(w.get('layer')), []).append((s_.get('name'), '%s %s 0 %s %s 0' % (w.get('x1'), w.get('y1'), w.get('x2'), w.get('y2'))))+            nets.add(s_.get('name'))+        if s_.findall('contactref'):+            nets.add(s_.get('name'))+    k = 0+    for n in sorted(by_layer):+        parts.append('<g id="%d_copper">' % n)+        for net, d in by_layer[n]:+            k += 1+            parts.append('<copper name=%s id="w%d" d=%s/>' % (qa(net), k, qa(d)))+        parts.append('</g>')+    parts += ['<drc/>', '</g>', '</boardinfo>']+    summary = {'name': meta.get('documentName') or '', 'unit': 'mm', 'boardThickness': str(round((stack.get('totalUm') or 1600) / 1000.0, 4)),+               'layerSetup': setup.get('value') if setup is not None else '', 'components': comps, 'componentCount': len(comps),+               'nets': sorted(nets), 'netCount': len(nets)}+    return {'success': True, 'output': 'Board info: %s, %d components, %d nets' % (summary['name'], len(comps), len(nets)),+            'data': {'summary': summary, 'rawXml': '\n'.join(parts), 'source': 'board export (Electron.boardInfo is gone in this Fusion)'}}++ def handle(app,args,action):     attempted=False; commands=[]     try:         if action=='schematic_run': return sch_run(app,args)         if action=='schematic_plan': return schematic_plan(args)         if action=='board_seed': return board_seed(args)+        if action=='board_info_compat' and not args.get('expectDocument') and app.activeDocument is not None:+            args['expectDocument']=app.activeDocument.name         root,board,meta=snapshot(app,args)+        if action=='board_info_compat': return board_info_compat(board,meta)         if action=='routing_state':             st=state(board,meta); n,fa=st['unconnected'],st['fusionAirwires']             hint='Edit verbs take boardId and this revision as expectedRevision. unconnected (%d) is computed from the copper itself.'%n
rust/lbr/src/authoring.rsadded+464
@@ -0,0 +1,464 @@+//! Library authoring on EAGLE .lbr text, offline: the standard's create_footprint, create_symbol,+//! read_symbol, find_symbol, copy/rename/remove/xcopy_footprint, clone/rename/remove_symbol and+//! set_params. In EAGLE a footprint is a <package>; the standard's "symbol" (a component you place)+//! is a <deviceset> with the <symbol>s its gates use. A symbol made here keeps its pin numbers on a+//! package-less device (attribute ADOM_PINMAP, "number:pin;..."), the way EAGLE's own supply+//! symbols carry no package, so set_params {footprint} can join pins to pads later.++use crate::Element;+use serde_json::{json, Value};++const TEMPLATE_HEAD: &str = include_str!("lbr_template_head.xml");+const MIL: f64 = 0.0254;++/// A new, empty library document (the layer table of a Fusion library).+pub fn new_library(description: &str) -> String {+    format!("{TEMPLATE_HEAD}<library>\n<description>{}</description>\n<packages>\n</packages>\n<symbols>\n</symbols>\n<devicesets>\n</devicesets>\n</library>\n</drawing>\n</eagle>\n",+            description.replace('&', "&amp;").replace('<', "&lt;"))+}++fn f(v: f64) -> String { let s = format!("{:.4}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); if s == "-0" { "0".into() } else { s } }+fn el(tag: &str, attrs: &[(&str, String)]) -> Element { let mut e = Element::new(tag); e.attrs = attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); e }+fn set(e: &mut Element, k: &str, v: &str) { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v.to_string(), None => e.attrs.push((k.into(), v.into())) } }+fn lib_mut(root: &mut Element) -> Result<&mut Element, String> { root.find_deep_mut("library").ok_or_else(|| "not a library: no <library>".to_string()) }+fn section<'a>(lib: &'a mut Element, tag: &str) -> &'a mut Element {+    if lib.find_child(tag).is_none() {+        // EAGLE order inside <library>: description?, packages, packages3d?, symbols, devicesets+        let order = ["description", "packages", "packages3d", "symbols", "devicesets"];+        let rank = |t: &str| order.iter().position(|o| *o == t).unwrap_or(9);+        let at = lib.children.iter().position(|c| rank(&c.tag) > rank(tag)).unwrap_or(lib.children.len());+        lib.children.insert(at, Element::new(tag));+    }+    lib.find_child_mut(tag).unwrap()+}+fn names(sec: &Element, tag: &str) -> Vec<String> { sec.children.iter().filter(|c| c.tag == tag).filter_map(|c| c.attr("name").map(str::to_string)).collect() }+fn find_ci<'a>(list: &'a [String], want: &str) -> Option<&'a String> { list.iter().find(|n| *n == want).or_else(|| list.iter().find(|n| n.eq_ignore_ascii_case(want))) }++/// Pads as the standard writes them: "des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;..." (hole 0 = SMD),+/// or a JSON list of {number, at:[x,y] mm, sizeMm:[w,h], shape, drillMm}.+fn parse_pads(v: &Value) -> Result<Vec<Element>, String> {+    let mut out = Vec::new();+    let mk = |des: &str, x: f64, y: f64, w: f64, h: f64, round: bool, hole: f64| -> Element {+        if hole > 0.0 {+            let mut e = el("pad", &[("name", des.into()), ("x", f(x)), ("y", f(y)), ("drill", f(hole)), ("diameter", f(w.max(h)))]);+            if !round { e.attrs.push(("shape".into(), "square".into())); }+            if (w - h).abs() > 1e-6 { e.attrs.retain(|a| a.0 != "shape"); e.attrs.push(("shape".into(), "long".into())); if h > w { e.attrs.push(("rot".into(), "R90".into())); } set(&mut e, "diameter", &f(w.min(h))); }+            e+        } else {+            let mut e = el("smd", &[("name", des.into()), ("x", f(x)), ("y", f(y)), ("dx", f(w)), ("dy", f(h)), ("layer", "1".into())]);+            if round { e.attrs.push(("roundness".into(), "100".into())); }+            e+        }+    };+    if let Some(s) = v.as_str() {+        for (i, item) in s.split(';').map(str::trim).filter(|t| !t.is_empty()).enumerate() {+            let p: Vec<&str> = item.split(',').map(str::trim).collect();+            if p.len() < 6 { return Err(format!("pad {}: want des,x_mil,y_mil,w_mil,h_mil,rect|round[,hole_mil], got {item}", i + 1)); }+            let n = |k: usize| p.get(k).and_then(|t| t.parse::<f64>().ok()).unwrap_or(0.0);+            out.push(mk(p[0], n(1) * MIL, n(2) * MIL, n(3) * MIL, n(4) * MIL, p[5].eq_ignore_ascii_case("round"), n(6) * MIL));+        }+    } else if let Some(a) = v.as_array() {+        for (i, p) in a.iter().enumerate() {+            let des = p["number"].as_str().map(str::to_string).or(p["number"].as_u64().map(|n| n.to_string())).ok_or(format!("pad {}: number", i + 1))?;+            let at = p["at"].as_array().ok_or(format!("pad {des}: at [x, y] mm"))?;+            let sz = p["sizeMm"].as_array().ok_or(format!("pad {des}: sizeMm [w, h]"))?;+            let g = |a: &Vec<Value>, k: usize| a.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);+            out.push(mk(&des, g(at, 0), g(at, 1), g(sz, 0), g(sz, 1), p["shape"].as_str() == Some("round"), p["drillMm"].as_f64().unwrap_or(0.0)));+        }+    } else { return Err("pads is required".into()); }+    if out.is_empty() { return Err("pads is empty".into()); }+    let mut seen = std::collections::HashSet::new();+    for e in &out { let n = e.attr("name").unwrap_or(""); if !seen.insert(n.to_string()) { return Err(format!("two pads are numbered {n}")); } }+    Ok(out)+}++/// The standard's create_footprint: a package from a pad list, with a documentation outline round+/// the pads and >NAME / >VALUE placeholders. Several at once with `footprints`.+pub fn create_footprint(xml: Option<&str>, args: &Value) -> Result<(String, Value), String> {+    let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));+    let mut root = Element::parse(&text).map_err(|e| e.to_string())?;+    let specs: Vec<Value> = match args["footprints"].as_array() { Some(a) => a.iter().map(|x| { let mut m = args.clone(); for (k, v) in x.as_object().cloned().unwrap_or_default() { m[k] = v; } m }).collect(), None => vec![args.clone()] };+    let mut made = Vec::new();+    {+        let lib = lib_mut(&mut root)?;+        for spec in &specs {+            let name = spec["name"].as_str().filter(|s| !s.is_empty()).ok_or("name is required")?.to_string();+            let pads = parse_pads(&spec["pads"])?;+            let sec = section(lib, "packages");+            if find_ci(&names(sec, "package"), &name).is_some() { return Err(format!("footprint {name} already exists (rename_footprint, remove_footprint, or another name)")); }+            let mut pkg = el("package", &[("name", name.clone())]);+            if let Some(d) = spec["description"].as_str() { let mut de = Element::new("description"); de.text = d.into(); pkg.children.push(de); }+            let ext = |e: &Element| -> (f64, f64, f64, f64) {+                let g = |k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+                let (x, y) = (g("x"), g("y"));+                let (hw, hh) = if e.tag == "smd" { (g("dx") / 2.0, g("dy") / 2.0) } else { let d = g("diameter").max(g("drill") * 1.6) / 2.0; (d, d) };+                (x - hw, y - hh, x + hw, y + hh)+            };+            let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+            for p in &pads { let (a, b, c, d) = ext(p); x0 = x0.min(a); y0 = y0.min(b); x1 = x1.max(c); y1 = y1.max(d); }+            let n = pads.len();+            pkg.children.extend(pads);+            let m = 0.25;+            for (a, b) in [((x0 - m, y0 - m), (x1 + m, y0 - m)), ((x1 + m, y0 - m), (x1 + m, y1 + m)), ((x1 + m, y1 + m), (x0 - m, y1 + m)), ((x0 - m, y1 + m), (x0 - m, y0 - m))] {+                pkg.children.push(el("wire", &[("x1", f(a.0)), ("y1", f(a.1)), ("x2", f(b.0)), ("y2", f(b.1)), ("width", "0.1".into()), ("layer", "51".into())]));+            }+            let mut tn = el("text", &[("x", f(x0 - m)), ("y", f(y1 + m + 0.3)), ("size", "1".into()), ("layer", "25".into())]); tn.text = ">NAME".into();+            let mut tv = el("text", &[("x", f(x0 - m)), ("y", f(y0 - m - 1.3)), ("size", "1".into()), ("layer", "27".into())]); tv.text = ">VALUE".into();+            pkg.children.push(tn); pkg.children.push(tv);+            section(lib, "packages").children.push(pkg);+            made.push(json!({"footprint": name, "pads": n}));+        }+    }+    let state = if xml.is_some() { "opened" } else { "created" };+    Ok((root.to_xml_document(), json!({"footprints": made, "libraryState": state})))+}++fn direction_for(name: &str, hidden: bool) -> &'static str {+    let u = name.to_uppercase();+    if hidden || ["GND", "VCC", "VDD", "VSS", "VEE", "V+", "V-", "AGND", "DGND", "PGND", "VIN", "VBAT", "3V3", "5V"].iter().any(|p| u == *p || u.starts_with(&format!("{p}@"))) { "pwr" } else { "pas" }+}++/// The standard's create_symbol: a component (deviceset) with one gate per part, each a+/// rectangular symbol with its pins on the sides asked; part 0 pins and hidden-net pins go on a+/// power gate placed on request. Pin numbers are kept for set_params {footprint}.+pub fn create_symbol(xml: Option<&str>, args: &Value) -> Result<(String, Value), String> {+    let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));+    let mut root = Element::parse(&text).map_err(|e| e.to_string())?;+    let specs: Vec<Value> = match args["symbols"].as_array() { Some(a) => a.iter().map(|x| { let mut m = args.clone(); for (k, v) in x.as_object().cloned().unwrap_or_default() { m[k] = v; } m }).collect(), None => vec![args.clone()] };+    let mut made = Vec::new();+    {+        let lib = lib_mut(&mut root)?;+        for spec in &specs {+            let name = spec["name"].as_str().filter(|s| !s.is_empty()).ok_or("name is required")?.to_string();+            if find_ci(&names(section(lib, "devicesets"), "deviceset"), &name).is_some() { return Err(format!("symbol {name} already exists")); }+            let pins_s = spec["pins"].as_str().ok_or("pins is required: 'des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...'")?;+            let bw = spec["bodyWidthMil"].as_f64().unwrap_or(800.0) * MIL;+            let bh = spec["bodyHeightMil"].as_f64().unwrap_or(600.0) * MIL;+            // part -> [(des, name, side, offset_mm, hidden)]+            let mut parts: std::collections::BTreeMap<i64, Vec<(String, String, String, f64, bool)>> = Default::default();+            for (i, item) in pins_s.split(';').map(str::trim).filter(|t| !t.is_empty()).enumerate() {+                let p: Vec<&str> = item.split(',').map(str::trim).collect();+                if p.len() < 4 { return Err(format!("pin {}: want des,name,side,offset_mil, got {item}", i + 1)); }+                let side = p[2].to_lowercase();+                if !["left", "right", "top", "bottom"].contains(&side.as_str()) { return Err(format!("pin {}: side is left, right, top or bottom", p[0])); }+                let part: i64 = p.get(4).and_then(|v| v.parse().ok()).unwrap_or(1);+                let hidden = p.get(5).map(|v| !v.is_empty()).unwrap_or(false);+                let pname = if hidden { p[5].to_string() } else { p[1].to_string() };+                let key = if part == 0 || hidden { 0 } else { part };+                parts.entry(key).or_default().push((p[0].to_string(), pname, side, p[3].parse::<f64>().unwrap_or(0.0) * MIL, hidden));+            }+            if parts.is_empty() { return Err("pins is empty".into()); }+            let real: Vec<i64> = parts.keys().cloned().filter(|k| *k != 0).collect();+            let multi = real.len() > 1;+            let mut gates = Element::new("gates");+            let mut pinmap: Vec<String> = Vec::new();+            let mut sym_count = 0;+            for (k, pins) in &parts {+                let suffix = if *k == 0 { "_PWR".to_string() } else if multi { format!("_{}", (b'A' + (*k as u8 - 1)) as char) } else { String::new() };+                let sname = format!("{name}{suffix}");+                let mut sym = el("symbol", &[("name", sname.clone())]);+                let (hw, hh) = (bw / 2.0, bh / 2.0);+                for (a, b) in [((-hw, -hh), (hw, -hh)), ((hw, -hh), (hw, hh)), ((hw, hh), (-hw, hh)), ((-hw, hh), (-hw, -hh))] {+                    sym.children.push(el("wire", &[("x1", f(a.0)), ("y1", f(a.1)), ("x2", f(b.0)), ("y2", f(b.1)), ("width", "0.254".into()), ("layer", "94".into())]));+                }+                let mut tn = el("text", &[("x", f(-hw)), ("y", f(hh + 0.8)), ("size", "1.778".into()), ("layer", "95".into())]); tn.text = ">NAME".into();+                let mut tv = el("text", &[("x", f(-hw)), ("y", f(-hh - 2.5)), ("size", "1.778".into()), ("layer", "96".into())]); tv.text = ">VALUE".into();+                sym.children.push(tn); sym.children.push(tv);+                let mut used: std::collections::HashMap<String, usize> = Default::default();+                let len = 5.08;+                for (des, pname, side, off, hidden) in pins {+                    let c = used.entry(pname.to_uppercase()).or_insert(0); *c += 1;+                    let unique = if *c > 1 { format!("{pname}@{c}") } else { pname.clone() };+                    let (x, y, rot) = match side.as_str() {+                        "left" => (-hw - len, hh - off, "R0"), "right" => (hw + len, hh - off, "R180"),+                        "top" => (-hw + off, hh + len, "R270"), _ => (-hw + off, -hh - len, "R90"),+                    };+                    let mut pe = el("pin", &[("name", unique.clone()), ("x", f(x)), ("y", f(y)), ("length", "middle".into()), ("direction", direction_for(&unique, *hidden).into())]);+                    if rot != "R0" { pe.attrs.push(("rot".into(), rot.into())); }+                    if *hidden { pe.attrs.push(("visible".into(), "off".into())); }+                    sym.children.push(pe);+                    let gname = if *k == 0 { "P".to_string() } else if multi { ((b'A' + (*k as u8 - 1)) as char).to_string() } else { "G$1".into() };+                    pinmap.push(format!("{des}:{gname}.{unique}"));+                }+                section(lib, "symbols").children.push(sym); sym_count += 1;+                let gname = if *k == 0 { "P".to_string() } else if multi { ((b'A' + (*k as u8 - 1)) as char).to_string() } else { "G$1".into() };+                let mut g = el("gate", &[("name", gname), ("symbol", sname), ("x", f((gates.children.len() as f64) * (bw + 15.24))), ("y", "0".into())]);+                if *k == 0 { g.attrs.push(("addlevel".into(), "request".into())); }+                gates.children.push(g);+            }+            let mut ds = el("deviceset", &[("name", name.clone()), ("prefix", spec["designator"].as_str().unwrap_or("U").to_string())]);+            if let Some(d) = spec["description"].as_str() { let mut de = Element::new("description"); de.text = d.into(); ds.children.push(de); }+            ds.children.push(gates);+            let mut devs = Element::new("devices");+            let mut dev = el("device", &[("name", "".into())]);+            let mut techs = Element::new("technologies");+            let mut tech = el("technology", &[("name", "".into())]);+            tech.children.push(el("attribute", &[("name", "ADOM_PINMAP".into()), ("value", pinmap.join(";")), ("constant", "no".into())]));+            techs.children.push(tech); dev.children.push(techs); devs.children.push(dev); ds.children.push(devs);+            section(lib, "devicesets").children.push(ds);+            made.push(json!({"symbol": name, "pins": pinmap.len(), "parts": real.len().max(1), "symbols": sym_count}));+        }+    }+    let state = if xml.is_some() { "opened" } else { "created" };+    Ok((root.to_xml_document(), json!({"symbols": made, "libraryState": state})))+}++fn pin_number_map(ds: &Element) -> Vec<(String, String, String)> {+    // (number, gate, pin) from a device's connects, else from ADOM_PINMAP+    let mut out = Vec::new();+    if let Some(devs) = ds.find_child("devices") {+        for d in devs.children.iter().filter(|c| c.tag == "device") {+            if let Some(cs) = d.find_child("connects") {+                for c in cs.children.iter().filter(|c| c.tag == "connect") {+                    for pad in c.attr("pad").unwrap_or("").split(' ').filter(|p| !p.is_empty()) { out.push((pad.to_string(), c.attr("gate").unwrap_or("").to_string(), c.attr("pin").unwrap_or("").to_string())); }+                }+                if !out.is_empty() { return out; }+            }+        }+        for d in devs.children.iter().filter(|c| c.tag == "device") {+            fn walk(e: &Element, out: &mut Vec<(String, String, String)>) {+                if e.tag == "attribute" && e.attr("name") == Some("ADOM_PINMAP") {+                    for item in e.attr("value").unwrap_or("").split(';').filter(|s| !s.is_empty()) {+                        if let Some((num, gp)) = item.split_once(':') { if let Some((g, p)) = gp.split_once('.') { out.push((num.into(), g.into(), p.into())); } }+                    }+                }+                for c in &e.children { walk(c, out); }+            }+            walk(d, &mut out);+            if !out.is_empty() { return out; }+        }+    }+    out+}++fn electrical(dir: &str) -> u8 { match dir { "in" => 0, "io" => 1, "out" => 2, "oc" => 3, "hiz" => 5, "pwr" | "sup" => 7, _ => 4 } }++/// The standard's read_symbol for a deviceset.+pub fn read_symbol(xml: &str, name: &str) -> Result<Value, String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let dss = section(lib, "devicesets").children.clone();+    let ds = dss.iter().find(|d| d.tag == "deviceset" && d.attr("name") == Some(name)).or_else(|| dss.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)))+        .ok_or_else(|| format!("no symbol {name}; the library has {}", dss.iter().filter_map(|d| d.attr("name")).collect::<Vec<_>>().join(", ")))?;+    let syms = section(lib, "symbols").children.clone();+    let map = pin_number_map(ds);+    let mut pins = Vec::new();+    let gates: Vec<&Element> = ds.find_child("gates").map(|g| g.children.iter().filter(|c| c.tag == "gate").collect()).unwrap_or_default();+    for g in &gates {+        if let Some(sym) = syms.iter().find(|s| s.attr("name") == g.attr("symbol")) {+            for p in sym.children.iter().filter(|c| c.tag == "pin") {+                let pname = p.attr("name").unwrap_or("");+                let des: Vec<&str> = map.iter().filter(|(_, gg, pp)| gg == g.attr("name").unwrap_or("") && pp == pname).map(|(n, _, _)| n.as_str()).collect();+                pins.push(json!({"des": des.join(" "), "name": pname.split('@').next().unwrap_or(pname), "pin": pname, "gate": g.attr("name"), "electrical": electrical(p.attr("direction").unwrap_or("io")), "hidden": p.attr("visible") == Some("off")}));+            }+        }+    }+    let mut params = Vec::new();+    let mut models = Vec::new();+    if let Some(devs) = ds.find_child("devices") {+        for d in devs.children.iter().filter(|c| c.tag == "device") {+            if let Some(pk) = d.attr("package").filter(|p| !p.is_empty()) { models.push(json!({"type": "PCBLIB", "name": pk, "device": d.attr("name"), "isCurrent": models.is_empty()})); }+            fn walk(e: &Element, out: &mut Vec<Value>) { if e.tag == "attribute" && e.attr("name") != Some("ADOM_PINMAP") { out.push(json!({"name": e.attr("name"), "value": e.attr("value").unwrap_or(""), "hidden": e.attr("display") == Some("off")})); } for c in &e.children { walk(c, out); } }+            walk(d, &mut params);+        }+    }+    params.dedup_by(|a, b| a["name"] == b["name"]);+    Ok(json!({"libref": ds.attr("name"), "description": ds.find_child("description").map(|d| d.text.trim().to_string()).unwrap_or_default(),+              "displayModeCount": 1, "parts": gates.len(), "pinsMode0": pins.len(), "pins": pins, "params": params, "models": models, "prefix": ds.attr("prefix")}))+}++/// The standard's find_symbol over library texts: (library label, text) pairs.+pub fn find_symbol(libs: &[(String, String)], query: &str, footprints: bool) -> Value {+    let q = query.to_lowercase();+    let mut matches = Vec::new();+    for (label, text) in libs {+        let Ok(mut root) = Element::parse(text) else { continue };+        let Ok(lib) = lib_mut(&mut root) else { continue };+        let (sec, tag) = if footprints { ("packages", "package") } else { ("devicesets", "deviceset") };+        for n in names(section(lib, sec), tag) { if n.to_lowercase().contains(&q) { matches.push(json!({"name": n, "library": label, "exact": n.eq_ignore_ascii_case(query)})); } }+    }+    let best = matches.iter().find(|m| m["exact"] == true).cloned().or_else(|| matches.iter().min_by_key(|m| m["name"].as_str().map(|s| s.len()).unwrap_or(usize::MAX)).cloned());+    json!({"matches": matches, "best": best})+}++fn primitives(e: &Element) -> usize { e.children.iter().filter(|c| c.tag != "description").count() }++/// copy_footprint (within a library) and xcopy_footprint (from another library's text).+pub fn copy_footprint(xml: &str, from_xml: Option<&str>, from: &str, to: &str) -> Result<(String, Value), String> {+    let src_text = from_xml.unwrap_or(xml);+    let mut src = Element::parse(src_text).map_err(|e| e.to_string())?;+    let pk = {+        let lib = lib_mut(&mut src)?;+        let sec = section(lib, "packages");+        let n = find_ci(&names(sec, "package"), from).cloned().ok_or_else(|| format!("no footprint {from}"))?;+        sec.children.iter().find(|p| p.tag == "package" && p.attr("name") == Some(n.as_str())).cloned().unwrap()+    };+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let sec = section(lib, "packages");+    if find_ci(&names(sec, "package"), to).is_some() { return Err(format!("footprint {to} already exists in the destination")); }+    let mut c = pk; set(&mut c, "name", to); c.attrs.retain(|(k, _)| k != "urn" && k != "library_version");+    let n = primitives(&c);+    sec.children.push(c);+    Ok((root.to_xml_document(), json!({"from": from, "to": to, "primitives": n})))+}++/// rename_footprint: the package, every device that uses it, and its 3D package instances.+pub fn rename_footprint(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let sec = section(lib, "packages");+    let n = find_ci(&names(sec, "package"), from).cloned().ok_or_else(|| format!("no footprint {from}"))?;+    if find_ci(&names(sec, "package"), to).is_some() && !n.eq_ignore_ascii_case(to) { return Err(format!("footprint {to} already exists")); }+    for p in sec.children.iter_mut().filter(|p| p.tag == "package" && p.attr("name") == Some(n.as_str())) { set(p, "name", to); }+    let mut devices = 0;+    fn walk(e: &mut Element, old: &str, new: &str, devices: &mut usize) {+        if e.tag == "device" && e.attr("package") == Some(old) { set(e, "package", new); *devices += 1; }+        if e.tag == "packageinstance" && e.attr("name") == Some(old) { set(e, "name", new); }+        for c in e.children.iter_mut() { walk(c, old, new, devices); }+    }+    walk(lib, &n, to, &mut devices);+    Ok((root.to_xml_document(), json!({"renamed": to, "from": n, "devicesUpdated": devices})))+}++/// remove_footprint: refused while a device uses it, so no symbol is left pointing at nothing.+pub fn remove_footprint(xml: &str, name: &str) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let n = find_ci(&names(section(lib, "packages"), "package"), name).cloned().ok_or_else(|| format!("no footprint {name}"))?;+    let users: Vec<String> = section(lib, "devicesets").children.iter().filter(|d| d.find_child("devices").map(|ds| ds.children.iter().any(|x| x.attr("package") == Some(n.as_str()))).unwrap_or(false)).filter_map(|d| d.attr("name").map(str::to_string)).collect();+    if !users.is_empty() { return Err(format!("footprint {n} is used by {}: remove or relink those first", users.join(", "))); }+    section(lib, "packages").children.retain(|p| !(p.tag == "package" && p.attr("name") == Some(n.as_str())));+    Ok((root.to_xml_document(), json!({"removed": n})))+}++/// clone_symbol: the deviceset copied under a new name with copies of its symbols.+pub fn clone_symbol(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let dsn = find_ci(&names(section(lib, "devicesets"), "deviceset"), from).cloned().ok_or_else(|| format!("no symbol {from}"))?;+    if find_ci(&names(section(lib, "devicesets"), "deviceset"), to).is_some() { return Err(format!("symbol {to} already exists")); }+    let mut ds = section(lib, "devicesets").children.iter().find(|d| d.tag == "deviceset" && d.attr("name") == Some(dsn.as_str())).cloned().unwrap();+    set(&mut ds, "name", to); ds.attrs.retain(|(k, _)| k != "urn" && k != "library_version");+    let syms = section(lib, "symbols").children.clone();+    let mut new_syms = Vec::new();+    let mut prims = 0;+    if let Some(g) = ds.find_child_mut("gates") {+        for gate in g.children.iter_mut().filter(|c| c.tag == "gate") {+            let old = gate.attr("symbol").unwrap_or("").to_string();+            if let Some(s) = syms.iter().find(|s| s.attr("name") == Some(old.as_str())) {+                let nn = if old.starts_with(&dsn) { format!("{to}{}", &old[dsn.len()..]) } else { format!("{to}_{old}") };+                let mut c = s.clone(); set(&mut c, "name", &nn); c.attrs.retain(|(k, _)| k != "urn" && k != "library_version");+                prims += primitives(&c);+                if !syms.iter().any(|x| x.attr("name") == Some(nn.as_str())) && !new_syms.iter().any(|x: &Element| x.attr("name") == Some(nn.as_str())) { new_syms.push(c); }+                set(gate, "symbol", &nn);+            }+        }+    }+    section(lib, "symbols").children.extend(new_syms);+    section(lib, "devicesets").children.push(ds);+    Ok((root.to_xml_document(), json!({"from": dsn, "to": to, "primitives": prims, "displayModeCount": 1})))+}++pub fn rename_symbol(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let sec = section(lib, "devicesets");+    let n = find_ci(&names(sec, "deviceset"), from).cloned().ok_or_else(|| format!("no symbol {from}"))?;+    if find_ci(&names(sec, "deviceset"), to).is_some() && !n.eq_ignore_ascii_case(to) { return Err(format!("symbol {to} already exists")); }+    for d in sec.children.iter_mut().filter(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())) { set(d, "name", to); }+    Ok((root.to_xml_document(), json!({"renamed": to, "from": n})))+}++/// remove_symbol: the deviceset, and the symbols no other deviceset uses.+pub fn remove_symbol(xml: &str, name: &str) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let n = find_ci(&names(section(lib, "devicesets"), "deviceset"), name).cloned().ok_or_else(|| format!("no symbol {name}"))?;+    let used_by = |sec: &Element, skip: &str| -> std::collections::HashSet<String> {+        sec.children.iter().filter(|d| d.tag == "deviceset" && d.attr("name") != Some(skip)).flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect()+    };+    let mine: Vec<String> = section(lib, "devicesets").children.iter().filter(|d| d.attr("name") == Some(n.as_str())).flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();+    let others = used_by(section(lib, "devicesets"), &n);+    section(lib, "devicesets").children.retain(|d| !(d.tag == "deviceset" && d.attr("name") == Some(n.as_str())));+    let gone: Vec<String> = mine.into_iter().filter(|s| !others.contains(s)).collect();+    section(lib, "symbols").children.retain(|s| !(s.tag == "symbol" && s.attr("name").map(|x| gone.iter().any(|g| g == x)).unwrap_or(false)));+    Ok((root.to_xml_document(), json!({"removed": n, "symbolsRemoved": gone})))+}++/// The standard's set_params: attributes on every device of the symbol (EAGLE keeps them per+/// technology), a COMMENT attribute, and with `footprint` a device on that package whose connects+/// come from the pin numbers (pad name = pin number).+pub fn set_params(xml: &str, symbol: Option<&str>, params: &Value, comment: Option<&str>, footprint: Option<&str>) -> Result<(String, Value), String> {+    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+    let lib = lib_mut(&mut root)?;+    let pk_names = names(section(lib, "packages"), "package");+    let pk_pads: Vec<String> = match footprint {+        Some(fp) => {+            let n = find_ci(&pk_names, fp).cloned().ok_or_else(|| format!("no footprint {fp} in this library"))?;+            section(lib, "packages").children.iter().find(|p| p.attr("name") == Some(n.as_str())).map(|p| p.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| c.attr("name").map(str::to_string)).collect()).unwrap_or_default()+        }+        None => vec![],+    };+    let fp_name = footprint.and_then(|fp| find_ci(&pk_names, fp).cloned());+    let sec = section(lib, "devicesets");+    let all = names(sec, "deviceset");+    let n = match symbol { Some(s) => find_ci(&all, s).cloned().ok_or_else(|| format!("no symbol {s}"))?, None => all.first().cloned().ok_or("the library has no symbols")? };+    let ds = sec.children.iter_mut().find(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())).unwrap();+    let mut kv: Vec<(String, String)> = match params {+        Value::Object(m) => m.iter().map(|(k, v)| (k.to_uppercase(), v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect(),+        Value::String(s) => s.split(';').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.trim().to_uppercase(), v.trim().to_string())).collect(),+        _ => vec![],+    };+    if let Some(c) = comment { kv.push(("COMMENT".into(), c.to_string())); }+    let mut linked = Value::Null;+    if let (Some(fp), false) = (&fp_name, pk_pads.is_empty()) {+        let map = pin_number_map(ds);+        if map.is_empty() { return Err(format!("{n} has no pin numbers to join to {fp}'s pads")); }+        let missing: Vec<&String> = map.iter().map(|(num, _, _)| num).filter(|num| !pk_pads.contains(num)).collect();+        if !missing.is_empty() { return Err(format!("{fp} has no pad {}", missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }+        let devs = match ds.find_child_mut("devices") { Some(d) => d, None => { ds.children.push(Element::new("devices")); ds.find_child_mut("devices").unwrap() } };+        // the package-less device that held the pin numbers becomes the footprint's device+        let idx = devs.children.iter().position(|d| d.tag == "device" && d.attr("package").map(|p| p.is_empty()).unwrap_or(true));+        let mut dev = match idx { Some(i) => devs.children.remove(i), None => el("device", &[("name", if devs.children.is_empty() { "".into() } else { format!("-{}", fp) })]) };+        set(&mut dev, "package", fp);+        dev.children.retain(|c| c.tag != "connects");+        let mut cs = Element::new("connects");+        let mut by_pin: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();+        for (num, g, p) in &map { by_pin.entry((g.clone(), p.clone())).or_default().push(num.clone()); }+        for ((g, p), pads) in &by_pin { cs.children.push(el("connect", &[("gate", g.clone()), ("pin", p.clone()), ("pad", pads.join(" "))])); }+        dev.children.insert(0, cs);+        devs.children.push(dev);+        linked = json!(fp);+    }+    let mut set_n = 0;+    if let Some(devs) = ds.find_child_mut("devices") {+        if devs.children.is_empty() { devs.children.push(el("device", &[("name", "".into())])); }+        for d in devs.children.iter_mut().filter(|c| c.tag == "device") {+            let techs = match d.find_child_mut("technologies") { Some(t) => t, None => { d.children.push(Element::new("technologies")); d.find_child_mut("technologies").unwrap() } };+            if techs.children.is_empty() { techs.children.push(el("technology", &[("name", "".into())])); }+            for t in techs.children.iter_mut().filter(|c| c.tag == "technology") {+                for (k, v) in &kv {+                    match t.children.iter_mut().find(|a| a.tag == "attribute" && a.attr("name") == Some(k.as_str())) {+                        Some(a) => set(a, "value", v),+                        None => t.children.push(el("attribute", &[("name", k.clone()), ("value", v.clone()), ("constant", "no".into())])),+                    }+                }+            }+        }+        set_n = kv.len();+    }+    Ok((root.to_xml_document(), json!({"component": n, "paramsSet": set_n, "footprint": linked})))+}++/// The preflight a library under construction must pass: every check of fusion_preflight_lbr+/// except the ones only a finished library meets (has symbols, devicesets, packages, connects):+/// a footprint-only library is fine to write; opening a component in it is what needs them.+pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) {+    let pf = crate::preflight_lbr(path);+    // also fine mid-build: a symbol not yet joined to a footprint (no connects anywhere yet), and a+    // library whose last footprint was just removed+    let errs: Vec<String> = pf.errors.into_iter().filter(|e| !e.starts_with("symbols-present") && !e.starts_with("devicesets-present")+        && !e.starts_with("connects-resolve: no connects at all") && !e.starts_with("packages-nonempty")).collect();+    (errs.is_empty(), errs)+}
rust/lbr/src/lib.rs+1
@@ -6,6 +6,7 @@ //! AI hint: a golden that disagrees with Python is a bug HERE until proven otherwise; the //! Python bridge is the production oracle for the whole port. +pub mod authoring; use std::collections::BTreeMap;  #[derive(Debug, thiserror::Error)]
tests/run_verb_tests.py+24−7
@@ -179,8 +179,12 @@ t("fusion_dismiss_blocking_dialogs", "daily", {}, {"ok": True}, "no-op when noth # ORDER IS THE TEST: 2D-context verbs (Electron.*, gerbers/BOM/CPL, stackup, # detect_layers) run while the 2D board view is ACTIVE, before show_3d_board # switches away. The 30-fail weekly round of 2026-08-14 was mostly this.-OPEN = {"fileName": "BQ25792", "projectName": "Main", "folderPath": "Molecules/XRP/Power"}-t("fusion_open_cloud_file", "daily", dict(OPEN, **{"_pollDoc": "BQ25792", "_pollSec": 180}), {"ok": True}, "fire-and-poll: cloud opens outlive the relay timeout")+# The battery runs on fusion-lane's COPY of BQ25792 in Default Project/fusion-lane-tests, never on the+# owner's design (Colby, 2026-10-28: "leave those designs to owner ... only make new stuff or make+# copies of existing stuff to mess with"). Refresh the copy with fusion_save_to_cloud {asCopy}.+DESIGN = "lane-BQ25792-copy"+OPEN = {"fileName": DESIGN, "projectName": "Default Project", "folderPath": "fusion-lane-tests"}+t("fusion_open_cloud_file", "daily", dict(OPEN, **{"_pollDoc": DESIGN, "_pollSec": 180}), {"ok": True}, "fire-and-poll: cloud opens outlive the relay timeout") t("fusion_show_schematic", "daily", {}, {"ok": True}) t("fusion_show_2d_board", "daily", {}, {"ok": True}) t("fusion_board_info", "daily", {}, {"ok": True, "fields": ["rawXml"]}, "needs the 2D board view ACTIVE")@@ -188,7 +192,7 @@ t("fusion_open_electronics", "daily", {}, {"ok": True, "fields": ["isElectronics # 1.12.0: the EDA Bridge Standard reads, on the canonical board, 2D view active. All read-only: # they export the board in the background and change nothing (run_drc, repour and the saves write, # so they stay out of the daily run on a shared cloud design).-B = {"expectDocument": "BQ25792"}+B = {"expectDocument": DESIGN} t("fusion_routing_state", "daily", B, {"ok": True, "pred": "board_model_ok"}, "the standard board model: pads sized, copper layers named, unconnected from the copper") t("fusion_layer_stack", "daily", B, {"ok": True, "fields": ["layers", "totalUm", "copperLayers"]}) t("fusion_netlist", "daily", B, {"ok": True, "fields": ["nets", "netCount"]})@@ -270,7 +274,7 @@ t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fi t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)") t("fusion_export_eagle_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.brd"}, {"ok": True, "fields": ["savedTo"]}, ".brd needs the 2D board view ACTIVE; extracts the EAGLE XML from the .fbrd container") t("fusion_export_board_image", "weekly", {"outputPath": f"{SCRATCH}/verbtest-assembly-top.png", "preset": "assembly_top"}, {"ok": True, "fields": ["outputPath"]}, "EXPORT IMAGE needs the 2D board view ACTIVE")-t("fusion_execute_text_command", "daily", {"command": "Electron.ImmediateRepaint /full", "expectDocument": "BQ25792"}, {"ok": True},+t("fusion_execute_text_command", "daily", {"command": "Electron.ImmediateRepaint /full", "expectDocument": DESIGN}, {"ok": True},   "Electron.* needs the ELECTRONICS editor context - must run in the 2D stage") t("fusion_electron_run", "weekly", {"command": "WINDOW FIT;"}, {"ok": True}, "2D board view active") t("fusion_electron_zoom", "weekly", {"factor": 1.0}, {"ok": True}, "2D board view active")@@ -280,7 +284,7 @@ t("fusion_export_gerbers", "weekly", {"outputDir": SCRATCH}, {"ok": True}, "2D b t("fusion_export_bom", "weekly", {"outputPath": f"{SCRATCH}/bom.csv"}, {"ok": True}, "2D board view active") t("fusion_export_cpl", "weekly", {"outputPath": f"{SCRATCH}/cpl.csv"}, {"ok": True}, "2D board view active") t("fusion_board_stackup", "weekly", {}, {"ok": True}, "exports the .brd - needs the board context")-t("fusion_bodies", "daily", {"expectDocument": "BQ25792"}, {"ok": True, "fields": ["bodies", "noBody", "suspect"]}, "switches to the 3D PCB and back to the 2D board")+t("fusion_bodies", "daily", {"expectDocument": DESIGN}, {"ok": True, "fields": ["bodies", "noBody", "suspect"]}, "switches to the 3D PCB and back to the 2D board") t("fusion_show_3d_board", "daily", {}, {"ok": True}) t("fusion_inspect_bodies", "daily", {}, {"ok": True, "fields": ["bodies", "units"]}) t("fusion_mcp_call", "weekly", {"tool": "fusion_mcp_read", "arguments": {"queryType": "document"}}, {"ok": True},@@ -305,7 +309,7 @@ t("fusion_run_modeling_script#guard", "negative", t("fusion_assembly_bom#guard", "negative",   {"expectDocument": "ZZZ_NoSuchDoc_TEST"}, {"errorCode": "wrong_document"}) t("fusion_run_modeling_script", "daily",-  {"script": "result = {'ok': True}", "expectDocument": "BQ25792"}, {"ok": True})+  {"script": "result = {'ok': True}", "expectDocument": DESIGN}, {"ok": True}) t("fusion_electron_select", "manual", note="selection state is user-visible") t("fusion_open_by_urn", "weekly", note="URN fetched from aps_search at runtime", args={"_dynamic": "urn_from_search"}) t("fusion_aps_open", "weekly", {"query": "BQ25792", "wait": False}, {"ok": True})@@ -392,6 +396,19 @@ t("fusion_set_window_sizing", "daily", {}, {"ok": True}, "no-args form is a pure t("fusion_cancel_run", "negative", {"runId": "no-such-run"}, {"errorCode": "unknown_run_id"}, "expect an honest 'unknown runId'") t("fusion_preflight_lbr", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {"pred": "must_refuse"}, "expect a refusal naming the missing file") t("fusion_library_parts", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file")+LIBNEW = "C:/Users/olive/AppData/Local/Temp/fusion-lane/battery/new.lbr"+t("fusion_create_footprint", "daily", {"libraryPath": LIBNEW, "name": "SOT-23", "pads": "1,-37,-40,24,28,rect,0;2,37,-40,24,28,rect,0;3,0,40,24,28,rect,0", "dryRun": True}, {"ok": True, "fields": ["data"]}, "dry run: a new library is planned and preflighted, nothing written")+t("fusion_create_symbol", "daily", {"libraryPath": LIBNEW, "name": "LM358", "pins": "1,OUT,right,100;2,IN-,left,100;3,IN+,left,200;8,V+,left,300,0;4,V-,left,400,0", "dryRun": True}, {"ok": True, "fields": ["data"]}, "dry run")+t("fusion_copy_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_rename_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_remove_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "footprintName": "A"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_clone_symbol", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_rename_symbol", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_remove_symbol", "negative", {"libraryPath": "C:/nope/missing.lbr", "symbolName": "A"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_set_params", "negative", {"libraryPath": "C:/nope/missing.lbr", "params": {"MPN": "X"}}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")+t("fusion_xcopy_footprint", "negative", {"fromLib": "C:/nope/missing.lbr", "from": "A", "toLib": LIBNEW, "dryRun": True}, {"errorCode": "edit_refused"}, "a missing source library is refused")+t("fusion_read_symbol", "negative", {"libraryPath": "C:/nope/missing.lbr", "symbolName": "X"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file")+t("fusion_find_symbol", "negative", {"query": "X"}, {"errorCode": "bad_args"}, "libraries or folder are required") t("fusion_edit_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "name": "X", "dryRun": True}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") t("fusion_export_library_part", "negative", {"libraryPath": "C:/nope/missing.lbr", "part": "X"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") t("fusion_take_screenshot", "weekly", {}, {"ok": True}, "needs Fusion up; hwnd-targeted background capture")@@ -458,7 +475,7 @@ _SECTION_BY_VERB = {     "fusion_action_ledger": "bridge-lifecycle", "fusion_addin_status": "python-addin", "fusion_alerts": "gui-driving",     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",-    "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",+    "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",     "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",
tools/release.sh+7−4
@@ -168,7 +168,7 @@ for f in ("bridge.json", "page.json", "package.json"):     json.dump(d, open(f, "w"), indent=2); open(f, "a").write("\n") PY sed -i "s/^version = \"[0-9.]*\"/version = \"$VERSION\"/" dashboard/Cargo.toml-sed -i "s/^version = \"[0-9.]*\"/version = \"$VERSION\"/" rust/Cargo.toml   # the Rust workspace rides the same line+sed -i -E "s/^version = \"[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?\"/version = \"$VERSION\"/" rust/Cargo.toml   # the Rust workspace rides the same line (a -dev suffix included) sed -i "s/DASH_VERSION=\"[0-9.]*\"/DASH_VERSION=\"$VERSION\"/" install.sh # prove they all match now BAD=$(cur_versions; sed -n 's/DASH_VERSION="\([0-9.]*\)"/\1/p' install.sh)@@ -243,8 +243,11 @@ if missing: raise SystemExit("missing release files: %s" % missing) # (twice per launch, then once per user action). A hand list is a second source of truth # that nothing keeps honest; the tree is the only one. Same family as kicad-bridge#63. import glob+# ... and the data files an add-in module reads at import (mechanical/schema.json, 1.13.0: without+# it `import commands` fails and the whole add-in is down, issue #69's failure again) tree = sorted(set(glob.glob("addin/AdomBridge/**/*.py", recursive=True)-                  + glob.glob("addin/AdomBridge/**/*.manifest", recursive=True)))+                  + glob.glob("addin/AdomBridge/**/*.manifest", recursive=True)+                  + glob.glob("addin/AdomBridge/commands/**/*.json", recursive=True))) extra = [f for f in tree if f not in files] if extra:     print("  +%d add-in file(s) from the tree the hand list lacked: %s" % (len(extra), extra))@@ -365,11 +368,11 @@ PUSH_FILES="server.py fusion_detect.py describe.py timings.py aps.py install_add   BRIDGE_VERSION bridge.json page.json package.json \   dashboard/src/main.rs dashboard/Cargo.toml dashboard/Cargo.lock dashboard/ui/index.html \   dockbar.json install.sh"-for f in handlers/*.py addin/AdomBridge/*.py addin/AdomBridge/commands/*.py \+for f in handlers/*.py addin/AdomBridge/*.py addin/AdomBridge/commands/*.py addin/AdomBridge/commands/*/*.py addin/AdomBridge/commands/*/*.json \          addin/AdomBridge/*.manifest skills/*/SKILL.md tools/release.sh CLAUDE.md \          tools/promote.sh tools/promote.py tools/check-portable.sh tools/check-naming.sh tools/freeze-contract.py \          contracts/describe.json rust/Cargo.toml rust/PORTING.md \-         rust/*/Cargo.toml rust/*/src/*.rs rust/bridge/src/*.py rust/bridge/src/*.json rust/bridge/src/*.xml \+         rust/*/Cargo.toml rust/*/src/*.rs rust/*/src/*/*.rs rust/bridge/src/*.py rust/bridge/src/*.json rust/bridge/src/*.xml \          rust/vendor/*/Cargo.toml rust/vendor/*/src/*.rs rust/vendor/*/*.md \          tools/sign_pe.py tools/gen-readme-verbs.py tests/*.py contracts/eda-bridge-standard/*.json CHANGELOG.md \          library_bundle.py README.md docs/*.md docs/img/*.svg docs/img/*.png \