app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 20 file(s)
20 files changed
+1045−53
BRIDGE_VERSION+1−1CHANGELOG.md+48−32bridge.json+13−1contracts/describe.json+246dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+48install.sh+1−1package.json+1−1page.json+2−2rust/Cargo.toml+1−1rust/bridge/src/addin.rs+38rust/bridge/src/electronics.rs+24rust/bridge/src/main.rs+65rust/bridge/src/routing.rs+1−1rust/bridge/src/routing_script.py+58rust/lbr/src/authoring.rs+464rust/lbr/src/lib.rs+1tests/run_verb_tests.py+24−7tools/release.sh+7−4BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.0-dev.1+1.13.0
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]" }-}+}
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('&', "&").replace('<', "<"))+}++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 \