← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.12.3\ No newline at end of file+1.12.4\ No newline at end of file
CHANGELOG.md+27
@@ -3,6 +3,33 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.12.4 (2026-10-27, insiders)++Standard parity, round three: the verbs credited by name now answer the standard's shape, and eight more. 251 verbs.++- Now in the standard's shape:+  - `fusion_export_dxf` with `layers` writes board layers to DXF, one file per layer (outline, copper with track+    widths, pads, vias, pour fills, silkscreen and fab with readable texts and resolved placeholders); without+    `layers` it is still the mechanical DXF;+  - `fusion_plan_route` takes `from`/`to`/`widthMm` and answers `found` and `routeNet` (two-layer boards);+  - `fusion_document_info` adds kind, saved, modified, path, version and project;+  - `fusion_close_all_documents` keeps unsaved work by default: a changed document stays open (keptOpen) unless+    `save` or `discardUnsaved`; scratch documents close only with `includeScratch`. Before, a changed saved document+    was saved (a new cloud version) and closed.+- New:+  - `fusion_render_board_3d` (Fusion's 3D view of the design, its 3D PCB shown when needed and the user's tab put+    back; transparent background);+  - `fusion_generate_body` and `fusion_library_views` (the standard's names over the package generator and the+    library captures, which open the library themselves);+  - `fusion_edit_parts` (value, designator, fields, lock; DNP and BOM flags refused, Fusion keeps them in variants);+  - `fusion_align_parts` (footprint box or anchor; refuses only the overlaps a move makes);+  - `fusion_remove_graphics` (board graphics by id, layer or kind; a dry run lists the ids);+  - `fusion_remove_parts` (deleted in the schematic: Fusion ignores a part deleted on the board).+- Learned live: `LOCK -` is not a command (a shift-click LOCK unlocks); a field the library part defines cannot be+  removed from the board; Fusion's `Electron.mfgodb` needs more than a CAM job (ODB++ is not offered yet).+- Daily suite: 111 pass on DESKTOP-BAR6S0F; the 5 failures are the APS verbs without the APS sign-in, and one test+  of mine corrected (plan_route on the four-layer BQ25792 is refused as two-layer only).+ ## 1.12.3 (2026-10-27, insiders)  - New `fusion_update_board_from_eagle {brdPath, expectDocument, save?}` (page issue #125, item 4): makes the open
README.md+13−6
@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog  All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_251 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.  **Lifecycle and readiness** @@ -597,6 +597,7 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_install_library_bundle` - COMPOSITE, the Fusion twin of kicad_install_library_bundle (#68): ONE call takes a staged Cloud Library bundle (zip of <Lib>.lbr + <Part>.step models, or a... - `fusion_library_manager` - Open Fusion's Library Manager and switch to one of its three tabs. - `fusion_library_parts` - Every ORDERABLE variant in a .lbr with its part numbers - offline, no Fusion needed.+- `fusion_library_views` - The EDA Bridge Standard's library_views, served by fusion_capture_library_views: pictures of library parts (symbol, footprint, 3D body on the footprint, and... - `fusion_make_3d_package` - Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. - `fusion_managed_library_create` - Create a NEW managed library on library.io FROM the library in front (which must already be saved in a Fusion Hub). - `fusion_managed_library_link` - Link the library IN FRONT to an existing managed library on library.io.@@ -637,7 +638,8 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_open_board` - Open a LOCAL legacy EAGLE .brd in Fusion's 2D board editor (dialog-free Document.newDesignFromLocal; a background open). - `fusion_open_electronics` - Read whether an ELECTRONICS editor (schematic, board or library) is the active workspace, so you know if Electron.* text commands and fusion_electron_run will... - `fusion_open_schematic` - Open a LOCAL legacy EAGLE .sch in Fusion's Schematic Editor (dialog-free Document.newDesignFromLocal; a background open).-- `fusion_plan_route` - Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board.+- `fusion_plan_route` - Plan a path for one net between two pads on the live board without changing it (the standard's plan_route): a bounded orthogonal two-layer A* search around the...+- `fusion_render_board_3d` - A 3D picture of the board from one side (the standard's render_board_3d): Fusion's own view of the design's 3D PCB, fitted to the board, saved as an image... - `fusion_render_schematic` - Render a schematic sheet to a PNG without opening a window, through Fusion's own EXPORT IMAGE, and keep visual baselines: baseline set stores the render... - `fusion_route_net` - Create explicit net copper in the live Fusion canvas. - `fusion_routing_drc` - Run fresh native Autodesk DRC and read UL_BOARD.errors, including approved errors, warnings and airwires.@@ -666,7 +668,7 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_export_board_image` - Render the OPEN board (or schematic) to a PNG via EAGLE's EXPORT IMAGE with a layer preset: all, top_copper, bottom_copper, top_silkscreen, bottom_silkscreen... - `fusion_export_cloud_file` - Export the ACTIVE Fusion Design document to a local file on the Fusion machine in one of step, stl, f3d, iges, sat or smt (despite the name it does not fetch a... - `fusion_export_dwg` - Export 2D DWG.-- `fusion_export_dxf` - Export 2D DXF.+- `fusion_export_dxf` - Board layers to DXF, one file per layer (the standard's export_dxf), when layers is given: the outline (Edge.Cuts: Dimension and Milling), copper (tracks as... - `fusion_export_eagle_source` - Export the ACTIVE electronics document as plain EAGLE XML (.sch or .brd; the outputPath extension picks the format). - `fusion_export_f3d` - Export the active design as a Fusion archive (.f3d). - `fusion_export_fbx` - Export to FBX mesh.@@ -686,7 +688,7 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **View, state and capture**  - `fusion_activate_document` - Make a named open document the ACTIVE one before driving it.-- `fusion_document_info` - Metadata for the ACTIVE document: name, product type, save state, version, parent project.+- `fusion_document_info` - The active document (the standard's document_info): its name, kind (project, schematic, board, library, model), whether it is saved and has unsaved changes... - `fusion_interactive` - Is Fusion taking input RIGHT NOW? Reads the add-in's busy flag (a long bridge command in flight), Fusion's own hasActiveJobs, and the main thread's latency on... - `fusion_live_feed` - A ~1 fps look INTO Fusion while the bridge drives it, kept as a BOX-SIDE ring buffer (~10 min). - `fusion_open_document` - The EDA Bridge Standard's name for fusion_open_design: the same verb, same arguments and reply (marked aliasOf).@@ -705,7 +707,7 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_cancel_run` - STOP a library import that is in flight (fusion_install_library_bundle / fusion_create_library) - the user's escape hatch from a long run, wired to a Cancel... - `fusion_check_dialogs` - List every Fusion dialog open now, each with its title, text and buttons read through UI Automation (no screenshot needed). - `fusion_check_recovery` - Ask whether a cloud file has a crash-recovery document waiting, which would raise Fusion's recovery modal on open.-- `fusion_close_all_documents` - Close ALL open documents without save dialogs.+- `fusion_close_all_documents` - Close every open document without a save dialog; unsaved work is never lost by default (the standard's close_all_documents). - `fusion_close_document` - Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). - `fusion_close_window` - Close a DIALOG window by hwnd (WM_CLOSE, like clicking X) - for dialogs without Cancel/Escape handling (e.g. Recovered Documents). - `fusion_dialog_log` - The EDA Bridge Standard's name for fusion_action_ledger: the same verb, same arguments and reply (marked aliasOf).@@ -737,13 +739,16 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. **Other**  - `fusion_account` - WHO Fusion is signed in as on this machine, and whether this machine currently holds the Autodesk SEAT.+- `fusion_align_parts` - Line parts up on a common left, right, top, bottom or centre line, or space them evenly (the standard's align_parts), by footprint box (pads, placement and... - `fusion_apply_pours` - Draw copper pours from a plan: POLYGON per pour (net, layer 1 or 16, outline points, isolate, rank 1..6, width, thermals, orphans), skipping any already on the... - `fusion_apply_silkscreen` - Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts... - `fusion_change_pours` - Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. - `fusion_component_data` - Placement data for one part or every part on the live board, in the background: footprint, value (comment), library, side and copper layer, position and... - `fusion_component_pins` - Every pad of one part with its net, position, layer, size, shape and drill (mm), the same rows fusion_routing_state gives, for one part. - `fusion_copper_layers` - 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.+- `fusion_edit_parts` - Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. - `fusion_execute_text_command` - Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included).+- `fusion_generate_body` - The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet... - `fusion_get_preferences` - Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot... - `fusion_launch` - The EDA Bridge Standard's name for fusion_start: the same verb, same arguments and reply (marked aliasOf). - `fusion_layer_stack` - The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with...@@ -770,7 +775,9 @@ _244 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_prewarm` - Warm the bridge ahead of the first real call, WITHOUT starting Fusion: confirms the add-in is synced into every Fusion add-in folder, readies the space-free... - `fusion_read_rules` - Every design rule on the live board from Fusion's rule manager, disabled ones included: name, kind (Copper Clearance, Minimum Copper Width, Teardrop...)... - `fusion_read_sheet_file` - 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...+- `fusion_remove_graphics` - Remove board-level graphics (the standard's remove_graphics): lines, arcs, rectangles, circles, polygons, texts and dimensions, by id, or every one on a layer... - `fusion_remove_items` - Delete tracks and vias given exactly as fusion_routing_state returned them (track start, end, layer; via position).+- `fusion_remove_parts` - Remove placed parts by designator (the standard's remove_parts). - `fusion_remove_pours` - Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. - `fusion_repour` - Refill every copper pour on the live board (Fusion's ratsnest pass, with hidden fills shown again) and read the poured copper back: filled area per layer and... - `fusion_ripup_nets` - Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way.@@ -833,7 +840,7 @@ adom-wiki pkg install adom/fusion-bridge   # the skills pack for your AI  Auto-updates via `updateManifestUrl` (ab polls on launch + every 4h). Every release lands on the insiders manifest first, and the public manifest moves only after the pre-production gate, so the-two tiers can serve different versions. 244 verbs -+two tiers can serve different versions. 251 verbs - `fusion_describe '{}'` returns the full machine-readable catalog. More demos:  <video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/fusion-bridge/files/library2-3d-montage.mp4"></video>
bridge.json+16−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.12.3",+  "version": "1.12.4",   "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.",@@ -26,6 +26,7 @@     "fusion_activate_document",     "fusion_addin_status",     "fusion_alerts",+    "fusion_align_parts",     "fusion_apply_fusion_update",     "fusion_apply_pours",     "fusion_apply_silkscreen",@@ -84,6 +85,7 @@     "fusion_dismiss_dialogs",     "fusion_document_info",     "fusion_eagle_import_status",+    "fusion_edit_parts",     "fusion_electron_pan",     "fusion_electron_run",     "fusion_electron_select",@@ -113,6 +115,7 @@     "fusion_export_stl",     "fusion_export_usdz",     "fusion_fetch_optimized_glb",+    "fusion_generate_body",     "fusion_generate_package",     "fusion_get_app_state",     "fusion_get_design_info",@@ -132,6 +135,7 @@     "fusion_layers_in_use",     "fusion_library_manager",     "fusion_library_parts",+    "fusion_library_views",     "fusion_license_status",     "fusion_list_cloud_files",     "fusion_list_cloud_projects",@@ -183,8 +187,11 @@     "fusion_read_sheet_file",     "fusion_readiness",     "fusion_release_seat",+    "fusion_remove_graphics",     "fusion_remove_items",+    "fusion_remove_parts",     "fusion_remove_pours",+    "fusion_render_board_3d",     "fusion_render_schematic",     "fusion_repour",     "fusion_reset_workspace",@@ -398,7 +405,14 @@       "sch_reset_fields": 150,       "sch_create": 150,       "sch_sheet_pins": 90,-      "update_board_from_eagle": 300+      "update_board_from_eagle": 300,+      "generate_body": 120,+      "library_views": 600,+      "render_board_3d": 120,+      "edit_parts": 150,+      "align_parts": 150,+      "remove_graphics": 150,+      "remove_parts": 150     },     "prefixes": [       {
contracts/describe.json+259−30
@@ -84,6 +84,38 @@       "summary": "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",       "timeoutSeconds": 30     },+    {+      "example": {+        "mode": "bottom",+        "refs": [+          "C1",+          "C2",+          "C3"+        ]+      },+      "input": {+        "allowOutside": "bool",+        "allowOverlap": "bool",+        "by": "courtyard|anchor, default courtyard",+        "dryRun": "bool",+        "expectDocument": "required",+        "expectedRevision": "optional",+        "gapMm": "distribute spacing (default even between the outer two)",+        "mode": "left|right|top|bottom|centerX|centerY|distributeX|distributeY (required)",+        "refs": "[ref] (two or more, required)",+        "to": "mm: the line (default from the group)"+      },+      "longRunning": false,+      "name": "fusion_align_parts",+      "output": {+        "mismatches": "[{ref, asked, got}]",+        "revision": "str",+        "targets": "[{ref, x, y}]"+      },+      "statusVerb": null,+      "summary": "Line parts up on a common left, right, top, bottom or centre line, or space them evenly (the standard's align_parts), by footprint box (pads, placement and documentation outlines, keepouts) or by anchor point. Rotation and side are kept. A part pushed off the board or onto another part is refused unless allowOutside / allowOverlap. Moved by name and read back.",+      "timeoutSeconds": 150+    },     {       "example": {},       "input": {},@@ -888,23 +920,25 @@     },     {       "example": {-        "closeUntitled": true+        "includeScratch": true       },       "input": {-        "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs",-        "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs",-        "save": "optional bool default false"+        "discardUnsaved": "bool, default false: close changed documents without saving (force also works)",+        "includeScratch": "bool, default false: also close never-saved scratch documents (closeUntitled also works)",+        "save": "bool, default false: save each changed document, then close it"       },       "longRunning": false,       "name": "fusion_close_all_documents",       "output": {-        "closed": "[str]",-        "success": "bool",-        "wouldPrompt": "[str]|null"+        "closed": "[name]",+        "keptOpen": "[{name, why}]",+        "remaining": "int",+        "saved": "[name]",+        "success": "bool"       },       "statusVerb": null,-      "summary": "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.",-      "timeoutSeconds": 60+      "summary": "Close every open document without a save dialog; unsaved work is never lost by default (the standard's close_all_documents). A saved document with unsaved changes stays open and is listed in keptOpen, unless save (save it, then close: a new cloud version) or discardUnsaved (close without saving) says otherwise. Never-saved scratch documents close only with includeScratch, and a changed one only with discardUnsaved too. A named, never-saved, changed document is always kept: Fusion would ask where to save it.",+      "timeoutSeconds": 90     },     {       "example": {@@ -1201,14 +1235,20 @@       "longRunning": false,       "name": "fusion_document_info",       "output": {-        "isSaved": "bool",+        "data": "the add-in's reply (openDocuments ...)",+        "kind": "project|schematic|board|library|model|other",+        "modified": "bool",         "name": "str",+        "path": "Project/folder/name.ext|null",         "productType": "str",-        "success": "bool"+        "project": "str|null",+        "saved": "bool",+        "success": "bool",+        "version": "int|null"       },       "statusVerb": null,-      "summary": "Metadata for the ACTIVE document: name, product type, save state, version, parent project.",-      "timeoutSeconds": 30+      "summary": "The active document (the standard's document_info): its name, kind (project, schematic, board, library, model), whether it is saved and has unsaved changes, its cloud location, version and project. Read only; opens nothing. The add-in's fields (open documents and the rest) are kept beside them.",+      "timeoutSeconds": 20     },     {       "example": {@@ -1231,6 +1271,35 @@       "summary": "The run record of a background fusion_new_electronics_from_eagle import: stage (started | done | failed), documentName, partsExpected (elements in the .brd), partsPlaced (read back from the board), placed[], and the final result. Poll it every few seconds after the import returned its runId.",       "timeoutSeconds": 20     },+    {+      "example": {+        "edits": [+          {+            "fields": {+              "MPN": "RC0402FR-0710KL"+            },+            "ref": "R5",+            "value": "10k"+          }+        ]+      },+      "input": {+        "dryRun": "bool",+        "edits": "[{ref, value?, newRef?, fields?: {name: value | null}, locked?: bool}] (required)",+        "expectDocument": "required: the board's name",+        "expectedRevision": "optional"+      },+      "longRunning": false,+      "name": "fusion_edit_parts",+      "output": {+        "edited": "[{ref, after: {ref, value, fields, locked}}]",+        "failed": "[{ref, error}]",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. Fusion keeps board and schematic in step, so the edit is in the schematic too. dnp, excludeFromBom, excludeFromPos and mount are refused per part (Fusion keeps them in assembly variants).",+      "timeoutSeconds": 150+    },     {       "example": {         "x1": 0,@@ -1493,18 +1562,29 @@     },     {       "example": {-        "outputPath": "C:/out/sketch.dxf"+        "expectDocument": "BQ25792",+        "layers": [+          "Edge.Cuts",+          "F.Cu"+        ]       },       "input": {-        "outputPath": "required str"+        "contours": "bool: the outline as closed polylines",+        "expectDocument": "required for the board form: the board's name",+        "layers": "[Edge.Cuts, F.Cu, In1.Cu, B.Cu, F.SilkS, B.SilkS, F.Fab, B.Fab, Dwgs.User or Fusion layer numbers] (the board form)",+        "outputDir": "folder on the Fusion machine (default %TEMP%/adom-dxf)",+        "outputPath": "the mechanical form's .dxf",+        "units": "mm|in, default mm"       },       "longRunning": true,       "name": "fusion_export_dxf",       "output": {-        "format": "str"+        "empty": "[layer]",+        "files": "[{layer, path, entities}]",+        "units": "str"       },       "statusVerb": null,-      "summary": "Export 2D DXF.",+      "summary": "Board layers to DXF, one file per layer (the standard's export_dxf), when layers is given: the outline (Edge.Cuts: Dimension and Milling), copper (tracks as polylines carrying their width, arcs, vias, pads, Fusion's pour fill), silkscreen and fab (footprint and board lines, circles, rectangles, polygons, texts the readable way up). DXF R12, mm or inches, the board's origin, y up. Without layers it is Fusion's mechanical DXF of the active design (outputPath).",       "timeoutSeconds": 120     },     {@@ -1797,6 +1877,40 @@       "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": {+        "dimensionsMm": {+          "A": 1.75,+          "D": 4.9,+          "E": 6.0,+          "E1": 3.9,+          "e": 1.27,+          "pins": 8+        },+        "family": "soic",+        "marking": "LM358",+        "outputPath": "C:/parts/LM358.step"+      },+      "input": {+        "dimensionsMm": "{A, D, E, E1, b, e, L, pins ...} (missing ones take the family's defaults)",+        "family": "required: chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight ...",+        "marking": "text on the top face",+        "markingStyle": "raised|engraved",+        "outputPath": "the .step to write (on the Fusion machine)"+      },+      "longRunning": false,+      "name": "fusion_generate_body",+      "output": {+        "aliasOf": "fusion_generate_package",+        "bodies": "[{name,vol}]",+        "etched": "str|null",+        "manifest": "obj",+        "stepPath": "str"+      },+      "statusVerb": null,+      "summary": "The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, plus pins), optionally marked on top, written as a STEP. family is the generator's type; pins becomes its per-side DPins (two rows) or DPins and EPins (quad packages).",+      "timeoutSeconds": 120+    },     {       "example": {         "etch": "103",@@ -2155,6 +2269,28 @@       "summary": "Every ORDERABLE variant in a .lbr with its part numbers - offline, no Fusion needed. For an EE this is the point of the library: two variants can share ONE symbol, ONE footprint and ONE 3D body and still be different products (an AEC-Q100 automotive part vs the baseline one), and that difference lives ENTIRELY in the metadata. EAGLE spells the orderable identity as deviceset + device + technology: <device> carries the PACKAGE variant, <technology> carries the GRADE/temperature variant, and attributes hang off <technology>. Reports per-variant MPN / MANUFACTURER / MOUSER_PN / DIGIKEY_PN / LCSC_PN (the exact names this bridge's own BOM export reads), plus warnings when variants are indistinguishable, share an MPN, or a deviceset has no description. These warnings never block the library from opening - they say whether an EE could actually order from it.",       "timeoutSeconds": 15     },+    {+      "example": {+        "libraryPath": "C:/libs/power.lbr",+        "parts": [+          "TPS54202"+        ]+      },+      "input": {+        "libraryPath": "the .lbr on the Fusion machine",+        "parts": "[device set name]",+        "views": "[symbol, footprint, 3d, device] (default all)"+      },+      "longRunning": true,+      "name": "fusion_library_views",+      "output": {+        "aliasOf": "fusion_capture_library_views",+        "captured": "[{package, view, path, ok}]"+      },+      "statusVerb": null,+      "summary": "The EDA Bridge Standard's library_views, served by fusion_capture_library_views: pictures of library parts (symbol, footprint, 3D body on the footprint, and the device view mapping pins to pads), each an image file.",+      "timeoutSeconds": 600+    },     {       "example": {},       "input": {@@ -2916,33 +3052,42 @@       "timeoutSeconds": 20     },     {-      "example": {},+      "example": {+        "from": "U1.12",+        "net": "SDA",+        "to": "J3.4"+      },       "input": {         "allowVias": "bool default true",-        "boardId": "required str",-        "expectDocument": "required str",-        "expectedRevision": "required str",-        "fromPad": "required str",-        "grid": "mm 0.1..5 default 0.5",+        "boardId": "optional: checked when given",+        "expectDocument": "required: the board's name",+        "expectedRevision": "optional: checked when given",+        "from": "the start pad as Ref.Pad (fromPad also works)",+        "gridMm": "0.1..5, default 0.5 (grid also works)",         "maxNodes": "int 1..100000 default 25000",         "net": "required str",         "timeoutMs": "int 10..5000 default 3000",-        "toPad": "required str",-        "viaCost": "mm-equivalent cost default 5",-        "viaDiameter": "mm default 0.6",-        "viaDrill": "mm default 0.3",-        "width": "mm default 0.25"+        "to": "the end pad (toPad also works)",+        "viaCost": "a via costs as much as this many mm, default 5",+        "viaDrillMm": "default 0.3 (viaDrill also works)",+        "viaSizeMm": "default 0.6 (viaDiameter also works)",+        "widthMm": "track width, default 0.25 (width also works)"       },       "longRunning": false,       "name": "fusion_plan_route",       "output": {+        "found": "bool",+        "lengthMm": "number",         "mutated": "false",         "plan": "{items,route,planner}",+        "reason": "str (found false)",         "revision": "str",-        "success": "bool"+        "routeNet": "fusion_route_net's arguments",+        "success": "bool",+        "vias": "int"       },       "statusVerb": "fusion_routing_state",-      "summary": "Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board. Uses existing copper and the shared conservative clearance model. Returns explicit route args for route_net; native DRC is still required after mutation.",+      "summary": "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route): a bounded orthogonal two-layer A* search around the existing copper and the shared conservative clearance model, with vias where they help. Answered as routeNet, this bridge's fusion_route_net arguments, to pass on unchanged; found:false with a reason when no path fits the search limits. Native DRC is still the proof after the route.",       "timeoutSeconds": 60     },     {@@ -3132,6 +3277,33 @@       "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": {+        "dryRun": true,+        "kinds": [+          "text"+        ],+        "layer": "F.SilkS"+      },+      "input": {+        "dryRun": "bool (lists ids)",+        "expectDocument": "required",+        "expectedRevision": "optional",+        "ids": "[id]",+        "kinds": "[line|arc|rect|circle|polygon|text|dimension]",+        "layer": "F.SilkS, B.SilkS, F.Fab, B.Fab, Edge.Cuts, a copper name or a Fusion layer number"+      },+      "longRunning": false,+      "name": "fusion_remove_graphics",+      "output": {+        "count": "int (dry run)",+        "removed": "[{id, kind, layer, points, text}]",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "Remove board-level graphics (the standard's remove_graphics): lines, arcs, rectangles, circles, polygons, texts and dimensions, by id, or every one on a layer and/or of a kind. Fusion stores no ids: an id is kind:layer:points, and a dry run lists them. Each is picked with only its layer shown; the rest of the board must read back unchanged, or every DELETE is undone. Graphics inside parts are not touched.",+      "timeoutSeconds": 150+    },     {       "example": {         "vias": [@@ -3161,6 +3333,32 @@       "summary": "Delete tracks and vias given exactly as fusion_routing_state returned them (track start, end, layer; via position). The board is read back as the old one minus exactly those items, or every removal is undone. results[] is per item: ok or err:not found.",       "timeoutSeconds": 180     },+    {+      "example": {+        "dryRun": true,+        "refs": [+          "R7",+          "R8"+        ]+      },+      "input": {+        "allowLocked": "bool, default false",+        "dryRun": "bool",+        "expectDocument": "required: the board's name",+        "expectedRevision": "optional",+        "refs": "[ref] (required)"+      },+      "longRunning": false,+      "name": "fusion_remove_parts",+      "output": {+        "failed": "[{ref, error}]",+        "removed": "[{ref, nets}]",+        "revision": "str"+      },+      "statusVerb": null,+      "summary": "Remove placed parts by designator (the standard's remove_parts). Fusion keeps the board in step with the schematic and ignores a part deleted on the board, so the part is deleted in the design's schematic and leaves the board with it. Each removed part lists the nets its pads carried; a locked part is refused unless allowLocked. Tracks that met its pads stay.",+      "timeoutSeconds": 150+    },     {       "example": {},       "input": {@@ -3181,6 +3379,37 @@       "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": {+        "background": "transparent",+        "side": "iso"+      },+      "input": {+        "background": "default|transparent",+        "expectDocument": "optional: the design's name",+        "format": "png|jpg",+        "height": "int px, default 900",+        "outputPath": "the image on the Fusion machine (default %TEMP%/adom-render/board-<side>.<format>)",+        "perspective": "bool, default false",+        "quality": "basic (high, a ray-traced render, is refused)",+        "side": "top|bottom|left|right|front|back|iso, default top",+        "width": "int px, default 1600",+        "zoom": "number, default 1.0"+      },+      "longRunning": false,+      "name": "fusion_render_board_3d",+      "output": {+        "design": "str",+        "height": "int",+        "path": "str",+        "side": "str",+        "success": "bool",+        "width": "int"+      },+      "statusVerb": null,+      "summary": "A 3D picture of the board from one side (the standard's render_board_3d): Fusion's own view of the design's 3D PCB, fitted to the board, saved as an image without the user's tab changing (it is put back). Orthographic by default; transparent background available for png.",+      "timeoutSeconds": 120+    },     {       "example": {         "baseline": "compare"
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.12.3"+version = "1.12.4" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.3"+version = "1.12.4" edition = "2021"  [dependencies]
describe.py+40−6
@@ -16,9 +16,9 @@ _T = [     ("fusion_routing_state", "Read the live board as the EDA Bridge Standard's board model, from a fresh Fusion export in the background: millimetres in Fusion's board frame, copper layers named F.Cu / In1.Cu / B.Cu, tracks, arcs (center, radius, angles), vias, every pad with its size, shape, rotation and net (a custom-shape pad as its bounding rectangle), parts with side and footprint, pours, net-less copper kept with an empty net, the outline as edges with its arcs, and unconnected / splitNets computed from the copper itself. The revision changes only with copper, parts or outline: edit verbs take it back as expectedRevision. Also carries the AI flow's keys (segments, elements, airwireCount, remainingNets, each pad's key REF.PAD).",      {"expectDocument":"required exact active board name","boardId":"optional document creationId guard"},      {"model":"'eda-bridge-standard/board 0.1'","boardId":"str","revision":"str","units":"mm","copperLayers":"[F.Cu..B.Cu]","nets":"[name]","tracks":"[{start,end,width,layer,net}]","arcs":"[{center,radius,startAngle,endAngle,width,layer,net}]","vias":"[{position,size,drill,layers,net}]","pads":"[{ref,name,key,x,y,sizeX,sizeY,rotation,layer,drill,net,shape,customShape?}]","components":"[{ref,x,y,rotation,side,pattern,has3d}]","polygons":"[{net,layer,points}]","outline":"[[x,y]] (arcs sampled every 2 degrees)","outlineEdges":"[{kind:line|arc,start,end,center?,radius?,angle1?,angle2?}]","unconnected":"int from the copper","splitNets":"[{net,groups}]","fusionAirwires":"int, Fusion's own ratsnest","segments":"list (AI flow)","remainingNets":"list (AI flow)"},60,"fusion_routing_state",False,{"expectDocument":"fusion-routing"}),-    ("fusion_plan_route", "Calculate a read-only, bounded orthogonal A* path between two pads on a live two-layer board. Uses existing copper and the shared conservative clearance model. Returns explicit route args for route_net; native DRC is still required after mutation.",-     {"expectDocument":"required str","boardId":"required str","expectedRevision":"required str","net":"required str","fromPad":"required str","toPad":"required str","width":"mm default 0.25","viaDiameter":"mm default 0.6","viaDrill":"mm default 0.3","grid":"mm 0.1..5 default 0.5","allowVias":"bool default true","viaCost":"mm-equivalent cost default 5","maxNodes":"int 1..100000 default 25000","timeoutMs":"int 10..5000 default 3000"},-     {"success":"bool","mutated":"false","revision":"str","plan":"{items,route,planner}"},60,"fusion_routing_state",False,{}),+    ("fusion_plan_route", "Plan a path for one net between two pads on the live board without changing it (the standard's plan_route): a bounded orthogonal two-layer A* search around the existing copper and the shared conservative clearance model, with vias where they help. Answered as routeNet, this bridge's fusion_route_net arguments, to pass on unchanged; found:false with a reason when no path fits the search limits. Native DRC is still the proof after the route.",+     {"expectDocument":"required: the board's name","net":"required str","from":"the start pad as Ref.Pad (fromPad also works)","to":"the end pad (toPad also works)","widthMm":"track width, default 0.25 (width also works)","viaSizeMm":"default 0.6 (viaDiameter also works)","viaDrillMm":"default 0.3 (viaDrill also works)","gridMm":"0.1..5, default 0.5 (grid also works)","allowVias":"bool default true","viaCost":"a via costs as much as this many mm, default 5","maxNodes":"int 1..100000 default 25000","timeoutMs":"int 10..5000 default 3000","boardId":"optional: checked when given","expectedRevision":"optional: checked when given"},+     {"success":"bool","mutated":"false","found":"bool","routeNet":"fusion_route_net's arguments","lengthMm":"number","vias":"int","reason":"str (found false)","plan":"{items,route,planner}","revision":"str"},60,"fusion_routing_state",False,{"net":"SDA","from":"U1.12","to":"J3.4"}),     ("fusion_route_net", "Create explicit net copper in the live Fusion canvas. Conservative clearance preflight, board/revision guards and exact copper readback. One or several trace segments per call; no general autorouting. Use routing_drc for native DRC.",      {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","net":"required exact name","points":"waypoints [x,y] or {pad} or {x,y,layer}","paths":"optional list of paths instead of points","width":"mm, default .25","layer":"F.Cu or 1, B.Cu or 16; default F.Cu","viaDiameter":"mm, default .6","viaDrill":"mm, default .3","clearance":"mm lower bound, default .2","dryRun":"optional bool"},      {"success":"bool","mutated":"bool","verified":"bool","revision":"str","plan":"obj","remainingNets":"list"},120,"fusion_routing_state",False,{}),@@ -203,6 +203,34 @@ _T = [      {"brdPath": "required: .brd or .fbrd on the Fusion machine", "expectDocument": "required: the open board's name", "expectedRevision": "optional", "save": "bool, default false: save in place after a verified read back", "comment": "the version comment", "dryRun": "bool: the plan only"},      {"success": "bool", "verified": "bool", "plan": "{partsMoved, copper, pours, outline, looseCopper, holes}", "mismatched": "[str]", "notSynced": "[str]", "saved": "bool", "save": "fusion_save_document's reply", "unconnected": "int", "revision": "str"},      300, None, True, {"brdPath": "C:/work/board.brd", "expectDocument": "Buck12V5V", "save": True}),+    ("fusion_generate_body", "The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet dimensions (JEDEC letters A, D, E, E1, b, e, L, plus pins), optionally marked on top, written as a STEP. family is the generator's type; pins becomes its per-side DPins (two rows) or DPins and EPins (quad packages).",+     {"family": "required: chip|soic|qfn|qfp|bga|sot23|dfn2|melf|ecap|crystal|header_straight ...", "dimensionsMm": "{A, D, E, E1, b, e, L, pins ...} (missing ones take the family's defaults)", "marking": "text on the top face", "markingStyle": "raised|engraved", "outputPath": "the .step to write (on the Fusion machine)"},+     {"aliasOf": "fusion_generate_package", "stepPath": "str", "bodies": "[{name,vol}]", "etched": "str|null", "manifest": "obj"},+     120, None, False, {"family": "soic", "dimensionsMm": {"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "e": 1.27}, "marking": "LM358", "outputPath": "C:/parts/LM358.step"}),+    ("fusion_library_views", "The EDA Bridge Standard's library_views, served by fusion_capture_library_views: pictures of library parts (symbol, footprint, 3D body on the footprint, and the device view mapping pins to pads), each an image file.",+     {"libraryPath": "the .lbr on the Fusion machine", "parts": "[device set name]", "views": "[symbol, footprint, 3d, device] (default all)"},+     {"aliasOf": "fusion_capture_library_views", "captured": "[{package, view, path, ok}]"},+     600, None, True, {"libraryPath": "C:/libs/power.lbr", "parts": ["TPS54202"]}),+    ("fusion_render_board_3d", "A 3D picture of the board from one side (the standard's render_board_3d): Fusion's own view of the design's 3D PCB, fitted to the board, saved as an image without the user's tab changing (it is put back). Orthographic by default; transparent background available for png.",+     {"side": "top|bottom|left|right|front|back|iso, default top", "width": "int px, default 1600", "height": "int px, default 900", "background": "default|transparent", "zoom": "number, default 1.0", "perspective": "bool, default false", "format": "png|jpg", "outputPath": "the image on the Fusion machine (default %TEMP%/adom-render/board-<side>.<format>)", "quality": "basic (high, a ray-traced render, is refused)", "expectDocument": "optional: the design's name"},+     {"success": "bool", "path": "str", "side": "str", "width": "int", "height": "int", "design": "str"},+     120, None, False, {"side": "iso", "background": "transparent"}),+    ("fusion_edit_parts", "Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. Fusion keeps board and schematic in step, so the edit is in the schematic too. dnp, excludeFromBom, excludeFromPos and mount are refused per part (Fusion keeps them in assembly variants).",+     {"edits": "[{ref, value?, newRef?, fields?: {name: value | null}, locked?: bool}] (required)", "expectDocument": "required: the board's name", "expectedRevision": "optional", "dryRun": "bool"},+     {"edited": "[{ref, after: {ref, value, fields, locked}}]", "failed": "[{ref, error}]", "revision": "str"},+     150, None, False, {"edits": [{"ref": "R5", "value": "10k", "fields": {"MPN": "RC0402FR-0710KL"}}]}),+    ("fusion_align_parts", "Line parts up on a common left, right, top, bottom or centre line, or space them evenly (the standard's align_parts), by footprint box (pads, placement and documentation outlines, keepouts) or by anchor point. Rotation and side are kept. A part pushed off the board or onto another part is refused unless allowOutside / allowOverlap. Moved by name and read back.",+     {"refs": "[ref] (two or more, required)", "mode": "left|right|top|bottom|centerX|centerY|distributeX|distributeY (required)", "by": "courtyard|anchor, default courtyard", "to": "mm: the line (default from the group)", "gapMm": "distribute spacing (default even between the outer two)", "allowOverlap": "bool", "allowOutside": "bool", "expectDocument": "required", "expectedRevision": "optional", "dryRun": "bool"},+     {"targets": "[{ref, x, y}]", "mismatches": "[{ref, asked, got}]", "revision": "str"},+     150, None, False, {"refs": ["C1", "C2", "C3"], "mode": "bottom"}),+    ("fusion_remove_graphics", "Remove board-level graphics (the standard's remove_graphics): lines, arcs, rectangles, circles, polygons, texts and dimensions, by id, or every one on a layer and/or of a kind. Fusion stores no ids: an id is kind:layer:points, and a dry run lists them. Each is picked with only its layer shown; the rest of the board must read back unchanged, or every DELETE is undone. Graphics inside parts are not touched.",+     {"ids": "[id]", "layer": "F.SilkS, B.SilkS, F.Fab, B.Fab, Edge.Cuts, a copper name or a Fusion layer number", "kinds": "[line|arc|rect|circle|polygon|text|dimension]", "expectDocument": "required", "expectedRevision": "optional", "dryRun": "bool (lists ids)"},+     {"removed": "[{id, kind, layer, points, text}]", "count": "int (dry run)", "revision": "str"},+     150, None, False, {"layer": "F.SilkS", "kinds": ["text"], "dryRun": True}),+    ("fusion_remove_parts", "Remove placed parts by designator (the standard's remove_parts). Fusion keeps the board in step with the schematic and ignores a part deleted on the board, so the part is deleted in the design's schematic and leaves the board with it. Each removed part lists the nets its pads carried; a locked part is refused unless allowLocked. Tracks that met its pads stay.",+     {"refs": "[ref] (required)", "allowLocked": "bool, default false", "expectDocument": "required: the board's name", "expectedRevision": "optional", "dryRun": "bool"},+     {"removed": "[{ref, nets}]", "failed": "[{ref, error}]", "revision": "str"},+     150, None, False, {"refs": ["R7", "R8"], "dryRun": True}),     ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",      {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},      {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),@@ -454,9 +482,13 @@ _T = [     ("fusion_open_cloud_file", "Open a CLOUD file by name (the front door to every design flow). PCB/ELECTRONICS NAV RULE: open the electronics DESIGN document (EcadDesignProductType), never a child .brd/.sch/3D directly - then switch views with fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board. LONG-RUNNING: fire it, then poll fusion_get_app_state until activeDocument matches (cloud opens on electronics designs take 1-3 min). A crash-recovery modal can block the open - fusion_dismiss_blocking_dialogs.", {"fileName": "required str", "projectName": "optional str (defaults to active project)", "folderPath": "optional str"}, {"success": "bool", "documentName": "str"}, 240, "fusion_get_app_state", True, {"fileName": "BQ25792", "projectName": "Main"}),     ("fusion_open_design", "Open a LOCAL design file (routes through the add-in import). Prefer cloud flows (fusion_open_cloud_file) for team designs; this is for local .f3d/.step style files.", {"filePath": "required str (WINDOWS path)", "intent": "optional \"mechanical\" | \"electronics\" (see fusion_open_by_urn)"}, {"success": "bool"}, 240, None, True, {"filePath": "C:/Temp/part.f3d"}),     ("fusion_close_document", "Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). Omit name to close the active document.", {"name": "optional str (default: active document)", "save": "optional bool default false"}, {"success": "bool", "closed": "str"}, 60, None, False, {"save": False}),-    ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, {"success": "bool", "closed": "[str]", "wouldPrompt": "[str]|null"}, 60, None, False, {"closeUntitled": True}),+    ("fusion_close_all_documents", "Close every open document without a save dialog; unsaved work is never lost by default (the standard's close_all_documents). A saved document with unsaved changes stays open and is listed in keptOpen, unless save (save it, then close: a new cloud version) or discardUnsaved (close without saving) says otherwise. Never-saved scratch documents close only with includeScratch, and a changed one only with discardUnsaved too. A named, never-saved, changed document is always kept: Fusion would ask where to save it.",+     {"save":"bool, default false: save each changed document, then close it","discardUnsaved":"bool, default false: close changed documents without saving (force also works)","includeScratch":"bool, default false: also close never-saved scratch documents (closeUntitled also works)"},+     {"success":"bool","closed":"[name]","saved":"[name]","keptOpen":"[{name, why}]","remaining":"int"},90,None,False,{"includeScratch":True}),     ("fusion_reset_workspace", "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.", {"force": "optional bool default false - also discard MODIFIED Untitled docs"}, {"success": "bool", "closedByAddin": "list", "untitledScratchClosed": "int", "remaining": "list"}, 90, None, False, {}),-    ("fusion_document_info", "Metadata for the ACTIVE document: name, product type, save state, version, parent project.", {}, {"success": "bool", "name": "str", "productType": "str", "isSaved": "bool"}, 30, None, False, {}),+    ("fusion_document_info", "The active document (the standard's document_info): its name, kind (project, schematic, board, library, model), whether it is saved and has unsaved changes, its cloud location, version and project. Read only; opens nothing. The add-in's fields (open documents and the rest) are kept beside them.",+     {},+     {"success":"bool","name":"str","kind":"project|schematic|board|library|model|other","saved":"bool","modified":"bool","path":"Project/folder/name.ext|null","version":"int|null","project":"str|null","productType":"str","data":"the add-in's reply (openDocuments ...)"},20,None,False,{}),     ("fusion_execute_text_command", "Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included). Electron.* commands need an ELECTRONICS document context (schematic/board view active). Guard risky calls with expectDocument.", {"command": "required str", "expectDocument": "optional str (refuses wrong_document if the active doc does not match)"}, {"success": "bool", "output": "str"}, 60, None, False, {"command": "Electron.ImmediateRepaint /full"}),     ("fusion_get_design_info", "Design-level summary of the active design: components, bodies, sketches, units, root name.", {}, {"success": "bool", "components": "int", "bodies": "int"}, 60, None, False, {}),     ("fusion_get_parameters", "All user + model parameters of the active design (name, expression, value, unit).", {}, {"success": "bool", "parameters": "list"}, 60, None, False, {}),@@ -501,7 +533,9 @@ _T = [     ("fusion_fetch_optimized_glb", "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.", {"jobId": "str (service job form)", "exportId": "str (async form from export_optimized_glb async:true - returns {phase, done, glbPath} instantly per poll)", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),     ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),     ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),-    ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),+    ("fusion_export_dxf", "Board layers to DXF, one file per layer (the standard's export_dxf), when layers is given: the outline (Edge.Cuts: Dimension and Milling), copper (tracks as polylines carrying their width, arcs, vias, pads, Fusion's pour fill), silkscreen and fab (footprint and board lines, circles, rectangles, polygons, texts the readable way up). DXF R12, mm or inches, the board's origin, y up. Without layers it is Fusion's mechanical DXF of the active design (outputPath).",+     {"layers":"[Edge.Cuts, F.Cu, In1.Cu, B.Cu, F.SilkS, B.SilkS, F.Fab, B.Fab, Dwgs.User or Fusion layer numbers] (the board form)","outputDir":"folder on the Fusion machine (default %TEMP%/adom-dxf)","units":"mm|in, default mm","contours":"bool: the outline as closed polylines","expectDocument":"required for the board form: the board's name","outputPath":"the mechanical form's .dxf"},+     {"files":"[{layer, path, entities}]","units":"str","empty":"[layer]"},120,None,True,{"layers":["Edge.Cuts","F.Cu"],"expectDocument":"BQ25792"}),     ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dwg"}),      # ── Manufacturing ──────────────────────────────────────────────────────
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.3"+DASH_VERSION="1.12.4" 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.12.3",+  "version": "1.12.4",   "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.3",+  "version": "1.12.4",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -143,4 +143,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.12.3"+version = "1.12.4" edition = "2021" license = "MIT" 
rust/bridge/src/docs.rsadded+46
@@ -0,0 +1,46 @@+//! document_info and close_all_documents in the EDA Bridge Standard's shape (docs_script.py on+//! Fusion's main thread). document_info keeps the add-in's reply and adds the standard's fields.+use serde_json::{json, Value};+const SOURCE: &str = include_str!("docs_script.py");+pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d") }+fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let s = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",+        serde_json::to_string(SOURCE).unwrap(), serde_json::to_string(&args.to_string()).unwrap(), serde_json::to_string(cmd).unwrap());+    let r = pass("run_modeling_script", json!({"script": s}));+    if r["success"].as_bool() == Some(false) { return r; }+    r["data"]["result"].clone()+}+pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    match cmd {+        "document_info" => {+            let mut old = pass("document_info", args.clone());+            let std_ = script(cmd, args, pass);+            if std_["success"].as_bool() == Some(true) {+                for k in ["name", "kind", "saved", "modified", "path", "version", "project"] { old[k] = std_[k].clone(); }+            }+            old+        }+        // no 3D PCB open: show it (the bridge does it, not the caller), render, then put the user's+        // tab back, since showing the 3D PCB brings it to the front+        "render_board_3d" => {+            let r = script(cmd, args, pass);+            if r["errorCode"] != "no_3d_board" { return r; }+            let front = script("front_tab", &json!({}), pass);+            let shown = crate::orchestrations::show_with_document("show_3d_board", &json!({"documentName": args["expectDocument"]}), pass);+            let mut r2 = script(cmd, args, pass);+            if let Some(i) = front["index"].as_i64() { let _ = script("switch_tab", &json!({"index": i}), pass); }+            r2["shown3d"] = json!(shown["success"].as_bool() == Some(true));+            r2+        }+        _ => script(cmd, args, pass),+    }+}++/// The standard's library_views in fusion_capture_library_views' terms: parts -> packages, view+/// device -> component (the capture opens the library itself when it is not open).+pub fn library_views_args(args: &Value) -> Value {+    let mut a = args.clone();+    if let Some(p) = args["parts"].as_array() { a["packages"] = json!(p); }+    if let Some(v) = args["views"].as_array() { a["views"] = json!(v.iter().map(|x| if x == "device" { json!("component") } else { x.clone() }).collect::<Vec<_>>()); }+    a+}
rust/bridge/src/docs_script.pyadded+170
@@ -0,0 +1,170 @@+"""Document verbs in the EDA Bridge Standard's shape (document_info, close_all_documents), run on+Fusion's main thread through the script transport (docs.rs embeds this file)."""++KIND = {'EcadDesignProductType': 'project', 'SchematicProductType': 'schematic', 'BoardProductType': 'board',+        'DesignProductType': 'model', 'ElectronLibraryProductType': 'library', 'ElectronicsLibraryProductType': 'library'}+++def _pt(d):+    try:+        return d.products.item(0).productType if d.products.count else ''+    except Exception:+        return ''+++def _location(df):+    """'Project/folder/sub/name.ext' for a saved file."""+    try:+        parts, f = [], df.parentFolder+        while f is not None and not f.isRoot:+            parts.append(f.name)+            f = f.parentFolder+        return '/'.join([df.parentProject.name] + list(reversed(parts)) + ['%s.%s' % (df.name, df.fileExtension)])+    except Exception:+        return None+++def doc_info(app, args):+    d = app.activeDocument+    if d is None:+        return {'success': False, 'errorCode': 'no_document', 'error': 'No document is open'}+    df = d.dataFile+    pt = _pt(d)+    return {'success': True, 'name': d.name, 'kind': KIND.get(pt, 'other'), 'productType': pt,+            'saved': df is not None, 'modified': bool(d.isModified), 'path': _location(df) if df else None,+            'version': (df.versionNumber if df else None), 'project': (df.parentProject.name if df else None)}+++def close_all(app, args):+    """Close every document without a save dialog; unsaved work is never lost by default."""+    save = args.get('save') is True+    discard = args.get('discardUnsaved') is True or args.get('force') is True+    scratch = args.get('includeScratch') is True or args.get('closeUntitled') is True+    closed, saved, kept = [], [], []+    for d in [app.documents.item(i) for i in range(app.documents.count)]:+        name = d.name+        try:+            df = d.dataFile+            modified = bool(d.isModified)+            if df is None:+                # never saved: a scratch document; a named one with changes would raise Fusion's Save box+                if not scratch:+                    kept.append({'name': name, 'why': 'scratch (never saved): pass includeScratch to close it'})+                    continue+                if modified and not discard:+                    kept.append({'name': name, 'why': 'scratch with unsaved changes: pass discardUnsaved to lose them'})+                    continue+                if modified and not name.lower().startswith('untitled'):+                    kept.append({'name': name, 'why': 'never saved and changed: Fusion would ask where to save it; save it with fusion_save_to_cloud first'})+                    continue+                d.close(False)+                closed.append(name)+                continue+            if modified:+                if save:+                    d.save('fusion-bridge: saved before close')+                    saved.append(name)+                elif not discard:+                    kept.append({'name': name, 'why': 'unsaved changes: pass save or discardUnsaved'})+                    continue+            d.close(False)+            closed.append(name)+        except Exception as e:+            kept.append({'name': name, 'why': 'Fusion refused: %s' % e})+    return {'success': True, 'closed': closed, 'saved': saved, 'keptOpen': kept, 'remaining': app.documents.count,+            '_hint': 'keptOpen lists what still needs a decision. Fusion keeps one blank design open when the last document closes.' if kept else+                     'Everything asked was closed. Fusion keeps one blank design open when the last document closes.'}+++def _design_3d(app, args):+    """The 3D PCB of the design in front (or named by expectDocument): its DesignProductType tab of+    the same name (and the same cloud folder when saved)."""+    docs = [app.documents.item(i) for i in range(app.documents.count)]+    anchor = app.activeDocument+    want = args.get('expectDocument')+    if want:+        named = [d for d in docs if d.name == want]+        if not named:+            return None, None, 'No open document is named %s' % want+        anchor = named[0]+    if anchor is None:+        return None, None, 'No document is open'+    if _pt(anchor) == 'DesignProductType':+        return docs.index(anchor), anchor, None+    for i, d in enumerate(docs):+        if _pt(d) == 'DesignProductType' and d.name == anchor.name:+            if anchor.dataFile is None or d.dataFile is None or d.dataFile.parentFolder.id == anchor.dataFile.parentFolder.id:+                return i, d, None+    return None, None, 'The design %s has no 3D PCB open: show it once with fusion_show_3d_board' % anchor.name+++SIDES = {'top': 'TopViewOrientation', 'bottom': 'BottomViewOrientation', 'left': 'LeftViewOrientation', 'right': 'RightViewOrientation',+         'front': 'FrontViewOrientation', 'back': 'BackViewOrientation', 'iso': 'IsoTopRightViewOrientation'}+++def render_board_3d(app, args):+    """A 3D picture of the board from one side, through Fusion's own viewport, with the tab the+    user had put back afterwards."""+    import adsk.core+    side = (args.get('side') or 'top').lower()+    if side not in SIDES:+        return {'success': False, 'errorCode': 'bad_args', 'error': 'side is top, bottom, left, right, front, back or iso'}+    if (args.get('quality') or 'basic') == 'high':+        return {'success': False, 'errorCode': 'unsupported', 'error': 'quality high (a ray-traced render) is not offered: Fusion renders through its Render workspace, which this verb does not drive'}+    fmt = (args.get('format') or 'png').lower()+    out = args.get('outputPath')+    if not out:+        import tempfile, os+        out = os.path.join(tempfile.gettempdir(), 'adom-render', 'board-%s.%s' % (side, fmt)).replace('\\', '/')+    import os+    os.makedirs(os.path.dirname(out) or '.', exist_ok=True)+    i, doc, err = _design_3d(app, args)+    if err:+        return {'success': False, 'errorCode': 'no_3d_board', 'error': err}+    docs = [app.documents.item(k) for k in range(app.documents.count)]+    front = docs.index(app.activeDocument) if app.activeDocument in docs else None+    try:+        app.executeTextCommand('UI.SwitchToTab %d' % i)+        vp = app.activeViewport+        cam = vp.camera+        cam.viewOrientation = getattr(adsk.core.ViewOrientations, SIDES[side])+        cam.cameraType = adsk.core.CameraTypes.PerspectiveCameraType if args.get('perspective') else adsk.core.CameraTypes.OrthographicCameraType+        cam.isFitView = True+        cam.isSmoothTransition = False+        vp.camera = cam+        zoom = float(args.get('zoom', 1.0))+        if zoom and abs(zoom - 1.0) > 1e-6:+            cam = vp.camera+            cam.viewExtents = cam.viewExtents / (zoom * zoom)+            cam.isSmoothTransition = False+            vp.camera = cam+        w, h = int(args.get('width', 1600)), int(args.get('height', 900))+        opts = adsk.core.SaveImageFileOptions.create(out)+        opts.width, opts.height = w, h+        opts.isAntiAliased = True+        opts.isBackgroundTransparent = (args.get('background') == 'transparent') and fmt == 'png'+        ok = vp.saveAsImageFileWithOptions(opts)+    finally:+        if front is not None and front != i:+            app.executeTextCommand('UI.SwitchToTab %d' % front)+    if not ok or not os.path.exists(out):+        return {'success': False, 'errorCode': 'render_failed', 'error': 'Fusion did not write %s' % out}+    return {'success': True, 'path': out, 'side': side, 'width': w, 'height': h, 'format': fmt, 'design': doc.name,+            'background': 'transparent' if opts.isBackgroundTransparent else 'default',+            '_hint': 'Fusion\'s own 3D view of the design\'s 3D PCB, fitted to the board from that side; the tab you had is back in front. A 3D PCB out of date with the board shows the old placement: fusion_show_3d_board updates it.'}+++def handle(app, args, action):+    if action == 'front_tab':+        docs = [app.documents.item(i) for i in range(app.documents.count)]+        return {'success': True, 'index': docs.index(app.activeDocument) if app.activeDocument in docs else None}+    if action == 'switch_tab':+        app.executeTextCommand('UI.SwitchToTab %d' % int(args['index']))+        return {'success': True}+    if action == 'render_board_3d':+        return render_board_3d(app, args)+    if action == 'document_info':+        return doc_info(app, args)+    if action == 'close_all_documents':+        return close_all(app, args)+    return {'success': False, 'errorCode': 'unknown_action', 'error': action}
rust/bridge/src/main.rs+34−3
@@ -41,6 +41,7 @@ mod janitor; mod electronics; mod routing; mod sch;+mod docs; mod sheet; mod bodies; mod display;@@ -69,6 +70,31 @@ const STANDARD_ALIASES: &[(&str, &str)] = &[     ("show_3d_chip", "fusion_show_3d_package"), ]; +/// The standard's verbs this bridge serves through its own, with the arguments renamed:+/// generate_body -> fusion_generate_package (family -> type, dimensionsMm -> params with pins as+/// the generator's per-side DPins/EPins, marking -> etch, markingStyle -> etchStyle, outputPath ->+/// outputStep). (library_views is docs.rs: it opens the library first.)+fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) {+    let bare = verb.strip_prefix("fusion_").unwrap_or(verb);+    let a = parsed["args"].clone();+    match bare {+        "generate_body" => {+            let family = a["family"].as_str().unwrap_or("").to_lowercase();+            let mut params = a["dimensionsMm"].as_object().cloned().unwrap_or_default();+            if let Some(n) = params.remove("pins").and_then(|v| v.as_u64()) {+                let quad = ["qfn", "qfp", "lqfp", "tqfp", "pqfp", "vqfn", "wqfn"].iter().any(|q| family.starts_with(q));+                if quad { params.insert("DPins".into(), json!(n / 4)); params.insert("EPins".into(), json!(n / 4)); }+                else { params.insert("DPins".into(), json!(n / 2)); }+            }+            let mut na = json!({"type": family, "params": params});+            for (from, to) in [("marking", "etch"), ("markingStyle", "etchStyle"), ("outputPath", "outputStep")] { if !a[from].is_null() { na[to] = a[from].clone(); } }+            parsed["args"] = na; parsed["command"] = json!("fusion_generate_package");+            ("fusion_generate_package".into(), parsed)+        }+        _ => (verb.to_string(), parsed),+    }+}+ fn standard_alias(asked: &str) -> Option<&'static str> {     let bare = asked.strip_prefix("fusion_").unwrap_or(asked);     STANDARD_ALIASES.iter().find(|(std_name, _)| *std_name == bare).map(|(_, own)| *own)@@ -403,6 +429,11 @@ fn main() {                     Some(own) => { let mut p = parsed.clone(); p["command"] = json!(own); (own.to_string(), p.to_string()) }                     None => (asked.clone(), raw),                 };+                // export_dxf with layers is the standard's board-layer DXF; without, Fusion's mechanical DXF+                let verb = if matches!(verb.as_str(), "fusion_export_dxf" | "export_dxf") && parsed["args"]["layers"].is_array() { "fusion_board_dxf".to_string() } else { verb };+                // the standard's generate_body and library_views, in this bridge's own verbs' terms+                let (verb, parsed) = standard_adapter(&verb, parsed);+                let raw = if verb.as_str() == asked.as_str() || standard_alias(&asked).is_some() { raw } else { parsed.to_string() };                 let reply = match verb.as_str() {                     // Phase 3 shadow: the sidecar still answers; the exe computes its own readiness                     // from real probes and rides beside it with a diff, until parity holds.@@ -413,13 +444,13 @@ fn main() {                     // a schematic file read in the exe: no Fusion needed                     "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },-                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {+                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {                         let (sc, ca) = (sidecar.clone(), caller.clone());                         let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t);                         let dlg = |r: Value| r;                         let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg };                         let cmd=v.strip_prefix("fusion_").unwrap_or(v);-                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };                         if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); }                         r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r                     },@@ -469,7 +500,7 @@ fn main() {                         r["shell"] = json!("rust"); r },                     "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },-                    v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },+                    v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_fetch_optimized_glb" | "fetch_optimized_glb" | "fusion_set_design_rules" | "set_design_rules" | "fusion_make_test_project" | "make_test_project" | "fusion_make_showcase_project" | "make_showcase_project" | "fusion_open_design" | "open_design" | "fusion_new_electronics_from_eagle" | "new_electronics_from_eagle" | "fusion_aps_open" | "aps_open" | "fusion_schematic" | "schematic" | "fusion_eagle_import_status" | "eagle_import_status" | "fusion_show_attributes" | "show_attributes") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, cmd, &caller); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "show_symbol" => orchestrations::show_symbol(&ctx, &parsed["args"], &pass), "show_footprint" => orchestrations::show_footprint(&ctx, &parsed["args"], &pass), "show_library" => orchestrations::show_library(&ctx, &parsed["args"], &pass), "show_project" => match orchestrations::show_project(&ctx, &parsed["args"], &pass) { Some(r) => r, None => side("show_project", parsed["args"].clone(), 240) }, "capture_library_views" => small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &parsed["args"], &pass), &caller, &pids), "library_views" => { let mut r = small_verbs::apply_failure_dialogs(orchestrations::capture_library_views(&ctx, &docs::library_views_args(&parsed["args"]), &pass), &caller, &pids); r["aliasOf"] = json!("fusion_capture_library_views"); r }, "open_cloud_file" => orchestrations::open_cloud_file(&ctx, &parsed["args"], &pass), "save_lbr" => small_verbs::merge_dialog_array(orchestrations::save_lbr(&ctx, &parsed["args"], &pass), &caller, &pids), "cleanup_cloud_files" => orchestrations::cleanup_cloud_files(&parsed["args"], &pass), "inspect_bodies" => orchestrations::inspect_bodies(&parsed["args"], &pass), "board_stackup" => services::board_stackup(&parsed["args"], &pass), "export_optimized_glb" => if parsed["args"]["async"].as_bool() == Some(true) { services::start_glb_export_async(&ctx, &parsed["args"]) } else { services::export_optimized_glb(&parsed["args"], &pass) }, "fetch_optimized_glb" => if parsed["args"]["exportId"].is_string() { services::glb_export_async_status(&parsed["args"]) } else { services::fetch_optimized_glb(&parsed["args"]) }, "set_design_rules" => services::set_design_rules(&ctx, &parsed["args"], &pass), "make_test_project" => small_verbs::merge_dialog_array(testproject::make_test_project(&ctx, &parsed["args"], &pass), &caller, &pids), "make_showcase_project" => small_verbs::merge_dialog_array(testproject::make_showcase_project(&ctx, &parsed["args"], &pass), &caller, &pids), "open_design" => orchestrations::open_design(&ctx, &parsed["args"], &pass), "new_electronics_from_eagle" => orchestrations::new_electronics_from_eagle(&ctx, &parsed["args"], &pass), "schematic" => orchestrations::schematic(&ctx, &parsed["args"], &pass), "eagle_import_status" => orchestrations::eagle_import_status(&ctx, &parsed["args"]), "show_attributes" => orchestrations::show_attributes(&ctx, &parsed["args"], &pass), "aps_open" => orchestrations::aps_open(&ctx, &parsed["args"], &pass), other => orchestrations::open_with_screenshot(&ctx, other, &parsed["args"], &pass) }; let mut r = small_verbs::merge_dialog_array(r0, &caller, &pids); small_verbs::add_image_delivery(&mut r); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     "fusion_state" | "state" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let mut r = orchestrations::fusion_state(&ctx, &parsed["args"]); r["shell"] = json!("rust"); r },                     v @ ("fusion_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },                     v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_install_fusion" | "install_fusion" | "fusion_uninstall" | "uninstall" | "fusion_prefs_open" | "prefs_open" | "fusion_prefs_close" | "prefs_close" | "fusion_generate_package" | "generate_package" | "fusion_mcp_status" | "mcp_status" | "fusion_mcp_tools" | "mcp_tools" | "fusion_mcp_call" | "mcp_call" | "fusion_mcp_resources" | "mcp_resources" | "fusion_mcp_enable" | "mcp_enable" | "fusion_library_manager" | "library_manager" | "fusion_managed_library_create" | "managed_library_create" | "fusion_managed_library_link" | "managed_library_link" | "fusion_managed_library_pull" | "managed_library_pull" | "fusion_managed_library_push" | "managed_library_push" | "fusion_managed_library_unlink" | "managed_library_unlink" | "fusion_signin" | "signin" | "fusion_signin_2fa" | "signin_2fa" | "fusion_web_signin" | "web_signin" | "fusion_aps_signin" | "aps_signin" | "fusion_demo" | "demo" | "fusion_demo_progress" | "demo_progress" | "fusion_demo_control" | "demo_control" | "fusion_electronics_query" | "electronics_query" | "fusion_view" | "view" | "fusion_interactive" | "interactive" | "fusion_narrate" | "narrate") if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: h.bridge_state_dir(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let cmd = v.strip_prefix("fusion_").unwrap_or(v); let mut r = match cmd { "apply_fusion_update" => maintenance::apply_fusion_update(&ctx, &pass), "install_fusion" => maintenance::install_fusion(&ctx), "prefs_open" => maintenance::prefs_open(&ctx, &parsed["args"], &pass), "prefs_close" => maintenance::prefs_close(&ctx, &parsed["args"]), "generate_package" => epg::generate_package(&parsed["args"], VERSION, &pass),
rust/bridge/src/routing.rs+2−2
@@ -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") }+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") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(),@@ -30,7 +30,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val             }             out         }-        None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle")}),+        None => json!({"success":false,"errorCode":"invalid_routing_response","error":"Fusion did not return a structured routing result", "mutationMayHaveOccurred":matches!(cmd, "route_net" | "move_parts" | "place_vias" | "apply_pours" | "apply_silkscreen" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "autoroute" | "update_board_from_eagle" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts")}),     } } 
rust/bridge/src/routing_script.py+570
@@ -1524,6 +1524,555 @@ def update_board_from_eagle(app, root, board, meta, args):     return out  +# ── board layers to DXF (the standard's export_dxf) ─────────────────────────────────────────────++class Dxf:+    """A minimal DXF R12 writer: LINE, ARC, CIRCLE, POLYLINE (widths and bulges), TEXT."""++    def __init__(self, scale):+        self.k, self.out = scale, []++    def _g(self, *pairs):+        for code, v in pairs:+            self.out.append('%d\n%s' % (code, ('%.6f' % v) if isinstance(v, float) else v))++    def line(self, a, b):+        self._g((0, 'LINE'), (8, 'L'), (10, a[0] * self.k), (20, a[1] * self.k), (30, 0.0), (11, b[0] * self.k), (21, b[1] * self.k), (31, 0.0))++    def circle(self, c, r):+        self._g((0, 'CIRCLE'), (8, 'L'), (10, c[0] * self.k), (20, c[1] * self.k), (30, 0.0), (40, r * self.k))++    def arc(self, c, r, a1, a2):+        self._g((0, 'ARC'), (8, 'L'), (10, c[0] * self.k), (20, c[1] * self.k), (30, 0.0), (40, r * self.k), (50, float(a1)), (51, float(a2)))++    def poly(self, pts, closed=False, width=0.0, bulges=None):+        self._g((0, 'POLYLINE'), (8, 'L'), (66, 1), (10, 0.0), (20, 0.0), (30, 0.0), (70, 1 if closed else 0), (40, width * self.k), (41, width * self.k))+        for k, p in enumerate(pts):+            self._g((0, 'VERTEX'), (8, 'L'), (10, p[0] * self.k), (20, p[1] * self.k), (30, 0.0))+            if bulges and bulges[k]:+                self._g((42, float(bulges[k])))+        self._g((0, 'SEQEND'), (8, 'L'))++    H = {'left': 0, 'center': 1, 'right': 2}+    V = {'bottom': 1, 'center': 2, 'top': 3}++    def text(self, p, h, txt, rot=0.0, align='bottom-left', spin=False):+        """A text as Fusion shows it: alignment as EAGLE's (bottom-left, center, top-right...), and+        turned the readable way up unless it spins (Fusion turns a text past 90 degrees half round,+        swapping its alignment, so it never reads upside down). An R12 TEXT is one line: each line+        of a multi-line text is its own, stepped down."""+        parts = (align or 'bottom-left').split('-')+        v, hz = (parts[0], parts[1]) if len(parts) == 2 else (('center', 'center') if parts[0] == 'center' else ('bottom', 'left'))+        rot = float(rot) % 360+        if not spin and 90 < rot <= 270:+            rot = (rot - 180) % 360+            hz = {'left': 'right', 'right': 'left'}.get(hz, hz)+            v = {'bottom': 'top', 'top': 'bottom'}.get(v, v)+        a = math.radians(rot)+        for i, line in enumerate((txt or '').replace('\r', '').split('\n')):+            if not line.strip():+                continue+            dx, dy = math.sin(a) * h * 1.6 * i, -math.cos(a) * h * 1.6 * i+            x, y = (p[0] + dx) * self.k, (p[1] + dy) * self.k+            tags = [(0, 'TEXT'), (8, 'L'), (10, x), (20, y), (30, 0.0), (40, h * self.k), (1, line), (50, rot)]+            if (hz, v) != ('left', 'bottom'):+                tags += [(72, self.H.get(hz, 0)), (11, x), (21, y), (31, 0.0), (73, self.V.get(v, 1))]+            self._g(*tags)++    def text_out(self):+        return '0\nSECTION\n2\nHEADER\n9\n$ACADVER\n1\nAC1009\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n' + '\n'.join(self.out) + '\n0\nENDSEC\n0\nEOF\n'+++SIDE_LAYERS = {'F.SilkS': (21, 25), 'B.SilkS': (22, 26), 'F.Fab': (51,), 'B.Fab': (52,), 'Edge.Cuts': (20, 46), 'Dwgs.User': (47, 48)}+FLIP = {21: 22, 22: 21, 25: 26, 26: 25, 51: 52, 52: 51, 1: 16, 16: 1, 29: 30, 30: 29, 31: 32, 32: 31}+++def _bulge(curve):+    return math.tan(math.radians(curve) / 4) if curve else 0.0+++def _place(el):+    """A footprint-frame point to the board: rotate, then mirror x for a bottom part, then move."""+    mirrored, deg = 'M' in (el.get('rot') or ''), float((re.search(r'R(-?[\d.]+)', el.get('rot') or 'R0') or [0, 0])[1])+    a, ex, ey = math.radians(deg), float(el.get('x')), float(el.get('y'))+    def f(x, y):+        x, y = x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a)+        if mirrored:+            x = -x+        return (ex + x, ey + y)+    return f, mirrored, deg+++def board_dxf(app, root, board, meta, args):+    """Chosen board layers to DXF, one file per layer: the outline (Edge.Cuts: Dimension 20 and+    Milling 46), copper (tracks with their width, arcs, vias, pads, pour fills), silkscreen and fab+    graphics (footprint and board lines, circles, rectangles, polygons, texts)."""+    want = args.get('layers')+    if not isinstance(want, list) or not want:+        fail('bad_args', 'layers is required: ["Edge.Cuts", "F.Cu", ...] (standard names) or Fusion layer numbers')+    units = args.get('units', 'mm')+    k = 1 / 25.4 if units == 'in' else 1.0+    out_dir = Path(args.get('outputDir') or (Path(tempfile.gettempdir()) / 'adom-dxf'))+    out_dir.mkdir(parents=True, exist_ok=True)+    stem = re.sub(r'[^A-Za-z0-9_.-]+', '_', meta.get('documentName') or 'board')+    model, _ = board_model(board, meta)+    L = Layers(board)+    num = {nm: n for n, nm in L.names.items()}+    pk = {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library') for p in l.findall('packages/package')}+    files, empty = [], []+    for lay in want:+        if isinstance(lay, int) or (isinstance(lay, str) and lay.isdigit()):+            eagle = (int(lay),)+            label = 'L%s' % lay+        elif lay in SIDE_LAYERS:+            eagle, label = SIDE_LAYERS[lay], lay+        elif lay in num:+            eagle, label = (num[lay],), lay+        else:+            fail('invalid_layer', '%s: use Edge.Cuts, F.Cu, In<n>.Cu, B.Cu, F.SilkS, B.SilkS, F.Fab, B.Fab, Dwgs.User or a Fusion layer number' % lay)+        d = Dxf(k)+        n0 = 0+        if 20 in eagle:+            edges = list(model['outlineEdges']) + [e for c in model['outlineCutouts'] for e in c['edges']]+            if args.get('contours'):+                for loop in [model['outline']] + [c['outline'] for c in model['outlineCutouts']]:+                    d.poly([tuple(p) for p in loop], closed=True)+            else:+                for e in edges:+                    if e['kind'] == 'arc':+                        d.arc(e['center'], e['radius'], e['angle1'], e['angle2'])+                    else:+                        d.line(e['start'], e['end'])+        cu = [n for n in eagle if n in COPPER]+        for n in cu:+            name = L.name(n)+            for t in model['tracks']:+                if t['layerId'] == n:+                    d.poly([(t['start']['x'], t['start']['y']), (t['end']['x'], t['end']['y'])], width=t['width'])+            for a in model['arcs']:+                if a['layerId'] == n:+                    c, r_ = (a['center']['x'], a['center']['y']), a['radius']+                    p1 = (c[0] + r_ * math.cos(math.radians(a['startAngle'])), c[1] + r_ * math.sin(math.radians(a['startAngle'])))+                    p2 = (c[0] + r_ * math.cos(math.radians(a['endAngle'])), c[1] + r_ * math.sin(math.radians(a['endAngle'])))+                    d.poly([p1, p2], width=a['width'], bulges=[_bulge((a['endAngle'] - a['startAngle']) % 360), 0])+            for v in model['vias']:+                if name in v['layers']:+                    d.circle((v['position']['x'], v['position']['y']), v['size'] / 2)+            for p_ in model['pads']:+                if p_['layer'] not in (name, '*.Cu'):+                    continue+                if p_.get('copperPoints'):+                    d.poly([tuple(q) for q in p_['copperPoints']], closed=True)+                elif p_.get('shape') in ('circle', 'round') or (p_.get('drill') and abs(p_['sizeX'] - p_['sizeY']) < 1e-6 and p_.get('shape') != 'rect'):+                    d.circle((p_['x'], p_['y']), p_['sizeX'] / 2)+                else:+                    hx, hy, a = p_['sizeX'] / 2, p_['sizeY'] / 2, math.radians(p_.get('rotation') or 0)+                    pts = [(p_['x'] + cx * math.cos(a) - cy * math.sin(a), p_['y'] + cx * math.sin(a) + cy * math.cos(a)) for cx, cy in ((-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy))]+                    d.poly(pts, closed=True)+            for z in zones(board):+                if z['layerId'] != n:+                    continue+                # the copper Fusion poured, island by island with its holes; a file with no fill+                # (an EAGLE board never poured) gives the pour as drawn+                for isl in z['fills']:+                    d.poly([tuple(q) for q in isl['outline']], closed=True)+                    for h in isl['holes']:+                        d.poly([tuple(q) for q in h], closed=True)+                if not z['fills']:+                    d.poly([tuple(q) for q in z['outline']], closed=True)+        other = [n for n in eagle if n not in COPPER and n != 20]+        if other or 20 in eagle:+            gl = set(other) | ({46} if 20 in eagle else set())+            for w in board.findall('plain/wire'):+                if int(w.get('layer', '0')) in gl:+                    d.poly([(_f(w, 'x1'), _f(w, 'y1')), (_f(w, 'x2'), _f(w, 'y2'))], width=_f(w, 'width'), bulges=[_bulge(_f(w, 'curve')), 0])+            for t in board.findall('plain/text'):+                if int(t.get('layer', '0')) in gl:+                    d.text((_f(t, 'x'), _f(t, 'y')), _f(t, 'size', 1.27), t.text or '', float((re.search(r'R(-?[\d.]+)', t.get('rot') or 'R0') or [0, 0])[1]),+                           t.get('align', 'bottom-left'), 'S' in (t.get('rot') or ''))+            for el in board.findall('elements/element'):+                pkg = pk.get((el.get('library'), el.get('package')))+                if pkg is None:+                    continue+                f, mirrored, deg = _place(el)+                for g in pkg:+                    lay_ = int(g.get('layer', '0') or 0)+                    if mirrored:+                        lay_ = FLIP.get(lay_, lay_)+                    if lay_ not in gl:+                        continue+                    if g.tag == 'wire':+                        c = _f(g, 'curve')+                        d.poly([f(_f(g, 'x1'), _f(g, 'y1')), f(_f(g, 'x2'), _f(g, 'y2'))], width=_f(g, 'width'), bulges=[_bulge(-c if mirrored else c), 0])+                    elif g.tag == 'circle':+                        d.circle(f(_f(g, 'x'), _f(g, 'y')), _f(g, 'radius'))+                    elif g.tag == 'rectangle':+                        x1, y1, x2, y2 = _f(g, 'x1'), _f(g, 'y1'), _f(g, 'x2'), _f(g, 'y2')+                        d.poly([f(x1, y1), f(x2, y1), f(x2, y2), f(x1, y2)], closed=True)+                    elif g.tag == 'polygon':+                        d.poly([f(_f(v, 'x'), _f(v, 'y')) for v in g.findall('vertex')], closed=True)+                    elif g.tag == 'text':+                        raw = (g.text or '').strip()+                        if raw.startswith('>'):+                            # a placeholder: the part's name, value or attribute (any case); none, nothing drawn+                            key = raw[1:].upper()+                            attrs = {a.get('name', '').upper(): a for a in el.findall('attribute')}+                            txt = {'NAME': el.get('name'), 'VALUE': el.get('value') or ''}.get(key)+                            if txt is None:+                                txt = attrs[key].get('value') if key in attrs else None+                            if not txt:+                                continue+                            sm = attrs.get(key)+                            if sm is not None and sm.get('display') == 'off':+                                continue+                            if sm is not None and sm.get('x') is not None:+                                d.text((_f(sm, 'x'), _f(sm, 'y')), _f(sm, 'size', 1.27), txt, float((re.search(r'R(-?[\d.]+)', sm.get('rot') or 'R0') or [0, 0])[1]),+                                       sm.get('align', 'bottom-left'), 'S' in (sm.get('rot') or ''))+                                continue+                        else:+                            txt = g.text or ''+                        trot = float((re.search(r'R(-?[\d.]+)', g.get('rot') or 'R0') or [0, 0])[1])+                        d.text(f(_f(g, 'x'), _f(g, 'y')), _f(g, 'size', 1.27), txt, (deg + (-trot if mirrored else trot)) % 360,+                               g.get('align', 'bottom-left'), 'S' in (g.get('rot') or ''))+        path = out_dir / ('%s-%s.dxf' % (stem, label.replace('.', '_')))+        path.write_text(d.text_out())+        n_ent = len([x for x in d.out if x.startswith('0\n') and not x.startswith('0\nVERTEX') and not x.startswith('0\nSEQEND')])+        files.append({'layer': label, 'path': path.as_posix(), 'entities': n_ent})+        if not n_ent:+            empty.append(label)+    return {'success': True, 'mutated': False, **meta, 'files': files, 'units': units, 'empty': empty,+            '_hint': 'One DXF (R12) per layer, in %s with the board\'s origin and y up. Tracks are polylines carrying their width; contours:true joins the outline into closed polylines.%s' % (units, (' Empty: %s.' % ', '.join(empty)) if empty else '')}+++# ── part and graphic edits (the standard's edit_parts, align_parts, remove_graphics, remove_parts) ──++def _element(board, ref):+    return next((e for e in board.findall('elements/element') if e.get('name') == ref), None)+++def _guard(meta, args):+    if args.get('expectedRevision') not in (None, meta.get('revision')):+        fail('stale_board', 'expectedRevision must match the current live board')+++def edit_parts(app, root, board, meta, args):+    """Value, designator, fields and the lock of placed parts; each edit read back. Fusion keeps+    board and schematic in step, so a value or name changed here is changed in the schematic too."""+    _guard(meta, args)+    edits = args.get('edits') or []+    if not edits:+        fail('bad_args', 'edits is [{ref, value?, newRef?, fields?: {name: value | null}, locked?}]')+    names = {e.get('name') for e in board.findall('elements/element')}+    plan, failed = [], []+    for ed in edits:+        ref = ed.get('ref')+        el = _element(board, ref)+        if el is None:+            failed.append({'ref': ref, 'error': 'no part %s on the board' % ref})+            continue+        refused = [k for k in ('dnp', 'excludeFromBom', 'excludeFromPos', 'mount') if k in ed]+        if refused:+            failed.append({'ref': ref, 'error': 'unsupported: %s (Fusion keeps do-not-populate and BOM exclusion in assembly variants, which this verb does not edit)' % ', '.join(refused)})+        cmds, cur = [], ref+        if ed.get('value') is not None:+            cmds.append("SET CONFIRM YES; VALUE '%s' %s; SET CONFIRM OFF;" % (identifier(cur), "'" + str(ed['value']).replace("'", "''") + "'"))+        for k, v in (ed.get('fields') or {}).items():+            if k.upper() in ('NAME', 'VALUE'):+                failed.append({'ref': ref, 'error': '%s is required: use newRef or value' % k})+                continue+            if v is None:+                cmds.append("SET CONFIRM YES; ATTRIBUTE '%s' '%s' DELETE; SET CONFIRM OFF;" % (identifier(cur), k.replace("'", "''")))+            else:+                cmds.append("SET CONFIRM YES; ATTRIBUTE '%s' '%s' '%s'; SET CONFIRM OFF;" % (identifier(cur), k.replace("'", "''"), str(v).replace("'", "''")))+        if ed.get('locked') is True:+            cmds.append("LOCK '%s';" % identifier(cur))+        elif ed.get('locked') is False:+            # a shift-click LOCK on the part's origin unlocks it ("LOCK -" is not a command)+            cmds.append('LOCK (S%.6fmm %.6fmm);' % (float(el.get('x')), float(el.get('y'))))+        if ed.get('newRef') and ed['newRef'] != ref:+            if ed['newRef'] in names:+                failed.append({'ref': ref, 'error': 'designator %s is already taken' % ed['newRef']})+            else:+                cmds.append("SET CONFIRM YES; NAME '%s' '%s'; SET CONFIRM OFF;" % (identifier(cur), identifier(ed['newRef'])))+                names.add(ed['newRef'])+        if cmds:+            plan.append({'ref': ref, 'edit': ed, 'cmds': cmds})+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'plan': [{'ref': p_['ref'], 'commands': p_['cmds']} for p_ in plan], 'failed': failed}+    for p_ in plan:+        for c in p_['cmds']:+            app.executeTextCommand('Electron.run ' + c)+    _, after, meta2 = snapshot(app, args)+    edited = []+    for p_ in plan:+        ed, ref = p_['edit'], p_['ref']+        name = ed.get('newRef') or ref+        el = _element(after, name)+        attrs = {a.get('name'): a.get('value') for a in el.findall('attribute')} if el is not None else {}+        bad = []+        if el is None:+            bad.append('part %s not found after the edit' % name)+        else:+            if ed.get('value') is not None and el.get('value') != str(ed['value']):+                bad.append('value is %s' % el.get('value'))+            for k, v in (ed.get('fields') or {}).items():+                if k.upper() in ('NAME', 'VALUE'):+                    continue+                got = next((val for an, val in attrs.items() if an.upper() == k.upper()), None)+                if v is None and got is not None:+                    bad.append('field %s is still %s: a field the library part defines cannot be removed from the board (set it to another value instead)' % (k, got))+                elif v is not None and got != str(v):+                    bad.append('field %s is %s' % (k, got))+            if 'locked' in ed and (el.get('locked') == 'yes') != bool(ed['locked']):+                bad.append('locked is %s' % (el.get('locked') == 'yes'))+        if bad:+            failed.append({'ref': ref, 'error': '; '.join(bad)})+        else:+            edited.append({'ref': ref, 'after': {'ref': name, 'value': el.get('value'), 'fields': attrs, 'locked': el.get('locked') == 'yes'}})+    return {'success': not failed, 'mutated': bool(plan), **meta2, 'edited': edited, 'failed': failed,+            '_hint': 'Fusion keeps board and schematic in step: these edits are in the schematic too.'}+++def part_boxes(board):+    """Each part's footprint box on the board (pads, placement and documentation outlines,+    keepouts), from its package through the part's turn and mirror."""+    pk = {(l.get('name'), p.get('name')): p for l in board.findall('libraries/library') for p in l.findall('packages/package')}+    out = {}+    for el in board.findall('elements/element'):+        pkg = pk.get((el.get('library'), el.get('package')))+        f, _, _ = _place(el)+        xs, ys = [], []+        for g in (pkg if pkg is not None else []):+            pts = []+            if g.tag in ('smd',):+                hx, hy = _f(g, 'dx') / 2, _f(g, 'dy') / 2+                pts = [(_f(g, 'x') + a, _f(g, 'y') + b) for a in (-hx, hx) for b in (-hy, hy)]+            elif g.tag == 'pad':+                r_ = max(_f(g, 'diameter'), _f(g, 'drill') * 1.6) / 2+                pts = [(_f(g, 'x') + a, _f(g, 'y') + b) for a in (-r_, r_) for b in (-r_, r_)]+            elif g.tag in ('wire', 'rectangle') and g.get('layer') in ('21', '22', '39', '40', '51', '52'):+                pts = [(_f(g, 'x1'), _f(g, 'y1')), (_f(g, 'x2'), _f(g, 'y2'))]+            elif g.tag == 'circle' and g.get('layer') in ('21', '22', '39', '40', '51', '52'):+                r_ = _f(g, 'radius')+                pts = [(_f(g, 'x') + a, _f(g, 'y') + b) for a in (-r_, r_) for b in (-r_, r_)]+            for x, y in pts:+                X, Y = f(x, y)+                xs.append(X)+                ys.append(Y)+        ex, ey = float(el.get('x')), float(el.get('y'))+        out[el.get('name')] = (min(xs + [ex]), min(ys + [ey]), max(xs + [ex]), max(ys + [ey]))+    return out+++def align_parts(app, root, board, meta, args):+    _guard(meta, args)+    refs = args.get('refs') or []+    mode = args.get('mode')+    if len(refs) < 2 or mode not in ('left', 'right', 'top', 'bottom', 'centerX', 'centerY', 'distributeX', 'distributeY'):+        fail('bad_args', 'refs (two or more) and mode left|right|top|bottom|centerX|centerY|distributeX|distributeY are required')+    els = {r_: _element(board, r_) for r_ in refs}+    missing = [r_ for r_, e in els.items() if e is None]+    if missing:+        fail('designator_not_found', 'No part %s on the board' % ', '.join(missing))+    by = args.get('by', 'courtyard')+    boxes = part_boxes(board)+    anch = {r_: (float(e.get('x')), float(e.get('y'))) for r_, e in els.items()}+    box = {r_: boxes[r_] if by == 'courtyard' else (anch[r_][0], anch[r_][1], anch[r_][0], anch[r_][1]) for r_ in refs}+    to = args.get('to')+    targets = {}+    if mode in ('left', 'right', 'top', 'bottom', 'centerX', 'centerY'):+        key = {'left': lambda b: b[0], 'right': lambda b: b[2], 'bottom': lambda b: b[1], 'top': lambda b: b[3],+               'centerX': lambda b: (b[0] + b[2]) / 2, 'centerY': lambda b: (b[1] + b[3]) / 2}[mode]+        vals = [key(box[r_]) for r_ in refs]+        line = float(to) if to is not None else {'left': min, 'bottom': min, 'right': max, 'top': max}.get(mode, lambda v: sum(v) / len(v))(vals)+        for r_ in refs:+            d = line - key(box[r_])+            x, y = anch[r_]+            targets[r_] = (x + d, y) if mode in ('left', 'right', 'centerX') else (x, y + d)+    else:+        ax = 0 if mode == 'distributeX' else 1+        order = sorted(refs, key=lambda r_: (box[r_][ax] + box[r_][ax + 2]) / 2)+        sizes = [box[r_][ax + 2] - box[r_][ax] for r_ in order]+        first, last = box[order[0]], box[order[-1]]+        if args.get('gapMm') is not None:+            gap = float(args['gapMm'])+        else:+            gap = ((last[ax + 2] - first[ax]) - sum(sizes)) / (len(order) - 1)+        pos = first[ax]+        for r_, sz in zip(order, sizes):+            d = pos - box[r_][ax]+            x, y = anch[r_]+            targets[r_] = (x + d, y) if ax == 0 else (x, y + d)+            pos += sz + gap+    # the checks move_parts makes: on the board, not over another part+    model, _ = board_model(board, meta)+    xs_ = [p[0] for p in model['outline']] or [-1e9, 1e9]+    ys_ = [p[1] for p in model['outline']] or [-1e9, 1e9]+    problems = []+    moved_box = {}+    for r_ in refs:+        dx, dy = targets[r_][0] - anch[r_][0], targets[r_][1] - anch[r_][1]+        b = boxes[r_]+        moved_box[r_] = (b[0] + dx, b[1] + dy, b[2] + dx, b[3] + dy)+        if not args.get('allowOutside') and (moved_box[r_][0] < min(xs_) - 1e-6 or moved_box[r_][2] > max(xs_) + 1e-6 or moved_box[r_][1] < min(ys_) - 1e-6 or moved_box[r_][3] > max(ys_) + 1e-6):+            problems.append('%s would leave the board' % r_)+    if not args.get('allowOverlap'):+        # only overlaps the move makes: footprint boxes of a dense board already touch+        def hit(b1, b2):+            return b1[0] < b2[2] - 1e-6 and b2[0] < b1[2] - 1e-6 and b1[1] < b2[3] - 1e-6 and b2[1] < b1[3] - 1e-6+        after_b = dict(boxes); after_b.update(moved_box)+        names_ = sorted(after_b)+        for i_, n1 in enumerate(names_):+            for n2 in names_[i_ + 1:]:+                if (n1 in refs or n2 in refs) and hit(after_b[n1], after_b[n2]) and not hit(boxes[n1], boxes[n2]):+                    problems.append('%s would overlap %s' % (n1, n2))+    tgt = [{'ref': r_, 'x': round(targets[r_][0], 4), 'y': round(targets[r_][1], 4)} for r_ in refs]+    if problems:+        fail('placement_refused', '; '.join(sorted(set(problems))[:10]) + ' (allowOverlap / allowOutside to allow)')+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'targets': tgt}+    for t in tgt:+        if abs(t['x'] - anch[t['ref']][0]) > 1e-6 or abs(t['y'] - anch[t['ref']][1]) > 1e-6:+            app.executeTextCommand("Electron.run MOVE '%s' (%.6fmm %.6fmm);" % (identifier(t['ref']), t['x'], t['y']))+    app.executeTextCommand('Electron.run RATSNEST;')+    _, after, meta2 = snapshot(app, args)+    mism = []+    for t in tgt:+        e = _element(after, t['ref'])+        got = (round(float(e.get('x')), 4), round(float(e.get('y')), 4))+        if abs(got[0] - t['x']) > 1e-3 or abs(got[1] - t['y']) > 1e-3:+            mism.append({'ref': t['ref'], 'asked': [t['x'], t['y']], 'got': list(got)})+    return {'success': not mism, 'mutated': True, **meta2, 'targets': tgt, 'mismatches': mism}+++def plain_graphics(board, L):+    """Board-level graphics with ids built from kind, layer and position (Fusion stores none)."""+    out = []+    for g in board.find('plain'):+        layer = int(g.get('layer', '0') or 0)+        name = L.name(layer)+        if g.tag == 'wire':+            c = _f(g, 'curve')+            kind = 'arc' if c else 'line'+            pts = [[_f(g, 'x1'), _f(g, 'y1')], [_f(g, 'x2'), _f(g, 'y2')]]+            pp = wire_points(tuple(pts[0]), tuple(pts[1]), c)+            pick = pp[len(pp) // 2] if c else ((pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2)+        elif g.tag == 'rectangle':+            kind, pts = 'rect', [[_f(g, 'x1'), _f(g, 'y1')], [_f(g, 'x2'), _f(g, 'y2')]]+            pick = (pts[0][0], (pts[0][1] + pts[1][1]) / 2)+        elif g.tag == 'circle':+            kind, pts = 'circle', [[_f(g, 'x'), _f(g, 'y')]]+            pick = (_f(g, 'x') + _f(g, 'radius'), _f(g, 'y'))+        elif g.tag == 'polygon':+            vs = g.findall('vertex')+            kind, pts = 'polygon', [[_f(v, 'x'), _f(v, 'y')] for v in vs]+            pick = ((pts[0][0] + pts[1][0]) / 2, (pts[0][1] + pts[1][1]) / 2) if len(pts) > 1 else tuple(pts[0])+        elif g.tag == 'text':+            kind, pts = 'text', [[_f(g, 'x'), _f(g, 'y')]]+            pick = tuple(pts[0])+        elif g.tag == 'dimension':+            kind, pts = 'dimension', [[_f(g, 'x1'), _f(g, 'y1')], [_f(g, 'x2'), _f(g, 'y2')]]+            pick = tuple(pts[0])+        else:+            continue+        gid = '%s:%d:%s' % (kind, layer, ','.join('%g,%g' % (round(p[0], 4), round(p[1], 4)) for p in pts))+        out.append({'id': gid, 'kind': kind, 'layer': name, 'layerId': layer, 'points': [[round(a, 4), round(b, 4)] for a, b in pts],+                    'text': g.text if g.tag == 'text' else None, 'pick': pick, 'shift': g.tag == 'polygon', 'node': g})+    return out+++def remove_graphics(app, root, board, meta, args):+    _guard(meta, args)+    L = Layers(board)+    allg = plain_graphics(board, L)+    want_ids = args.get('ids')+    if want_ids:+        unknown = [i for i in want_ids if i not in {g['id'] for g in allg}]+        if unknown:+            fail('unknown_ids', 'No board graphic has id %s (a dry run with a layer lists them)' % ', '.join(unknown[:5]))+        hits = [g for g in allg if g['id'] in want_ids]+    else:+        hits = allg+    if args.get('layer') is not None:+        lay = args['layer']+        n = SIDE_LAYERS.get(lay, (None,))[0] if lay in SIDE_LAYERS else _layer_number(board, lay)+        hits = [g for g in hits if g['layerId'] == n or (lay in SIDE_LAYERS and g['layerId'] in SIDE_LAYERS[lay])]+    if args.get('kinds'):+        hits = [g for g in hits if g['kind'] in args['kinds']]+    if not want_ids and args.get('layer') is None and not args.get('kinds'):+        fail('bad_args', 'Give ids, or a layer and/or kinds to remove every match')+    rows = [{k: g[k] for k in ('id', 'kind', 'layer', 'points', 'text')} for g in hits]+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'removed': rows, 'count': len(rows)}+    if not hits:+        return {'success': True, 'mutated': False, **meta, 'removed': [], 'note': 'nothing matched'}+    visible = _visible_layers(root)+    try:+        for g in hits:+            app.executeTextCommand('Electron.run DISPLAY NONE %d; DELETE (%s%.6fmm %.6fmm);' % (g['layerId'], 'S' if g['shift'] else '', g['pick'][0], g['pick'][1]))+    finally:+        _restore_display(app, visible)+    _, after, meta2 = snapshot(app, args)+    left = {g['id'] for g in plain_graphics(after, Layers(after))}+    should = {g['id'] for g in allg} - {g['id'] for g in hits}+    _, _, w0, v0 = objects(board)+    _, _, w1, v1 = objects(after)+    if left != should or copper_signature(w0 + v0) != copper_signature(w1 + v1) or _elements_of(after) != _elements_of(board):+        for _ in hits:+            app.executeTextCommand('Electron.run UNDO;')+        fail('readback_mismatch', 'The board graphics read back were not the old ones minus the asked ones (still there %s, gone too %s); every DELETE was undone'+             % (sorted({g['id'] for g in hits} & left)[:4], sorted(should - left)[:4]))+    return {'success': True, 'mutated': True, **meta2, 'removed': rows}+++def remove_parts(app, root, board, meta, args):+    """Parts are removed in the design's schematic: Fusion keeps the board in step with it, and a+    DELETE of a part on the board does nothing (the board follows the schematic)."""+    _guard(meta, args)+    refs = args.get('refs') or []+    if not refs:+        fail('bad_args', 'refs is required')+    nets, failed, todo = objects(board)[0], [], []+    for r_ in refs:+        el = _element(board, r_)+        if el is None:+            failed.append({'ref': r_, 'error': 'no part %s on the board' % r_})+        elif el.get('locked') == 'yes' and not args.get('allowLocked'):+            failed.append({'ref': r_, 'error': 'part_locked (allowLocked:true to remove it)'})+        else:+            todo.append(r_)+    plan = [{'ref': r_, 'nets': sorted({n for n, s_ in nets.items() for c in s_.findall('contactref') if c.get('element') == r_})} for r_ in todo]+    if args.get('dryRun'):+        return {'success': True, 'mutated': False, **meta, 'removed': plan, 'failed': failed}+    if not todo:+        return {'success': False, 'errorCode': 'nothing_to_do', 'error': 'No part could be removed', 'failed': failed, **meta}+    bdoc = app.activeDocument+    i, sdoc = _schematic_of(app, bdoc)+    if sdoc is None:+        fail('no_schematic', 'The board has no schematic open beside it: open the design (its project document)')+    j = next(k for k in range(app.documents.count) if app.documents.item(k) == bdoc)+    try:+        for r_ in todo:+            el = _element(board, r_)+            if el.get('locked') == 'yes':+                app.executeTextCommand('Electron.run LOCK (S%.6fmm %.6fmm);' % (float(el.get('x')), float(el.get('y'))))+        app.executeTextCommand('UI.SwitchToTab %d' % i)+        for r_ in todo:+            app.executeTextCommand("Electron.run SET CONFIRM NO; DELETE '%s'; SET CONFIRM OFF;" % identifier(r_))+    finally:+        app.executeTextCommand('UI.SwitchToTab %d' % j)+    app.executeTextCommand('Electron.run RATSNEST;')+    _, after, meta2 = snapshot(app, args)+    still = [r_ for r_ in todo if _element(after, r_) is not None]+    gone_too = sorted(set(_elements_of(board)) - set(_elements_of(after)) - set(todo))+    for r_ in still:+        failed.append({'ref': r_, 'error': 'still on the board after the delete in the schematic'})+    if gone_too:+        failed.append({'ref': None, 'error': 'other parts went too: %s' % gone_too})+    return {'success': not failed, 'mutated': True, **meta2, 'removed': [p_ for p_ in plan if p_['ref'] not in still], 'failed': failed,+            '_hint': 'Removed from the design (schematic and board). Tracks and vias that met their pads stay: fusion_routing_state shows what is left dangling.'}++ def handle(app,args,action):     attempted=False; commands=[]     try:@@ -1553,6 +2102,11 @@ def handle(app,args,action):             return apply_silkscreen(app,root,board,meta,args)         if action=='autoroute': return autoroute(app,root,board,meta,args)         if action=='update_board_from_eagle': return update_board_from_eagle(app,root,board,meta,args)+        if action=='board_dxf': return board_dxf(app,root,board,meta,args)+        if action=='edit_parts': return edit_parts(app,root,board,meta,args)+        if action=='align_parts': return align_parts(app,root,board,meta,args)+        if action=='remove_graphics': return remove_graphics(app,root,board,meta,args)+        if action=='remove_parts': return remove_parts(app,root,board,meta,args)         if action=='run_erc': return run_erc(app,root,board,meta,args)         if action=='set_layer_visibility': return set_layer_visibility(app,root,board,meta,args)         if action=='select_net': return select_net(app,root,board,meta,args)@@ -1614,6 +2168,22 @@ def handle(app,args,action):                     '_hint':'Every net with its pads as Designator.Pad. Compare with the schematic intent, or feed it to a schematic review.'}         # The live board as EAGLE XML (Fusion's own fills included), for analysis offline.         if action=='board_source': return {'success':True,'mutated':False,**meta,'source':ET.tostring(root,encoding='unicode')}+        if action=='plan_route':+            # the standard's plan_route (read only): from/to, widthMm, viaSizeMm, viaDrillMm, gridMm;+            # boardId and expectedRevision are checked when given. routeNet is this bridge's+            # route_net arguments, to pass on unchanged.+            for a_,b_ in (('from','fromPad'),('to','toPad'),('widthMm','width'),('viaSizeMm','viaDiameter'),('viaDrillMm','viaDrill'),('gridMm','grid')):+                if args.get(a_) is not None and args.get(b_) is None: args[b_]=args[a_]+            if args.get('boardId') not in (None,meta['boardId']): fail('board_mismatch','boardId does not identify the active board')+            if args.get('expectedRevision') not in (None,meta['revision']): fail('stale_board','expectedRevision must match the current live board')+            try:+                p_=automatic_plan(board,args)+            except RoutingError as e_:+                if e_.code in ('no_route','search_budget'):+                    return {'success':True,'mutated':False,**meta,'found':False,'reason':str(e_),'errorCode':None}+                raise+            return {'success':True,'mutated':False,**meta,'plan':p_,'found':True,'routeNet':p_['route'],'lengthMm':p_['planner']['lengthMm'],'vias':p_['planner']['viaCount'],+                    '_hint':'Pass routeNet to fusion_route_net with expectedRevision set to revision; fusion_run_drc after the route is still the proof.'}         if args.get('boardId')!=meta['boardId']: fail('board_mismatch','Mutations/checks require boardId from fusion_routing_state')         if args.get('expectedRevision')!=meta['revision']: fail('stale_board','expectedRevision must match the current live board')         if action=='plan_route':
tests/run_verb_tests.py+10−1
@@ -228,6 +228,15 @@ for v in ("fusion_sch_text", "fusion_sch_junction", "fusion_sch_delete_wire", "f     t(v, "manual", note="schematic edit: tested live on a scratch import of BQ25792 (never the cloud design)") t("fusion_update_board_from_eagle#guard", "negative", dict(B, brdPath="C:/nonexistent/none.brd"), {"pred": "must_refuse"}, "a missing file is refused before anything changes") t("fusion_update_board_from_eagle", "manual", note="writes a saved design: tested live on fl-sync-test in Default Project / fusion-lane-tests (#125 item 4)")+t("fusion_export_dxf#board", "daily", dict(B, layers=["Edge.Cuts", "F.Cu", "F.SilkS"], outputDir=f"{SCRATCH}/dxf"), {"ok": True, "fields": ["files"]}, "board layers to DXF (the standard's export_dxf)")+t("fusion_plan_route#standard", "negative", dict(B, net="SDA", **{"from": "IC1.15", "to": "R18.2"}), {"pred": "must_refuse"}, "the standard's names in; BQ25792 has four layers and the planner takes two: refused with unsupported_board (the shared engine's plan_route will cover more)")+t("fusion_render_board_3d", "daily", dict(B, side="iso", width=800, height=500, outputPath=f"{SCRATCH}/board-iso.png"), {"ok": True, "fields": ["path"]}, "shows the 3D PCB itself when it is not open, then puts the board tab back")+t("fusion_remove_graphics#dry", "daily", dict(B, layer="F.SilkS", kinds=["text"], dryRun=True), {"ok": True, "fields": ["removed", "count"]}, "lists ids, changes nothing")+t("fusion_align_parts#dry", "daily", dict(B, refs=["C19", "C20", "C21"], mode="top", dryRun=True), {"ok": True, "fields": ["targets"]})+t("fusion_remove_parts#dry", "daily", dict(B, refs=["R2"], dryRun=True), {"ok": True, "fields": ["removed"]}, "plan only")+t("fusion_edit_parts#dry", "daily", dict(B, edits=[{"ref": "R2", "value": "0.01"}], dryRun=True), {"ok": True, "fields": ["plan"]}, "plan only")+for v in ("fusion_edit_parts", "fusion_align_parts", "fusion_remove_graphics", "fusion_remove_parts", "fusion_generate_body", "fusion_library_views"):+    t(v, "manual", note="edits a design, writes files or closes documents: tested live on scratch imports and fl-sync-test") t("fusion_export_bom#parts", "daily", {"outputPath": f"{SCRATCH}/bom-parts.csv"}, {"ok": True, "pred": "bom_has_part_numbers"}, "1.12.0: MPN/LCSC columns from the part attributes (page issue #105)") t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["commands"]}, "TextCommands.List needs the electronics editor context") t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)")@@ -420,7 +429,7 @@ _SECTION_BY_VERB = {     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",     "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_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_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_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_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",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tests/test_routing.py+41
@@ -294,4 +294,45 @@ class BoardFromEagleTests(unittest.TestCase):         self.assertIn('NET_1', str(e.exception))  +class PlanRouteStandardTests(unittest.TestCase):+    """plan_route answers the standard's shape: from/to in, found and routeNet out."""+    def setUp(self):+        self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot()+        self.board = self.root.find('drawing/board')+        self.saved = r.snapshot+        r.snapshot = lambda app, args: (self.root, self.board, {'boardId': 'b', 'revision': 'r1'})+    def tearDown(self): r.snapshot = self.saved+    def test_standard_names(self):+        out = r.handle(None, {'net': 'NET_1', 'from': 'J1.1', 'to': 'J2.1', 'widthMm': 0.25}, 'plan_route')+        self.assertTrue(out['found'], out)+        self.assertEqual('NET_1', out['routeNet']['net'])+        self.assertGreater(out['lengthMm'], 39)+    def test_stale_revision_still_refused(self):+        out = r.handle(None, {'net': 'NET_1', 'from': 'J1.1', 'to': 'J2.1', 'expectedRevision': 'old'}, 'plan_route')+        self.assertEqual('stale_board', out['errorCode'])+++class BoardDxfTests(unittest.TestCase):+    def test_layers_to_dxf(self):+        import tempfile+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        d = tempfile.mkdtemp()+        out = r.board_dxf(None, root, b, {'documentName': 'fixture'}, {'layers': ['Edge.Cuts', 'F.Cu', 'F.SilkS'], 'outputDir': d, 'contours': True})+        got = {f['layer']: f for f in out['files']}+        self.assertEqual({'Edge.Cuts', 'F.Cu', 'F.SilkS'}, set(got))+        self.assertGreater(got['F.Cu']['entities'], 0)+        text = Path(got['Edge.Cuts']['path']).read_text()+        self.assertTrue(text.startswith('0\nSECTION') and text.rstrip().endswith('EOF'))+        try:+            import ezdxf+        except ImportError:+            return+        for f in out['files']:+            self.assertEqual(0, len(ezdxf.readfile(f['path']).audit().errors), f)+    def test_unknown_layer_refused(self):+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        with self.assertRaises(r.RoutingError):+            r.board_dxf(None, root, b, {}, {'layers': ['Nope.Layer']})++ if __name__=='__main__':unittest.main()