app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 37 file(s)
37 files changed
+1881−38
BRIDGE_VERSION+1−1CHANGELOG.md+28addin/AdomBridge/commands/close_document.py+6bridge.json+19−2contracts/describe.json+293−3dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+41−1install.sh+1−1package.json+1−1page.json+2−2rust/Cargo.toml+1−1rust/bridge/src/addin.rs+15rust/bridge/src/checks.rs+1−1rust/bridge/src/demo.rs+1−1rust/bridge/src/docs.rs+146−1rust/bridge/src/docs_script.py+42rust/bridge/src/fusion_prefs.rs+1−1rust/bridge/src/lifecycle.rs+1−1rust/bridge/src/main.rs+69−1rust/bridge/src/mint.rs+2−2rust/bridge/src/orchestrations.rs+1−1rust/bridge/src/pdf.rs+96rust/bridge/src/routing.rs+2−2rust/bridge/src/routing_script.py+602rust/bridge/src/sch.rs+1−1rust/bridge/src/sch_script.py+120rust/bridge/src/testproject.rs+51rust/lbr/src/lib.rs+189rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+1−1rust/vendor/adom-eda-checks/VENDOR.md+4−4rust/vendor/adom-eda-checks/src/current.rs+4−2rust/vendor/adom-eda-checks/src/fab.rs+22−3tests/run_verb_tests.py+14−1tests/test_routing.py+76tests/test_sch.py+24BRIDGE_VERSION+1−1@@ -1 +1 @@-1.12.7+1.12.8
CHANGELOG.md+28@@ -3,6 +3,34 @@ 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.8 (2026-10-28, insiders)++KiCad's remaining core and app proposals, and a close that never saves unasked. 271 verbs.++- Fixed: `fusion_close_document {save:false}` on a saved cloud document with unsaved changes used to save a+ new version first, silently, because Fusion asks to save even when told not to. It made BQ25792 v228 for a+ caller who meant to discard. Such a close is now refused (`unsaved_cloud_document`) and nothing is+ written, unless `save:true` or `saveToClose:true`. The bridge's own internal closes pass `saveToClose`.+- New:+ - `fusion_cross_probe`: a schematic part to its board part and back, by designator; `select` highlights both.+ - `fusion_import_svg`: SVG artwork onto silkscreen, mask, paste or documentation layers. Fusion's POLYGON+ asks for a signal name in a modal, so filled shapes are drawn row by row with RECT. Holes follow+ even-odd, bottom layers are mirrored, and copper is refused before any command runs.+ - `fusion_copy_routing`: tracks and through vias by an offset, nets remapped through paired parts or a+ netMap. A copy that would land on another net is refused before drawing; Fusion's DRC runs before and+ after, and any new error undoes it.+ - `fusion_set_layer_count`: 2, 4, 6 or 8 copper layers through the board's design rules, with the+ thickness kept; layers still carrying copper are refused.+ - `fusion_project_info`, `fusion_project_snapshot` (EAGLE XML, PNGs and PDFs in a dated folder) and+ `fusion_project_create` (an empty schematic and board opened as a new design).+ - `fusion_export_library_part` and `fusion_edit_footprint`: offline .lbr work, preflighted before writing.+ - `fusion_project_rename`: a design kept as EAGLE files (folder and files renamed together); refused while open+ in Fusion, and for a cloud design, which Fusion's data panel renames.+ - `create_part_project`: the standard's name now runs `fusion_make_test_project`.+- The checks engine is `adom-eda-checks` 0.1.4 (standard 0.4.5): copper-only clearance (#13) and each pour+ layer listed once in `current_capacity` (#15).+- Daily suite: 131 pass on DESKTOP-BAR6S0F; the 5 failures are the APS verbs without the APS sign-in.+ ## 1.12.7 (2026-10-27, insiders) The standard's fab exports, and read_footprint. 261 verbs.
addin/AdomBridge/commands/close_document.py+6@@ -117,6 +117,12 @@ def handle_close_document(app: adsk.core.Application, args: dict) -> dict: _saved_first = False try: if not save_changes and doc.dataFile is not None and bool(doc.isModified):+ if not args.get("saveToClose"):+ # never save a version the caller did not ask for (BQ25792 v228, 2026-10-28)+ return {"success": False, "errorCode": "unsaved_cloud_document", "document": doc_name,+ "error": ("%r is a saved cloud document with unsaved changes; Fusion asks to save it even "+ "when told not to, so it was not closed and nothing was saved." % doc_name),+ "_hint": "Pass save:true (or saveToClose:true) to save a version and close, or keep it open."} doc.save("adom bridge: saved before close to avoid Fusion's prompt") _saved_first = True except Exception:
bridge.json+19−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.12.7",+ "version": "1.12.8", "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.",@@ -74,8 +74,10 @@ "fusion_component_pins", "fusion_copper_balance", "fusion_copper_layers",+ "fusion_copy_routing", "fusion_create_cloud_folder", "fusion_create_library",+ "fusion_cross_probe", "fusion_current_capacity", "fusion_delete_cloud_file", "fusion_demo",@@ -87,6 +89,7 @@ "fusion_dismiss_dialogs", "fusion_document_info", "fusion_eagle_import_status",+ "fusion_edit_footprint", "fusion_edit_parts", "fusion_electron_pan", "fusion_electron_run",@@ -111,6 +114,7 @@ "fusion_export_ipc2581", "fusion_export_ipc356", "fusion_export_lbr",+ "fusion_export_library_part", "fusion_export_obj", "fusion_export_odb", "fusion_export_optimized_glb",@@ -131,6 +135,7 @@ "fusion_impedance", "fusion_import_electronics", "fusion_import_step",+ "fusion_import_svg", "fusion_inspect_bodies", "fusion_install", "fusion_install_fusion",@@ -190,6 +195,10 @@ "fusion_prefs_close", "fusion_prefs_open", "fusion_prewarm",+ "fusion_project_create",+ "fusion_project_info",+ "fusion_project_rename",+ "fusion_project_snapshot", "fusion_prove_result", "fusion_read_footprint", "fusion_read_rules",@@ -249,6 +258,7 @@ "fusion_set_auto_update", "fusion_set_design_rules", "fusion_set_driving_badge",+ "fusion_set_layer_count", "fusion_set_layer_visibility", "fusion_set_outline", "fusion_set_parameter",@@ -432,7 +442,14 @@ "export_ipc356": 120, "export_gencad": 120, "export_ipc2581": 150,- "read_footprint": 90+ "read_footprint": 90,+ "cross_probe": 90,+ "import_svg": 150,+ "copy_routing": 240,+ "set_layer_count": 150,+ "project_info": 120,+ "project_snapshot": 300,+ "project_create": 180 }, "prefixes": [ {
contracts/describe.json+293−3@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-10-27",+ "frozen": "2026-10-28", "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.", "source": "describe.py", "verbs": [@@ -946,7 +946,8 @@ }, "input": { "name": "optional str (default: active document)",- "save": "optional bool default false"+ "save": "optional bool default false: save a version, then close",+ "saveToClose": "optional bool: save a version only because the close needs it" }, "longRunning": false, "name": "fusion_close_document",@@ -955,7 +956,7 @@ "success": "bool" }, "statusVerb": null,- "summary": "Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). Omit name to close the active document.",+ "summary": "Close ONE document without a modal save dialog. Omit name to close the active document. An unchanged document, or a never-saved scratch one, closes at once. A saved cloud document with unsaved changes is refused (unsaved_cloud_document): Fusion asks to save it even when told not to, so closing it means saving a version, which needs save:true (or saveToClose:true).", "timeoutSeconds": 60 }, {@@ -1050,6 +1051,50 @@ "summary": "The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each.", "timeoutSeconds": 60 },+ {+ "example": {+ "box": [+ 20,+ 13,+ 30,+ 16+ ],+ "dryRun": true,+ "expectDocument": "BQ25792",+ "offset": [+ 0,+ -10+ ],+ "refMap": {+ "C12": "C13"+ }+ },+ "input": {+ "box": "[x1, y1, x2, y2] mm (required)",+ "dryRun": "bool",+ "expectDocument": "required: the board's name",+ "keepUnmappedNets": "bool, default false",+ "layers": "[layer]",+ "netMap": "{'SIG': 'SIG2'}",+ "offset": "[dx, dy] mm (required)",+ "refMap": "{'U1': 'U5'}",+ "refPattern": "{from: 'CH1_*', to: 'CH2_*'}",+ "validate": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_copy_routing",+ "output": {+ "clashes": "[{item, at, touches}]",+ "copied": "{tracks, vias}",+ "drc": "{ran, newErrors}",+ "netMap": "{from: to}",+ "pairs": "{ref: ref}",+ "skipped": "[{item, why}]"+ },+ "statusVerb": null,+ "summary": "Copy the tracks and through vias inside a box to an offset (the standard's copy_routing), remapping nets by pairing parts (refMap or refPattern: each source pad's net goes to the partner pad's net), then netMap. A track is copied when both ends are in the box, a via when its centre is. A destination end on another net's copper is refused before anything is drawn (Fusion would ask to join the nets). Read back exactly; with validate (default) Fusion's DRC runs and any new error undoes the copy.",+ "timeoutSeconds": 180+ }, { "example": { "folderName": "AI-Scratch",@@ -1098,6 +1143,27 @@ "summary": "ONE-CALL library creation for web-control (one call = one consent). EVERY LIBRARY IS MULTI-PART - a single-part library is simply parts:[one entry], and there is no separate single-part verb to choose. Pass parts:[{package, lbrPath, modelPath}] for any number of parts; the older {libraryPath, stepPath} shape still works and is normalised into a one-element parts list, so both run the identical code path and the ETA scales with the part count either way. TWO entry shapes: A) {libraryPath, stepPath} - a ready .lbr plus the 3D; B) {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?} - KiCad sources; needs adom-lbr, which is Linux-container-only by platform rule, so on Windows/Mac boxes shape B refuses IMMEDIATELY (adom_lbr_unavailable, before any staging or Fusion launch - fusion_readiness reports kicadIngest up front). Convert at AUTHORING time in a container and use shape A. pinMap = explicit {pinName: padName} for when pin numbers do not match pad names (BGA-style). Chains convert -> bind 3D -> prove (four evidence frames + verdict) in the BACKGROUND and returns immediately; poll fusion_prove_result (stage narrates; top-level `verdict` proven/not_proven is the score - `stage:done` only means the chain finished). EVERY PART IS PROVED, not just the first: a 10-component library streams 40 evidence frames, FOUR per component (symbol, footprint, 3d, ganged) in assembly order, each tagged with `part` so a front end can group them as they arrive, and `provedParts[]` reports per-part pass/fail. The library is proven only if EVERY part is - one good part does not vouch for nine unchecked ones. On the FINAL fetch pass {inlineFrames:true} to get each evidence frame as a base64 thumbnailDataUrl a browser can render (plain polls return box paths only). The chain closes its library document on success AND failure - batches need no between-part sweeping. stepPath is required for a PROVEN library - no real 3D body without it.", "timeoutSeconds": 30 },+ {+ "example": {},+ "input": {+ "expectDocument": "the design's name (its schematic or board)",+ "path": "sheet number",+ "ref": "'U1'",+ "refs": "['U1','C3']",+ "select": "bool, default false",+ "symbolId": "'U1:G$1'"+ },+ "longRunning": false,+ "name": "fusion_cross_probe",+ "output": {+ "matches": "[{ref, linked, board:{ref,id,side,x,y,rotation}, schematic:{sheet,id,ref,symbol,value}}]",+ "selected": "[ref]",+ "symbolWithoutPart": "[ref]"+ },+ "statusVerb": null,+ "summary": "From a schematic part to the board part linked to it and back (the standard's cross_probe): Fusion links them by designator. Each match gives the board part (side, x, y, turn, package) and the schematic part (sheet, gate id, symbol, value); linked:false when one side is missing; symbolWithoutPart lists schematic parts with no board part. select:true highlights the matches in both editors.",+ "timeoutSeconds": 90+ }, { "example": { "nets": {@@ -1309,6 +1375,44 @@ "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": {+ "dryRun": true,+ "libraryPath": "C:/libs/my.lbr",+ "name": "SOIC-8",+ "pads": [+ {+ "number": "1",+ "set": {+ "sizeMm": [+ 1.5,+ 0.6+ ]+ }+ }+ ]+ },+ "input": {+ "addPads": "[{number, type: smd|thru, at, sizeMm, drillMm?, shape?, layers?, rotation?}]",+ "dryRun": "bool",+ "graphics": "[{layer, mode: append|replace|delete, widthMm?, shapes: [{type: line|arc|rect|circle|poly, ...}]}]",+ "libraryPath": "a .lbr on the Fusion machine (required)",+ "metadata": "{description}",+ "name": "the footprint (required)",+ "pads": "[{number, set?: {sizeMm, at, shape, drillMm, layers, rotation, type, newNumber}, delete?: true}]",+ "renumber": "{map: {'1': 'A1'}} | {sequential: true, start?: 1}"+ },+ "longRunning": false,+ "name": "fusion_edit_footprint",+ "output": {+ "backup": "path",+ "footprint": "as fusion_read_footprint answers it",+ "summary": "{changed, renamed, connectsDropped}"+ },+ "statusVerb": null,+ "summary": "Edit one footprint of a .lbr in place, offline (the standard's edit_footprint): pads changed (size, position, shape, drill, layer, turn, type, number), deleted, added or renumbered (map or sequential), with the devices' connects following a renamed pad and a deleted pad's connects dropped and listed; graphics appended to, replaced on or deleted from a layer (line, arc, rect, circle, poly); the description set. The result must pass the same preflight as fusion_preflight_lbr before it is written; the old file is kept as .bak. 3D models are Fusion cloud links and are refused here.",+ "timeoutSeconds": 30+ }, { "example": { "edits": [@@ -1806,6 +1910,30 @@ "summary": "Export the electronics LIBRARY in front as an EAGLE .scr script via Electron.run EXPORT SCRIPT: a text dump of every symbol, package and device, the round-trip check that a library loaded correctly. Requires an electronics editor to be active (fusion_open_lbr puts the library in front). Returns the path, size and the first 2000 characters as a preview; pull the file to read the rest.", "timeoutSeconds": 60 },+ {+ "example": {+ "libraryPath": "C:/libs/ADS8588SIPM.fusion.lbr",+ "part": "ADS8588SIPM"+ },+ "input": {+ "footprint": "only this package",+ "libraryPath": "a .lbr on the Fusion machine (required)",+ "outputDir": "default the library's folder",+ "part": "the deviceset's name, or Lib:Name (required)"+ },+ "longRunning": false,+ "name": "fusion_export_library_part",+ "output": {+ "exports": "{symbol, footprint, model, library}",+ "missing": "[model]",+ "models": "[{name, urn, type}]",+ "packages": "[str]",+ "symbols": "[str]"+ },+ "statusVerb": null,+ "summary": "One part of a .lbr into its own small library (the standard's export_library_part): the deviceset with only the symbols its gates use and the packages its devices use (or only one footprint). Offline, no Fusion. A Fusion library holds symbol and footprint together, so both exports name the one file; 3D packages are cloud items, listed by URN.",+ "timeoutSeconds": 30+ }, { "example": { "outputPath": "C:/out/part.obj"@@ -2207,6 +2335,44 @@ "summary": "Import a STEP/.stp (or other native-importable) file into a NEW Fusion design via the native import API. The open-electronics hints redirect .step here. (Was implemented but missing from this catalog until the verb suite's reverse-drift check caught it, 2026-08-14.)", "timeoutSeconds": 300 },+ {+ "example": {+ "at": [+ 5,+ 20+ ],+ "dryRun": true,+ "layer": "F.SilkS",+ "svgPath": "C:/art/logo.svg",+ "widthMm": 8+ },+ "input": {+ "anchor": "topleft|center",+ "at": "[x, y] mm (required)",+ "dryRun": "bool",+ "expectDocument": "required: the board's name",+ "layer": "default F.SilkS (B.SilkS, F.Mask, B.Mask, F.Paste, B.Paste, or a non-copper Fusion layer)",+ "mirror": "default true on bottom layers",+ "scale": "default 1 (the SVG's own size)",+ "svg": "the SVG text instead",+ "svgPath": "an .svg on the Fusion machine",+ "toleranceMm": "row height and curve tolerance, default 0.02",+ "widthMm": "the artwork's width"+ },+ "longRunning": false,+ "name": "fusion_import_svg",+ "output": {+ "boxMm": "[x1, y1, x2, y2]",+ "ids": "[id]",+ "rects": "int",+ "shapes": "int",+ "sizeMm": "[w, h]",+ "skipped": "[{element, why}]"+ },+ "statusVerb": null,+ "summary": "Import SVG artwork (a logo, a label) onto the board (the standard's import_svg): paths, rects, circles, ellipses and polygons with their transforms; curves and arcs flattened to toleranceMm; a shape inside another is a hole. Fusion's POLYGON asks for a signal name in a dialog, so the artwork is drawn as rectangles row by row (toleranceMm rows, repeated rows merged). Bottom layers are mirrored so it reads from the back. Read back; if anything else changed, every rectangle is undone. Copper layers are refused: Fusion draws copper shapes as signal pours and asks for the name in a dialog.",+ "timeoutSeconds": 120+ }, { "example": { "worldSpace": true@@ -3343,6 +3509,107 @@ "summary": "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 staging folder, and caches the PCB design-rules catalog from adom/pcb-design-rules. Adom Bridge calls it once per installed version in the background (bridge.json prewarm.verb); safe to call again any time. Launching Fusion is never part of it.", "timeoutSeconds": 120 },+ {+ "example": {+ "dryRun": true,+ "layers": 4,+ "name": "Sensor-Hub",+ "parentDir": "C:/Users/me/Documents/Boards",+ "textVariables": {+ "REV": "A"+ },+ "title": "Sensor hub"+ },+ "input": {+ "dryRun": "bool",+ "fabRules": "a fab from adom/pcb-design-rules, recorded for later",+ "layers": "2|4|6|8, default 2",+ "name": "the design's name (required)",+ "overwrite": "bool",+ "parentDir": "a folder on the Fusion machine (required)",+ "textVariables": "{'REV': 'A'}",+ "title": "str"+ },+ "longRunning": false,+ "name": "fusion_project_create",+ "output": {+ "files": "[sch, brd]",+ "layerSetup": "str",+ "layers": "int",+ "project": "folder",+ "steps": "[str]"+ },+ "statusVerb": null,+ "summary": "A new, empty electronics design (the standard's project_create): an empty schematic and board written as EAGLE source in <parentDir>/<name>/ (the board's copper count in its layer setup; title and text variables as the schematic's global attributes) and opened in Fusion as a new design, dialog-free. Save it to the cloud with fusion_save_to_cloud.",+ "timeoutSeconds": 180+ },+ {+ "example": {+ "expectDocument": "BQ25792"+ },+ "input": {+ "expectDocument": "the design's name (default the document in front)",+ "project": "the same, in the standard's word"+ },+ "longRunning": false,+ "name": "fusion_project_info",+ "output": {+ "board": "{layers, parts, nets, sizeMm}",+ "files": "[{kind, name, open, saved, modified, path, version, modifiedAt}]",+ "modifiedAt": "epoch s",+ "name": "str",+ "netClasses": "[str]",+ "project": "the cloud project",+ "sheets": "the tree, as sch_hierarchy"+ },+ "statusVerb": null,+ "summary": "A Fusion electronics design at a glance (the standard's project_info): its documents (the open ones and its cloud files of the same name: project, schematic, board, 3D PCB) with version and date, the sheet tree, the board (copper layers, parts, nets, size) and its net classes. Read only; the design must be open.",+ "timeoutSeconds": 120+ },+ {+ "example": {+ "dryRun": true,+ "newName": "Sensor-Hub-v2",+ "project": "C:/Boards/Sensor-Hub"+ },+ "input": {+ "dryRun": "bool",+ "newName": "str (required)",+ "project": "a .sch, .brd or the folder holding them, on the Fusion machine (required)"+ },+ "longRunning": false,+ "name": "fusion_project_rename",+ "output": {+ "changed": "[]",+ "renamed": "[{from, to}]"+ },+ "statusVerb": null,+ "summary": "Rename a design kept as EAGLE files (the standard's project_rename): <name>.sch, .brd, .lbr and EAGLE's numbered backups become <newName>.*, and a folder named after the design is renamed with them. EAGLE files do not name each other, so nothing inside changes. Refused while Fusion has the design open. A Fusion cloud design is renamed in Fusion's data panel.",+ "timeoutSeconds": 30+ },+ {+ "example": {+ "expectDocument": "BQ25792",+ "label": "before-reroute"+ },+ "input": {+ "expectDocument": "the design's name (default the document in front)",+ "label": "added to the folder name",+ "outputDir": "default Documents/Adom Snapshots/<design>",+ "pdfs": "bool, default true"+ },+ "longRunning": false,+ "name": "fusion_project_snapshot",+ "output": {+ "files": "[path]",+ "folder": "path",+ "notes": "[str]",+ "pdfs": "{made: [path], skipped}"+ },+ "statusVerb": null,+ "summary": "A dated plain-file copy of a Fusion design (the standard's project_snapshot): a folder on the Fusion machine with the schematic and board as EAGLE XML, a PNG of every sheet and of the board, and PDFs made from them. Fusion's cloud versions stay as they are; save one with fusion_save_document.",+ "timeoutSeconds": 300+ }, { "example": { "runId": "a3f1c2d4e5b6"@@ -4730,6 +4997,29 @@ "summary": "Read/toggle the 'Adom is driving Fusion' TASKBAR OVERLAY BADGE (STICKY per user, default ON). While the bridge drives Fusion, its Windows taskbar button wears a small Adom mark so the user knows at a glance who is in control; it sheds automatically after 5 minutes of driving-verb idle. No args = read. Turning it off clears any badge immediately.", "timeoutSeconds": 15 },+ {+ "example": {+ "count": 6,+ "dryRun": true,+ "expectDocument": "BQ25792"+ },+ "input": {+ "count": "2|4|6|8 (required)",+ "dryRun": "bool",+ "expectDocument": "required: the board's name"+ },+ "longRunning": false,+ "name": "fusion_set_layer_count",+ "output": {+ "from": "[layer]",+ "layers": "[F.Cu, In1.Cu, ..., B.Cu]",+ "stackup": "[{type, name, thickness}]",+ "thicknessMm": "number"+ },+ "statusVerb": null,+ "summary": "Set the board's copper layer count to 2, 4, 6 or 8 (the standard's set_layer_count): the board's design rules are saved, given the new layer setup and a regenerated stack-up (copper kept per layer, 0.2 mm prepreg beside the outer layers, cores sharing the rest, so the board thickness is kept), and loaded back. A layer being removed that still carries copper or graphics is refused with the counts. Read back; if the layers are not the ones asked, the board's own rules are loaded back.",+ "timeoutSeconds": 120+ }, { "example": { "displayed": false,
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.12.7"+version = "1.12.8" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.7"+version = "1.12.8" edition = "2021" [dependencies]
describe.py+41−1@@ -263,6 +263,46 @@ _T = [ {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-gencad)"}, {"path": "str", "components": "int", "signals": "int", "pads": "int", "shapes": "int"}, 180, None, False, {}),+ ('fusion_cross_probe', "From a schematic part to the board part linked to it and back (the standard's cross_probe): Fusion links them by designator. Each match gives the board part (side, x, y, turn, package) and the schematic part (sheet, gate id, symbol, value); linked:false when one side is missing; symbolWithoutPart lists schematic parts with no board part. select:true highlights the matches in both editors.",+ {"expectDocument": "the design's name (its schematic or board)", "ref": "'U1'", "refs": "['U1','C3']", "symbolId": "'U1:G$1'", "path": "sheet number", "select": "bool, default false"},+ {"matches": "[{ref, linked, board:{ref,id,side,x,y,rotation}, schematic:{sheet,id,ref,symbol,value}}]", "symbolWithoutPart": "[ref]", "selected": "[ref]"},+ 90, None, False, {}),+ ('fusion_import_svg', "Import SVG artwork (a logo, a label) onto the board (the standard's import_svg): paths, rects, circles, ellipses and polygons with their transforms; curves and arcs flattened to toleranceMm; a shape inside another is a hole. Fusion's POLYGON asks for a signal name in a dialog, so the artwork is drawn as rectangles row by row (toleranceMm rows, repeated rows merged). Bottom layers are mirrored so it reads from the back. Read back; if anything else changed, every rectangle is undone. Copper layers are refused: Fusion draws copper shapes as signal pours and asks for the name in a dialog.",+ {"expectDocument": "required: the board's name", "svgPath": "an .svg on the Fusion machine", "svg": "the SVG text instead", "at": "[x, y] mm (required)", "widthMm": "the artwork's width", "scale": "default 1 (the SVG's own size)", "anchor": "topleft|center", "layer": "default F.SilkS (B.SilkS, F.Mask, B.Mask, F.Paste, B.Paste, or a non-copper Fusion layer)", "mirror": "default true on bottom layers", "toleranceMm": "row height and curve tolerance, default 0.02", "dryRun": "bool"},+ {"rects": "int", "shapes": "int", "sizeMm": "[w, h]", "ids": "[id]", "skipped": "[{element, why}]", "boxMm": "[x1, y1, x2, y2]"},+ 120, None, False, {"svgPath": "C:/art/logo.svg", "at": [5, 20], "widthMm": 8, "layer": "F.SilkS", "dryRun": True}),+ ('fusion_copy_routing', "Copy the tracks and through vias inside a box to an offset (the standard's copy_routing), remapping nets by pairing parts (refMap or refPattern: each source pad's net goes to the partner pad's net), then netMap. A track is copied when both ends are in the box, a via when its centre is. A destination end on another net's copper is refused before anything is drawn (Fusion would ask to join the nets). Read back exactly; with validate (default) Fusion's DRC runs and any new error undoes the copy.",+ {"expectDocument": "required: the board's name", "box": "[x1, y1, x2, y2] mm (required)", "offset": "[dx, dy] mm (required)", "refMap": "{'U1': 'U5'}", "refPattern": "{from: 'CH1_*', to: 'CH2_*'}", "netMap": "{'SIG': 'SIG2'}", "keepUnmappedNets": "bool, default false", "layers": "[layer]", "validate": "bool, default true", "dryRun": "bool"},+ {"copied": "{tracks, vias}", "netMap": "{from: to}", "pairs": "{ref: ref}", "skipped": "[{item, why}]", "clashes": "[{item, at, touches}]", "drc": "{ran, newErrors}"},+ 180, None, False, {"expectDocument": "BQ25792", "box": [20, 13, 30, 16], "offset": [0, -10], "refMap": {"C12": "C13"}, "dryRun": True}),+ ('fusion_set_layer_count', "Set the board's copper layer count to 2, 4, 6 or 8 (the standard's set_layer_count): the board's design rules are saved, given the new layer setup and a regenerated stack-up (copper kept per layer, 0.2 mm prepreg beside the outer layers, cores sharing the rest, so the board thickness is kept), and loaded back. A layer being removed that still carries copper or graphics is refused with the counts. Read back; if the layers are not the ones asked, the board's own rules are loaded back.",+ {"expectDocument": "required: the board's name", "count": "2|4|6|8 (required)", "dryRun": "bool"},+ {"layers": "[F.Cu, In1.Cu, ..., B.Cu]", "from": "[layer]", "stackup": "[{type, name, thickness}]", "thicknessMm": "number"},+ 120, None, False, {"expectDocument": "BQ25792", "count": 6, "dryRun": True}),+ ('fusion_export_library_part', "One part of a .lbr into its own small library (the standard's export_library_part): the deviceset with only the symbols its gates use and the packages its devices use (or only one footprint). Offline, no Fusion. A Fusion library holds symbol and footprint together, so both exports name the one file; 3D packages are cloud items, listed by URN.",+ {"libraryPath": "a .lbr on the Fusion machine (required)", "part": "the deviceset's name, or Lib:Name (required)", "footprint": "only this package", "outputDir": "default the library's folder"},+ {"exports": "{symbol, footprint, model, library}", "symbols": "[str]", "packages": "[str]", "models": "[{name, urn, type}]", "missing": "[model]"},+ 30, None, False, {"libraryPath": "C:/libs/ADS8588SIPM.fusion.lbr", "part": "ADS8588SIPM"}),+ ('fusion_project_info', "A Fusion electronics design at a glance (the standard's project_info): its documents (the open ones and its cloud files of the same name: project, schematic, board, 3D PCB) with version and date, the sheet tree, the board (copper layers, parts, nets, size) and its net classes. Read only; the design must be open.",+ {"expectDocument": "the design's name (default the document in front)", "project": "the same, in the standard's word"},+ {"name": "str", "project": "the cloud project", "files": "[{kind, name, open, saved, modified, path, version, modifiedAt}]", "sheets": "the tree, as sch_hierarchy", "board": "{layers, parts, nets, sizeMm}", "netClasses": "[str]", "modifiedAt": "epoch s"},+ 120, None, False, {"expectDocument": "BQ25792"}),+ ('fusion_project_snapshot', "A dated plain-file copy of a Fusion design (the standard's project_snapshot): a folder on the Fusion machine with the schematic and board as EAGLE XML, a PNG of every sheet and of the board, and PDFs made from them. Fusion's cloud versions stay as they are; save one with fusion_save_document.",+ {"expectDocument": "the design's name (default the document in front)", "label": "added to the folder name", "outputDir": "default Documents/Adom Snapshots/<design>", "pdfs": "bool, default true"},+ {"folder": "path", "files": "[path]", "pdfs": "{made: [path], skipped}", "notes": "[str]"},+ 300, None, False, {"expectDocument": "BQ25792", "label": "before-reroute"}),+ ('fusion_project_create', "A new, empty electronics design (the standard's project_create): an empty schematic and board written as EAGLE source in <parentDir>/<name>/ (the board's copper count in its layer setup; title and text variables as the schematic's global attributes) and opened in Fusion as a new design, dialog-free. Save it to the cloud with fusion_save_to_cloud.",+ {"parentDir": "a folder on the Fusion machine (required)", "name": "the design's name (required)", "title": "str", "layers": "2|4|6|8, default 2", "textVariables": "{'REV': 'A'}", "fabRules": "a fab from adom/pcb-design-rules, recorded for later", "overwrite": "bool", "dryRun": "bool"},+ {"project": "folder", "files": "[sch, brd]", "layers": "int", "layerSetup": "str", "steps": "[str]"},+ 180, None, False, {"parentDir": "C:/Users/me/Documents/Boards", "name": "Sensor-Hub", "layers": 4, "title": "Sensor hub", "textVariables": {"REV": "A"}, "dryRun": True}),+ ('fusion_edit_footprint', "Edit one footprint of a .lbr in place, offline (the standard's edit_footprint): pads changed (size, position, shape, drill, layer, turn, type, number), deleted, added or renumbered (map or sequential), with the devices' connects following a renamed pad and a deleted pad's connects dropped and listed; graphics appended to, replaced on or deleted from a layer (line, arc, rect, circle, poly); the description set. The result must pass the same preflight as fusion_preflight_lbr before it is written; the old file is kept as .bak. 3D models are Fusion cloud links and are refused here.",+ {"libraryPath": "a .lbr on the Fusion machine (required)", "name": "the footprint (required)", "pads": "[{number, set?: {sizeMm, at, shape, drillMm, layers, rotation, type, newNumber}, delete?: true}]", "addPads": "[{number, type: smd|thru, at, sizeMm, drillMm?, shape?, layers?, rotation?}]", "renumber": "{map: {'1': 'A1'}} | {sequential: true, start?: 1}", "graphics": "[{layer, mode: append|replace|delete, widthMm?, shapes: [{type: line|arc|rect|circle|poly, ...}]}]", "metadata": "{description}", "dryRun": "bool"},+ {"summary": "{changed, renamed, connectsDropped}", "footprint": "as fusion_read_footprint answers it", "backup": "path"},+ 30, None, False, {"libraryPath": "C:/libs/my.lbr", "name": "SOIC-8", "pads": [{"number": "1", "set": {"sizeMm": [1.5, 0.6]}}], "dryRun": True}),+ ('fusion_project_rename', "Rename a design kept as EAGLE files (the standard's project_rename): <name>.sch, .brd, .lbr and EAGLE's numbered backups become <newName>.*, and a folder named after the design is renamed with them. EAGLE files do not name each other, so nothing inside changes. Refused while Fusion has the design open. A Fusion cloud design is renamed in Fusion's data panel.",+ {"project": "a .sch, .brd or the folder holding them, on the Fusion machine (required)", "newName": "str (required)", "dryRun": "bool"},+ {"renamed": "[{from, to}]", "changed": "[]"},+ 30, None, False, {"project": "C:/Boards/Sensor-Hub", "newName": "Sensor-Hub-v2", "dryRun": True}), ('fusion_read_footprint', "Every detail of one library footprint (the standard's read_footprint): pads with number, type, shape, position, size, drill, layers and turn; the layers used; the courtyard box (component exclude layers 39/40, else pads and placement outline); graphics per layer; the 3D packages that instance it. From a .lbr (libraryPath + name) or, by libId 'Lib:Name', from the libraries the open board embeds.", {"libraryPath": "a .lbr on the Fusion machine (with name)", "name": "the package's name", "libId": "'Library:Package' from the board's embedded libraries", "expectDocument": "the board's name, for libId"}, {"name": "str", "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]", "layers": "[str]", "courtyardMm": "[x1, y1, x2, y2]", "graphics": "{layer: count}", "attributes": "{mount}", "models": "[{name, urn, type}]"},@@ -521,7 +561,7 @@ _T = [ # tab and to every caller reading fusion_describe - never let these drift again. ("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_document", "Close ONE document without a modal save dialog. Omit name to close the active document. An unchanged document, or a never-saved scratch one, closes at once. A saved cloud document with unsaved changes is refused (unsaved_cloud_document): Fusion asks to save it even when told not to, so closing it means saving a version, which needs save:true (or saveToClose:true).", {"name": "optional str (default: active document)", "save": "optional bool default false: save a version, then close", "saveToClose": "optional bool: save a version only because the close needs it"}, {"success": "bool", "closed": "str"}, 60, None, False, {"save": False}), ("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}),
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.7"+DASH_VERSION="1.12.8" 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.7",+ "version": "1.12.8", "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.7",+ "version": "1.12.8", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -143,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.12.7"+version = "1.12.8" edition = "2021" license = "MIT"
rust/bridge/src/addin.rs+15@@ -249,6 +249,21 @@ pub fn pass_through(cmd: &str, args: &Value, ctx: &Ctx) -> Value { "_hint": "The active document changed: a Fusion tab switch retargets the active document. Re-activate the intended document (fusion_activate_document), or re-issue with expectDocument set to the current name (a rename mid-session also changes it)."}); } }+ // close_document {save:false} on a changed cloud document: Fusion asks to save even with+ // saveChanges=False, so the add-in used to save a version first, silently (it saved+ // BQ25792 v228 for a caller who asked to discard, 2026-10-28). Saving needs save:true or+ // saveToClose:true now; otherwise the close is refused and nothing is written.+ if cmd == "close_document" && args["save"].as_bool() != Some(true) && args["saveToClose"].as_bool() != Some(true) {+ let name = match args["name"].as_str() { Some(n) => serde_json::to_string(n).unwrap_or("None".into()), None => "None".into() };+ let probe = format!("import adsk.core\napp=adsk.core.Application.get()\nn={name}\nd=None\nfor i in range(app.documents.count):\n x=app.documents.item(i)\n if (n is None and x==app.activeDocument) or x.name==n:\n d=x; break\nresult={{'found': d is not None, 'cloud': bool(d is not None and d.dataFile is not None), 'modified': bool(d is not None and d.isModified), 'name': d.name if d else None}}\n");+ let pr = call_addin("run_modeling_script", &json!({"script": probe}), 15, sidecar);+ let r = &pr["data"]["result"];+ if r["cloud"].as_bool() == Some(true) && r["modified"].as_bool() == Some(true) {+ return json!({"success": false, "errorCode": "unsaved_cloud_document", "document": r["name"],+ "error": format!("'{}' is a saved cloud document with unsaved changes. Fusion asks to save it even when told not to, so closing it now would mean saving a new version. Nothing was closed or saved.", r["name"].as_str().unwrap_or("")),+ "_hint": "Keep it open, or pass save:true to save a version and close (saveToClose:true does the same with the bridge's own version comment). Changes you do not want are best undone before saving."});+ }+ } // raw close_document gate: never close over an in-flight Hub upload (John, 2026-08-21) if cmd == "close_document" || cmd == "close_all_documents" { let deadline = Instant::now() + Duration::from_secs(180);
rust/bridge/src/checks.rs+1−1@@ -42,6 +42,6 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val if let Some(v) = r[key]["verb"].as_str().map(str::to_string) { r[key]["verb"] = json!(format!("fusion_{v}")); } } for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } }- r["engine"] = json!("adom-eda-checks 0.1.2 (EDA Bridge Standard 0.4.3, vendored)");+ r["engine"] = json!("adom-eda-checks 0.1.4 (EDA Bridge Standard 0.4.5, vendored)"); r }
rust/bridge/src/demo.rs+1−1@@ -301,7 +301,7 @@ pub fn demo_tour(ctx: &Ctx, args: &Value, env: TourEnv, pass: &dyn Fn(&str, Valu let _ = pass2("run_modeling_script", json!({"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"})); sleep_s(2.0); let _ = crate::small_verbs::dismiss_blocking_dialogs(&sd2, &json!({}), &caller, &pids, &|_| {});- let _ = pass2("close_document", json!({"save": false})); sleep_s(1.0);+ let _ = pass2("close_document", json!({"save": false, "saveToClose": true})); sleep_s(1.0); } else { update_state(&id, |s| push_to(s, "failedSteps", json!(key))); } } }
rust/bridge/src/docs.rs+146−1@@ -2,7 +2,7 @@ //! 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" | "read_footprint") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d" | "read_footprint" | "project_info" | "project_snapshot" | "project_rename") } 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());@@ -13,6 +13,9 @@ fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { match cmd { "read_footprint" => read_footprint(args, pass),+ "project_info" => project_info(args, pass),+ "project_snapshot" => project_snapshot(args, pass),+ "project_rename" => project_rename(args, pass), "document_info" => { let mut old = pass("document_info", args.clone()); let std_ = script(cmd, args, pass);@@ -77,3 +80,145 @@ fn read_footprint(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { Err(e) => json!({"success": false, "errorCode": "footprint_not_found", "error": e.to_string()}), } }++/// The standard's project_info for a Fusion electronics design: its documents (open ones and the+/// cloud files of the same name beside them, with version and date), the sheet tree, the board+/// (copper layers, parts, nets, size) and the net classes. Read only; the design must be open.+fn project_info(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut a = args.clone();+ if a["expectDocument"].is_null() && a["project"].is_string() { a["expectDocument"] = a["project"].clone(); }+ let files = script("project_files", &a, pass);+ if files["success"].as_bool() != Some(true) {+ let mut f = files;+ f["_hint"] = json!("Open the design first (fusion_open_cloud_file {fileName, projectName}); project_info reads what Fusion has open and the cloud files beside it.");+ return f;+ }+ let ea = json!({"expectDocument": files["name"]});+ let sheets = crate::sch::call("sch_hierarchy", &ea, pass);+ let st = crate::routing::call("routing_state", &ea, pass);+ let classes = crate::routing::call("net_classes", &ea, pass);+ let size = st["outline"].as_array().filter(|o| !o.is_empty()).map(|o| {+ let xs: Vec<f64> = o.iter().filter_map(|p| p[0].as_f64()).collect();+ let ys: Vec<f64> = o.iter().filter_map(|p| p[1].as_f64()).collect();+ let span = |v: &Vec<f64>| (v.iter().cloned().fold(f64::MIN, f64::max) - v.iter().cloned().fold(f64::MAX, f64::min));+ json!([(span(&xs) * 1000.0).round() / 1000.0, (span(&ys) * 1000.0).round() / 1000.0])+ });+ let board = if st["success"].as_bool() == Some(false) { json!({"error": st["error"]}) } else {+ json!({"layers": st["copperLayers"], "parts": st["components"].as_array().map(|c| c.len()), "nets": st["nets"].as_array().map(|n| n.len()), "sizeMm": size})+ };+ let names: Vec<Value> = classes["classes"].as_array().or(classes["netClasses"].as_array()).map(|c| c.iter().map(|x| x["name"].clone()).collect()).unwrap_or_default();+ json!({"success": true, "name": files["name"], "project": files["project"], "files": files["files"], "open": true, "modifiedAt": files["modifiedAt"],+ "sheets": if sheets["success"].as_bool() == Some(false) { json!({"error": sheets["error"]}) } else { sheets["tree"].clone() },+ "board": board, "netClasses": names, "textVariables": {},+ "_hint": "Fusion keeps a design as cloud files (project, schematic, board, 3D PCB) of one name; versions are cloud versions and dates are epoch seconds. Fusion has no text variables."})+}++/// The standard's project_snapshot for a Fusion design: a dated folder on the Fusion machine with+/// the design's schematic and board as EAGLE XML, a PNG of every sheet and of the board, and PDFs+/// made from them. The cloud keeps Fusion's own versions; this is the plain-file copy beside them.+fn project_snapshot(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let mut a = args.clone();+ if a["expectDocument"].is_null() && a["project"].is_string() { a["expectDocument"] = a["project"].clone(); }+ let files = script("project_files", &a, pass);+ if files["success"].as_bool() != Some(true) { return files; }+ let name = files["name"].as_str().unwrap_or("design").to_string();+ let safe = |t: &str| t.chars().map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '_' }).collect::<String>();+ let stamp = chrono::Local::now().format("%Y-%m-%d_%H%M%S").to_string();+ let label = a["label"].as_str().filter(|l| !l.is_empty()).map(|l| format!("-{}", safe(l))).unwrap_or_default();+ let base = a["outputDir"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| {+ let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_else(|_| ".".into()).replace('\\', "/");+ format!("{home}/Documents/Adom Snapshots/{}", safe(&name))+ });+ let folder = format!("{}/{stamp}{label}", base.trim_end_matches('/'));+ if let Err(e) = std::fs::create_dir_all(&folder) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{folder}: {e}")}); }+ let front = script("front_tab", &json!({}), pass)["index"].clone();+ let mut written: Vec<String> = Vec::new();+ let mut notes: Vec<String> = Vec::new();+ let mut board_png: Option<(String, f64)> = None;+ let tab_of = |kind: &str| files["files"].as_array().and_then(|f| f.iter().find(|r| r["kind"] == kind && r["open"] == true)).map(|r| r["tab"].clone());+ for (kind, ext) in [("schematic", "sch"), ("board", "brd")] {+ let Some(tab) = tab_of(kind) else { notes.push(format!("no {kind} open, so no .{ext}")); continue };+ let _ = script("switch_tab", &json!({"index": tab}), pass);+ let out = format!("{folder}/{}.{ext}", safe(&name));+ let r = pass("export_eagle_source", json!({"outputPath": out}));+ if r["success"].as_bool() == Some(true) { written.push(out); } else { notes.push(format!("{ext}: {}", r["error"].as_str().unwrap_or("export failed"))); }+ if kind == "board" {+ let png = format!("{folder}/{}-board.png", safe(&name));+ let r = pass("export_board_image", json!({"outputPath": png, "preset": "all", "dpi": 300}));+ if r["success"].as_bool() == Some(true) { written.push(png.clone()); board_png = Some((png, r["dpi"].as_f64().unwrap_or(300.0))); } else { notes.push(format!("board image: {}", r["error"].as_str().unwrap_or("failed"))); }+ }+ }+ if !front.is_null() { let _ = script("switch_tab", &json!({"index": front}), pass); }+ let mut sheet_pngs: Vec<(String, f64)> = Vec::new();+ if tab_of("schematic").is_some() {+ let tree = crate::sch::call("sch_hierarchy", &json!({"expectDocument": name}), pass);+ let n = tree["tree"]["sheets"].as_array().map(|s| s.len()).unwrap_or(1).max(1);+ for k in 1..=n {+ let r = crate::sch::call("render_schematic", &json!({"expectDocument": name, "sheet": k, "outputDir": folder, "name": format!("{}-sheet{k}", safe(&name)), "width": 3000}), pass);+ match r["png"].as_str() { Some(p) => { written.push(p.to_string()); sheet_pngs.push((p.to_string(), r["dpi"].as_f64().unwrap_or(150.0))); }, None => notes.push(format!("sheet {k}: {}", r["error"].as_str().unwrap_or("render failed"))) }+ }+ }+ let mut made: Vec<String> = Vec::new();+ if a["pdfs"].as_bool() != Some(false) {+ if !sheet_pngs.is_empty() {+ let p = format!("{folder}/{}-schematic.pdf", safe(&name));+ match crate::pdf::images_to_pdf(&sheet_pngs, &p) { Ok(_) => { made.push(p.clone()); written.push(p); }, Err(e) => notes.push(format!("schematic pdf: {e}")) }+ }+ if let Some(bp) = &board_png {+ let p = format!("{folder}/{}-board.pdf", safe(&name));+ match crate::pdf::images_to_pdf(std::slice::from_ref(bp), &p) { Ok(_) => { made.push(p.clone()); written.push(p); }, Err(e) => notes.push(format!("board pdf: {e}")) }+ }+ }+ json!({"success": !written.is_empty(), "folder": folder, "files": written, "pdfs": {"made": made, "skipped": if a["pdfs"].as_bool() == Some(false) { json!("pdfs:false") } else { json!(Value::Null) }},+ "notes": notes, "design": name,+ "_hint": "A plain-file copy of the design beside Fusion's own cloud versions: EAGLE XML of the schematic and board, a PNG per sheet and of the board, and PDFs of them. Save a cloud version with fusion_save_document."})+}++/// The standard's project_rename for a design kept as EAGLE files: <folder>/<name>.sch and .brd+/// (and EAGLE's numbered backups) become <newName>.*, and a folder named after the design is+/// renamed with them. EAGLE files do not name each other, so nothing inside changes. Refused while+/// Fusion has the design open. A Fusion cloud design is renamed in Fusion's data panel, not here.+fn project_rename(args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let project = args["project"].as_str().unwrap_or("").replace('\\', "/");+ let new_name = args["newName"].as_str().unwrap_or("").trim().to_string();+ if project.is_empty() || new_name.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "project (a .sch, .brd or the folder holding them) and newName are required"}); }+ if new_name.chars().any(|c| "/\\:*?\"<>|".contains(c)) || new_name.len() > 64 { return json!({"success": false, "errorCode": "bad_args", "error": "newName: no path characters, 64 at most"}); }+ let p = std::path::Path::new(&project);+ if !p.exists() {+ return json!({"success": false, "errorCode": "not_supported", "error": format!("{project} is not a file or folder on the Fusion machine"),+ "_hint": "A Fusion cloud design is renamed in Fusion's data panel (its project, schematic, board and 3D PCB together). project_rename renames a design kept as EAGLE files."});+ }+ let (dir, old) = if p.is_dir() {+ let stem = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+ (p.to_path_buf(), stem)+ } else {+ (p.parent().map(|d| d.to_path_buf()).unwrap_or_default(), p.file_stem().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())+ };+ let open = script("project_files", &json!({"expectDocument": old}), pass);+ if open["success"].as_bool() == Some(true) {+ return json!({"success": false, "errorCode": "open_in_fusion", "error": format!("Fusion has the design {old} open; close it first (fusion_close_document)")});+ }+ let mut renamed: Vec<Value> = Vec::new();+ let mut plan: Vec<(std::path::PathBuf, std::path::PathBuf)> = Vec::new();+ if let Ok(rd) = std::fs::read_dir(&dir) {+ for e in rd.flatten() {+ let fname = e.file_name().to_string_lossy().to_string();+ let Some(rest) = fname.strip_prefix(&old) else { continue };+ // name.sch, name.brd, name.lbr and EAGLE's backups name.s#1 / name.b#2+ let ok = rest.starts_with('.') && regex::Regex::new(r"^\.(sch|brd|lbr|s#\d+|b#\d+|l#\d+|pro|epf)$").unwrap().is_match(&rest.to_lowercase());+ if ok { plan.push((e.path(), dir.join(format!("{new_name}{rest}")))); }+ }+ }+ if plan.is_empty() { return json!({"success": false, "errorCode": "nothing_to_rename", "error": format!("no {old}.sch or {old}.brd in {}", dir.display())}); }+ if let Some((_, to)) = plan.iter().find(|(_, to)| to.exists()) { return json!({"success": false, "errorCode": "exists", "error": format!("{} already exists", to.display())}); }+ let folder_too = dir.file_name().map(|f| f.to_string_lossy() == old).unwrap_or(false);+ let new_dir = dir.parent().map(|d| d.join(&new_name)).unwrap_or_else(|| dir.clone());+ if folder_too && new_dir.exists() { return json!({"success": false, "errorCode": "exists", "error": format!("{} already exists", new_dir.display())}); }+ for (a, b) in &plan { renamed.push(json!({"from": a.to_string_lossy().replace('\\', "/"), "to": b.to_string_lossy().replace('\\', "/")})); }+ if folder_too { renamed.push(json!({"from": dir.to_string_lossy().replace('\\', "/"), "to": new_dir.to_string_lossy().replace('\\', "/")})); }+ if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "renamed": renamed, "changed": []}); }+ for (a, b) in &plan { if let Err(e) = std::fs::rename(a, b) { return json!({"success": false, "errorCode": "rename_failed", "error": format!("{}: {e}", a.display()), "renamed": renamed}); } }+ if folder_too { if let Err(e) = std::fs::rename(&dir, &new_dir) { return json!({"success": false, "errorCode": "rename_failed", "error": format!("{}: {e}", dir.display())}); } }+ json!({"success": true, "mutated": true, "renamed": renamed, "changed": [],+ "_hint": "EAGLE's schematic and board find each other by file name in one folder, so nothing inside the files needed changing. Open it again with fusion_new_electronics_from_eagle."})+}
rust/bridge/src/docs_script.py+42@@ -154,6 +154,46 @@ def render_board_3d(app, args): '_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 project_files(app, args):+ """The design's documents: the ones open in Fusion and, for a saved design, its cloud files of+ the same name in the same folder (project, schematic, board, 3D PCB), with version and date."""+ docs = [app.documents.item(i) for i in range(app.documents.count)]+ want = args.get('expectDocument') or args.get('project') or (app.activeDocument.name if app.activeDocument else None)+ mine = [d for d in docs if d.name == want]+ if not mine:+ return {'success': False, 'errorCode': 'not_open', 'error': 'No open design is named %s' % want,+ 'open': sorted({d.name for d in docs})}+ files, folder = [], None+ for d in mine:+ df = d.dataFile+ row = {'kind': KIND.get(_pt(d), 'other'), 'name': d.name, 'open': True, 'saved': df is not None, 'modified': bool(d.isModified), 'tab': docs.index(d)}+ if df is not None:+ folder = folder or df.parentFolder+ row.update({'path': _location(df), 'version': df.versionNumber})+ files.append(row)+ if folder is not None:+ seen = {f.get('path') for f in files}+ for i in range(folder.dataFiles.count):+ df = folder.dataFiles.item(i)+ if df.name != want:+ continue+ path = _location(df)+ row = next((f for f in files if f.get('path') == path), None)+ if row is None:+ row = {'kind': {'fprj': 'project', 'fsch': 'schematic', 'fbrd': 'board', 'f3d': 'model'}.get(df.fileExtension, 'other'), 'name': df.name, 'open': False, 'saved': True, 'path': path}+ files.append(row)+ row['version'] = df.latestVersionNumber+ for attr, key in (('dateModified', 'modified'), ('dateCreated', 'created')):+ try:+ v = getattr(df, attr)+ row[key + 'At'] = int(v) if v else None+ except Exception:+ pass+ modified = max([f.get('modifiedAt') or 0 for f in files] or [0]) or None+ return {'success': True, 'name': want, 'files': files, 'modifiedAt': modified, 'open': True,+ 'project': mine[0].dataFile.parentProject.name if mine[0].dataFile is not None else None}++ def handle(app, args, action): if action == 'front_tab': docs = [app.documents.item(i) for i in range(app.documents.count)]@@ -167,4 +207,6 @@ def handle(app, args, action): return doc_info(app, args) if action == 'close_all_documents': return close_all(app, args)+ if action == 'project_files':+ return project_files(app, args) return {'success': False, 'errorCode': 'unknown_action', 'error': action}
rust/bridge/src/fusion_prefs.rs+1−1@@ -90,7 +90,7 @@ pub fn reset_workspace(state_dir: &std::path::Path, args: &Value, pass: &dyn Fn( if let Some(p) = prev_total { if docs.len() >= p { break; } } prev_total = Some(docs.len()); let victim = untitled[0]["name"].clone();- let c = call("close_document", json!({"name": victim, "save": false}), 20);+ let c = call("close_document", json!({"name": victim, "save": false, "saveToClose": true}), 20); if c["success"].as_bool() != Some(true) { break; } closed.push(py_str(&victim)); }
rust/bridge/src/lifecycle.rs+1−1@@ -31,7 +31,7 @@ pub fn close_all_documents_via_addin() -> Value { for d in docs.iter().rev() { let name = d["name"].as_str().unwrap_or("").to_string(); if name.is_empty() { continue; }- match addin_post("close_document", json!({"name": name, "save": false}), 10) {+ match addin_post("close_document", json!({"name": name, "save": false, "saveToClose": true}), 10) { Some(r) if r["success"].as_bool() == Some(true) => out["closed"].as_array_mut().unwrap().push(json!(name)), Some(r) => out["errors"].as_array_mut().unwrap().push(json!(format!("{name}: {}", r["error"].as_str().unwrap_or("?")))), None => out["errors"].as_array_mut().unwrap().push(json!(format!("{name}: no answer"))),
rust/bridge/src/main.rs+69−1@@ -13,6 +13,7 @@ //! - /command body {command, args}; the response is the verb's JSON as-is mod chains;+mod pdf; mod small_verbs; mod addin; mod fusion_prefs;@@ -92,6 +93,17 @@ fn standard_adapter(verb: &str, mut parsed: Value) -> (String, Value) { parsed["args"] = na; parsed["command"] = json!("fusion_generate_package"); ("fusion_generate_package".into(), parsed) }+ "create_part_project" => {+ // the standard's part ("Lib:Name" or a deviceset) and libraryPath onto make_test_project;+ // Fusion names and places the new design itself, so projectName/outputDir do not apply+ let part = a["part"].as_str().unwrap_or("");+ let ds = part.rsplit(':').next().unwrap_or(part);+ let mut na = json!({"libraryPath": a["libraryPath"].clone()});+ if !ds.is_empty() { na["deviceset"] = json!(ds); }+ if !a["device"].is_null() { na["device"] = a["device"].clone(); }+ parsed["args"] = na; parsed["command"] = json!("fusion_make_test_project");+ ("fusion_make_test_project".into(), parsed)+ } _ => (verb.to_string(), parsed), } }@@ -501,7 +513,7 @@ fn main() { r["shell"] = json!("rust"); r }, "fusion_start" | "start" | "launch" if addin_owner_is_exe(&**h) => { let (sc, ca) = (sidecar.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let dlg = |r: Value| r; let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: h.fusion_processes().iter().map(|p| p.pid).collect(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "start", &caller); let mut r = launch::start(&ctx, &parsed["args"]); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "start", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, "fusion_open_lbr" | "open_lbr" if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let t0 = Instant::now(); let ind = timings::Indicator::start(&sd, "open_lbr", &caller); let mut r = orchestrations::open_lbr(&ctx, &parsed["args"], &|v, a| addin::pass_through(v, &a, &ctx)); ind.stop(r["success"].as_bool() == Some(false)); timings::log_run(&sd, "open_lbr", t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r },- v @ ("fusion_show_symbol" | "show_symbol" | "fusion_show_footprint" | "show_footprint" | "fusion_show_library" | "show_library" | "fusion_show_project" | "show_project" | "fusion_show_schematic" | "show_schematic" | "fusion_show_2d_board" | "show_2d_board" | "fusion_show_3d_board" | "show_3d_board" | "fusion_capture_library_views" | "capture_library_views" | "fusion_library_views" | "library_views" | "fusion_open_cloud_file" | "open_cloud_file" | "fusion_save_lbr" | "save_lbr" | "fusion_cleanup_cloud_files" | "cleanup_cloud_files" | "fusion_inspect_bodies" | "inspect_bodies" | "fusion_board_stackup" | "board_stackup" | "fusion_export_optimized_glb" | "export_optimized_glb" | "fusion_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 },+ 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_project_create" | "project_create" | "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), "project_create" => small_verbs::merge_dialog_array(testproject::project_create(&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),@@ -623,6 +635,8 @@ fn main() { "fusion_verb_times" | "verb_times" => native_verb_times(&**h, parsed["args"]["limit"].as_u64().unwrap_or(40) as usize), // Native since Phase 2: offline library checks on the golden-tested .lbr engine. "fusion_library_parts" | "library_parts" => native_library_parts(&parsed["args"]),+ "fusion_export_library_part" | "export_library_part" => native_export_library_part(&parsed["args"]),+ "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]), "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]), // Native since Phase 2: the raw authenticated APS GET (never-charge guarded). "fusion_aps_get" | "aps_get" => native_aps_get(&parsed["args"]),@@ -870,6 +884,60 @@ fn native_library_parts(args: &Value) -> Value { t.devicesets, t.variants, t.with_mpn, t.with_distributor, tail)}) } +/// The standard's export_library_part: one part of a .lbr into its own small library (its+/// symbols, its packages and its deviceset), offline. A Fusion library holds symbol and footprint+/// together, so both answers name the one file; 3D packages live in the cloud and are named by URN.+fn native_export_library_part(args: &Value) -> Value {+ let path = lbr_path_arg(args, &["libraryPath", "lbrPath", "filePath"]);+ let part = args["part"].as_str().unwrap_or("");+ let ds = part.rsplit(':').next().unwrap_or(part).to_string();+ if path.is_empty() || ds.is_empty() {+ return json!({"success": false, "errorCode": "bad_args", "error": "libraryPath and part (the deviceset's name, or Lib:Name) are required", "shell": "rust"});+ }+ let xml = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string(), "libraryPath": path, "shell": "rust"}) };+ let tmp = std::env::temp_dir().join(format!("adom-export-part-{}", std::process::id()));+ let split = match fusion_lbr::split_per_part(&xml, &tmp) { Ok(m) => m, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string(), "shell": "rust"}) };+ let key = match args["footprint"].as_str().filter(|f| !f.is_empty()) { Some(f) => format!("{ds}::{f}"), None => ds.clone() };+ let Some(entry) = split.get(&key).or_else(|| split.iter().find(|(k, _)| k.eq_ignore_ascii_case(&key)).map(|(_, v)| v)) else {+ let names: Vec<&String> = split.keys().filter(|k| !k.contains("::")).collect();+ let _ = std::fs::remove_dir_all(&tmp);+ return json!({"success": false, "errorCode": "part_not_found", "error": format!("no part {key} in the library"), "parts": names, "shell": "rust"});+ };+ let out_dir = args["outputDir"].as_str().map(|s| s.replace('\\', "/")).unwrap_or_else(|| std::path::Path::new(&path).parent().map(|p| p.to_string_lossy().replace('\\', "/")).unwrap_or_default());+ let _ = std::fs::create_dir_all(&out_dir);+ let file = std::path::Path::new(&entry.lbr_path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| format!("{ds}.lbr"));+ let dest = format!("{}/{}", out_dir.trim_end_matches('/'), file);+ let copied = std::fs::copy(&entry.lbr_path, &dest);+ let _ = std::fs::remove_dir_all(&tmp);+ if let Err(e) = copied { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string(), "path": dest, "shell": "rust"}); }+ let models: Vec<Value> = entry.packages.iter().filter_map(|pk| fusion_lbr::read_footprint(&xml, None, pk).ok()).flat_map(|f| f["models"].as_array().cloned().unwrap_or_default()).collect();+ json!({"success": true, "shell": "rust", "exports": {"symbol": dest, "footprint": dest, "model": Value::Null, "library": dest},+ "symbols": entry.symbols, "packages": entry.packages, "models": models, "missing": ["model"],+ "_hint": "One .lbr with the part's symbols, packages and deviceset (a Fusion library keeps symbol and footprint together). 3D packages are cloud items, named in models by URN, so no model file is written: fusion_show_3d_package opens one to export it."})+}++/// The standard's edit_footprint on a .lbr, offline: the edit is made, the result is checked with+/// the same preflight as fusion_preflight_lbr, and only then written in place (the old file kept+/// beside it as .bak). A failing preflight writes nothing.+fn native_edit_footprint(args: &Value) -> Value {+ let path = lbr_path_arg(args, &["libraryPath", "lbrPath", "filePath"]);+ let name = args["name"].as_str().unwrap_or("");+ if path.is_empty() || name.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "libraryPath and name (the footprint) are required", "shell": "rust"}); }+ let xml = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e.to_string(), "libraryPath": path, "shell": "rust"}) };+ let (text, summary) = match fusion_lbr::edit_footprint(&xml, name, args) { Ok(r) => r, Err(e) => return json!({"success": false, "errorCode": "edit_refused", "error": e, "shell": "rust"}) };+ let after = fusion_lbr::read_footprint(&text, None, name).unwrap_or(Value::Null);+ let tmp = std::env::temp_dir().join(format!("adom-edit-fp-{}.lbr", std::process::id()));+ let _ = std::fs::write(&tmp, &text);+ let pf = fusion_lbr::preflight_lbr(&tmp);+ let _ = std::fs::remove_file(&tmp);+ if !pf.ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": "the edited library would not open cleanly in Fusion; nothing was written", "preflight": pf, "summary": summary, "shell": "rust"}); }+ if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "summary": summary, "footprint": after, "shell": "rust"}); }+ let bak = format!("{path}.bak");+ if let Err(e) = std::fs::copy(&path, &bak).and_then(|_| std::fs::write(&path, &text)) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string(), "shell": "rust"}); }+ json!({"success": true, "mutated": true, "libraryPath": path, "backup": bak, "summary": summary, "footprint": after, "shell": "rust",+ "_hint": "Edited in place (the old file is the .bak beside it) and preflighted. A library already open in Fusion does not see the change until it is reopened (fusion_open_lbr); a design's embedded copy updates when its parts are updated from the library."})+}+ /// `fusion_preflight_lbr`: will this .lbr open cleanly in Fusion? Offline, instant. fn native_preflight_lbr(args: &Value) -> Value { let path = lbr_path_arg(args, &["libraryPath", "filePath"]);
rust/bridge/src/mint.rs+2−2@@ -319,7 +319,7 @@ pub fn close_docs_opened_since(ctx: &Ctx, before: &[String], keep: &[String], pa last = Some(docs.len()); let Some(u) = docs.iter().find(|d| d["name"].as_str().unwrap_or("").starts_with("Untitled") && !(d["isModified"].as_bool().unwrap_or(false) || d["modified"].as_bool().unwrap_or(false))) else { break }; let nm = u["name"].as_str().unwrap_or("").to_string();- if pass("close_document", json!({"name": nm, "save": false}))["success"].as_bool() == Some(true) { closed.push(nm); } else { break; }+ if pass("close_document", json!({"name": nm, "save": false, "saveToClose": true}))["success"].as_bool() == Some(true) { closed.push(nm); } else { break; } } closed }@@ -538,7 +538,7 @@ pub fn sweep_untitled_scratch(pass: &dyn Fn(&str, Value) -> Value) -> Vec<String if let Some(p) = prev_total { if docs.len() >= p { break; } } prev_total = Some(docs.len()); let victim = untitled[0]["name"].as_str().unwrap_or("").to_string();- if pass("close_document", json!({"name": victim, "save": false}))["success"].as_bool() != Some(true) { break; }+ if pass("close_document", json!({"name": victim, "save": false, "saveToClose": true}))["success"].as_bool() != Some(true) { break; } closed.push(victim); } closed
rust/bridge/src/orchestrations.rs+1−1@@ -74,7 +74,7 @@ pub fn close_bridge_doc_verified(ctx: &Ctx, name: &str, timeout_s: u64, pass: &d std::thread::sleep(Duration::from_secs(5)); } if !drained { return json!({"closed": false, "deferred": true, "why": "Fusion Hub uploads still active after 120s - close deferred so the upload is not destroyed; retry after the jobs drain"}); }- let _ = pass("close_document", json!({"name": n, "save": false}));+ let _ = pass("close_document", json!({"name": n, "save": false, "saveToClose": true})); let deadline = Instant::now() + Duration::from_secs(timeout_s); while Instant::now() < deadline { let di = pass("document_info", json!({}));
rust/bridge/src/pdf.rsadded+96@@ -0,0 +1,96 @@+//! A minimal PDF writer: one page per PNG, the image filling the page at its own DPI. The PNG is+//! flattened onto white and re-encoded as 8-bit RGB, and its IDAT stream goes into the PDF as is+//! (FlateDecode with the PNG predictor), so no other image or compression crate is needed.++fn rgb_png(path: &str) -> Result<(u32, u32, Vec<u8>), String> {+ let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;+ let mut dec = png::Decoder::new(f);+ dec.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);+ let mut rd = dec.read_info().map_err(|e| format!("{path}: {e}"))?;+ let mut buf = vec![0; rd.output_buffer_size()];+ let info = rd.next_frame(&mut buf).map_err(|e| format!("{path}: {e}"))?;+ let (w, h) = (info.width, info.height);+ let px = &buf[..info.buffer_size()];+ let rgb: Vec<u8> = match info.color_type {+ png::ColorType::Rgb => px.to_vec(),+ png::ColorType::Rgba => px.chunks(4).flat_map(|p| { let a = p[3] as u32; (0..3).map(move |k| ((p[k] as u32 * a + 255 * (255 - a)) / 255) as u8) }).collect(),+ png::ColorType::Grayscale => px.iter().flat_map(|g| [*g, *g, *g]).collect(),+ png::ColorType::GrayscaleAlpha => px.chunks(2).flat_map(|p| { let a = p[1] as u32; let v = ((p[0] as u32 * a + 255 * (255 - a)) / 255) as u8; [v, v, v] }).collect(),+ other => return Err(format!("{path}: unsupported colour type {other:?}")),+ };+ let mut out = Vec::new();+ {+ let mut enc = png::Encoder::new(&mut out, w, h);+ enc.set_color(png::ColorType::Rgb);+ enc.set_depth(png::BitDepth::Eight);+ let mut wr = enc.write_header().map_err(|e| e.to_string())?;+ wr.write_image_data(&rgb).map_err(|e| e.to_string())?;+ }+ // the IDAT chunks, concatenated, are one zlib stream of filtered rows+ let mut idat = Vec::new();+ let mut i = 8;+ while i + 8 <= out.len() {+ let len = u32::from_be_bytes([out[i], out[i + 1], out[i + 2], out[i + 3]]) as usize;+ if &out[i + 4..i + 8] == b"IDAT" { idat.extend_from_slice(&out[i + 8..i + 8 + len]); }+ i += 12 + len;+ }+ Ok((w, h, idat))+}++/// Write `pages` (PNG paths with their DPI) as one PDF at `out`.+pub fn images_to_pdf(pages: &[(String, f64)], out: &str) -> Result<usize, String> {+ let mut body: Vec<u8> = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec();+ let mut offsets: Vec<usize> = Vec::new();+ let n = pages.len();+ // objects: 1 catalog, 2 pages, then per page: page, image, content+ let mut objs: Vec<Vec<u8>> = vec![Vec::new(), Vec::new()];+ let kids: Vec<String> = (0..n).map(|k| format!("{} 0 R", 3 + 3 * k)).collect();+ objs[0] = b"<< /Type /Catalog /Pages 2 0 R >>".to_vec();+ objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), n).into_bytes();+ for (k, (path, dpi)) in pages.iter().enumerate() {+ let (w, h, idat) = rgb_png(path)?;+ let (pw, ph) = (w as f64 * 72.0 / dpi.max(1.0), h as f64 * 72.0 / dpi.max(1.0));+ let (img, content) = (4 + 3 * k, 5 + 3 * k);+ objs.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pw:.2} {ph:.2}] /Resources << /XObject << /Im{k} {img} 0 R >> >> /Contents {content} 0 R >>").into_bytes());+ let mut im = format!("<< /Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns {w} >> /Length {} >>\nstream\n", idat.len()).into_bytes();+ im.extend_from_slice(&idat);+ im.extend_from_slice(b"\nendstream");+ objs.push(im);+ let draw = format!("q {pw:.2} 0 0 {ph:.2} 0 0 cm /Im{k} Do Q");+ objs.push(format!("<< /Length {} >>\nstream\n{draw}\nendstream", draw.len()).into_bytes());+ }+ for (k, o) in objs.iter().enumerate() {+ offsets.push(body.len());+ body.extend_from_slice(format!("{} 0 obj\n", k + 1).as_bytes());+ body.extend_from_slice(o);+ body.extend_from_slice(b"\nendobj\n");+ }+ let xref = body.len();+ body.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());+ for o in &offsets { body.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); }+ body.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());+ std::fs::write(out, &body).map_err(|e| format!("{out}: {e}"))?;+ Ok(n)+}++#[cfg(test)] mod tests {+ use super::*;+ #[test] fn writes_a_pdf_page_per_png() {+ let dir = std::env::temp_dir().join("adom-pdf-test");+ let _ = std::fs::create_dir_all(&dir);+ let p = dir.join("a.png");+ {+ let f = std::fs::File::create(&p).unwrap();+ let mut e = png::Encoder::new(f, 4, 2);+ e.set_color(png::ColorType::Rgba); e.set_depth(png::BitDepth::Eight);+ e.write_header().unwrap().write_image_data(&[255u8; 4 * 2 * 4]).unwrap();+ }+ let out = dir.join("a.pdf");+ let pages = vec![(p.to_string_lossy().to_string(), 72.0), (p.to_string_lossy().to_string(), 144.0)];+ assert_eq!(2, images_to_pdf(&pages, &out.to_string_lossy()).unwrap());+ let b = std::fs::read(&out).unwrap();+ assert!(b.starts_with(b"%PDF-1.4") && b.ends_with(b"%%EOF\n"));+ let t = String::from_utf8_lossy(&b);+ assert!(t.contains("/Count 2") && t.contains("/MediaBox [0 0 4.00 2.00]") && t.contains("/MediaBox [0 0 2.00 1.00]"));+ }+}
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" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs" | "export_odb" | "export_ipc356" | "export_gencad" | "export_ipc2581" | "import_svg" | "copy_routing" | "set_layer_count") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { let mut args_v = args.clone(); if args_v.is_object() { args_v["_bridgeVersion"] = json!(env!("CARGO_PKG_VERSION")); }@@ -32,7 +32,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" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts")}),+ 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" | "import_svg" | "copy_routing" | "set_layer_count")}), } }
rust/bridge/src/routing_script.py+602@@ -2039,6 +2039,605 @@ def remove_graphics(app, root, board, meta, args): return {'success': True, 'mutated': True, **meta2, 'removed': rows} +# โโ import_svg โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++def _svg_floats(t):+ return [float(x) for x in re.findall(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?', t or '')]+++def _svg_matrix(t):+ """An SVG transform attribute as a 2x3 affine (a, b, c, d, e, f)."""+ M = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)+ def mul(A, B):+ a1, b1, c1, d1, e1, f1 = A; a2, b2, c2, d2, e2, f2 = B+ return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1)+ for name, body in re.findall(r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)', t or ''):+ v = _svg_floats(body)+ if name == 'matrix' and len(v) == 6: T = tuple(v)+ elif name == 'translate': T = (1, 0, 0, 1, v[0], v[1] if len(v) > 1 else 0)+ elif name == 'scale': T = (v[0], 0, 0, v[1] if len(v) > 1 else v[0], 0, 0)+ elif name == 'rotate':+ a = math.radians(v[0]); c, s_ = math.cos(a), math.sin(a)+ T = (c, s_, -s_, c, 0, 0)+ if len(v) == 3:+ T = mul(mul((1, 0, 0, 1, v[1], v[2]), T), (1, 0, 0, 1, -v[1], -v[2]))+ elif name == 'skewX': T = (1, 0, math.tan(math.radians(v[0])), 1, 0, 0)+ else: T = (1, math.tan(math.radians(v[0])), 0, 1, 0, 0)+ M = mul(M, T)+ return M+++def _svg_apply(M, p):+ a, b, c, d, e, f = M+ return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)+++def _bezier(p0, p1, p2, p3, tol):+ n = max(2, min(64, int(math.ceil(math.sqrt((math.hypot(p1[0] - p0[0], p1[1] - p0[1]) + math.hypot(p2[0] - p1[0], p2[1] - p1[1]) + math.hypot(p3[0] - p2[0], p3[1] - p2[1])) / max(tol, 1e-6))))))+ out = []+ for k in range(1, n + 1):+ t = k / n; u = 1 - t+ out.append((u ** 3 * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t ** 3 * p3[0],+ u ** 3 * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t ** 3 * p3[1]))+ return out+++def _arc(p0, rx, ry, phi, large, sweep, p1, tol):+ """SVG elliptical arc (endpoint form) flattened."""+ if rx == 0 or ry == 0:+ return [p1]+ rx, ry = abs(rx), abs(ry)+ c, s_ = math.cos(math.radians(phi)), math.sin(math.radians(phi))+ dx, dy = (p0[0] - p1[0]) / 2, (p0[1] - p1[1]) / 2+ x1, y1 = c * dx + s_ * dy, -s_ * dx + c * dy+ lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry)+ if lam > 1:+ rx, ry = rx * math.sqrt(lam), ry * math.sqrt(lam)+ num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1+ den = rx * rx * y1 * y1 + ry * ry * x1 * x1+ co = math.sqrt(max(0.0, num / den)) if den else 0.0+ if large == sweep:+ co = -co+ cx1, cy1 = co * rx * y1 / ry, -co * ry * x1 / rx+ cx, cy = c * cx1 - s_ * cy1 + (p0[0] + p1[0]) / 2, s_ * cx1 + c * cy1 + (p0[1] + p1[1]) / 2+ ang = lambda ux, uy, vx, vy: math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)+ t1 = ang(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)+ dt = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)+ if not sweep and dt > 0: dt -= 2 * math.pi+ elif sweep and dt < 0: dt += 2 * math.pi+ n = max(2, min(128, int(math.ceil(abs(dt) / max(1e-3, 2 * math.acos(max(-1.0, min(1.0, 1 - tol / max(rx, ry)))))))))+ out = []+ for k in range(1, n + 1):+ t = t1 + dt * k / n+ x, y = rx * math.cos(t), ry * math.sin(t)+ out.append((c * x - s_ * y + cx, s_ * x + c * y + cy))+ return out+++def _svg_path_rings(d, tol):+ toks = re.findall(r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?', d or '')+ rings, cur, pos, start, last_ctrl, cmd, i = [], [], (0.0, 0.0), (0.0, 0.0), None, None, 0+ def num():+ nonlocal i+ v = float(toks[i]); i += 1+ return v+ def isnum(k):+ return k < len(toks) and not toks[k].isalpha()+ while i < len(toks):+ if toks[i].isalpha():+ cmd = toks[i]; i += 1+ elif cmd is None:+ break+ rel = cmd.islower(); C = cmd.upper()+ if C == 'Z':+ if cur: rings.append(cur)+ cur, pos, last_ctrl = [], start, None+ continue+ if not isnum(i):+ continue+ base = pos if rel else (0.0, 0.0)+ if C == 'M':+ if cur: rings.append(cur)+ pos = (base[0] + num(), base[1] + num()); start = pos; cur = [pos]; last_ctrl = None+ cmd = 'l' if rel else 'L'+ elif C == 'L':+ pos = (base[0] + num(), base[1] + num()); cur.append(pos); last_ctrl = None+ elif C == 'H':+ pos = ((pos[0] if rel else 0) + num(), pos[1]); cur.append(pos); last_ctrl = None+ elif C == 'V':+ pos = (pos[0], (pos[1] if rel else 0) + num()); cur.append(pos); last_ctrl = None+ elif C in 'CS':+ if C == 'C':+ p1 = (base[0] + num(), base[1] + num())+ else:+ p1 = (2 * pos[0] - last_ctrl[0], 2 * pos[1] - last_ctrl[1]) if last_ctrl and last_ctrl[2] == 'c' else pos+ p2 = (base[0] + num(), base[1] + num()); p3 = (base[0] + num(), base[1] + num())+ cur += _bezier(pos, p1[:2], p2, p3, tol); pos = p3; last_ctrl = (p2[0], p2[1], 'c')+ elif C in 'QT':+ if C == 'Q':+ q = (base[0] + num(), base[1] + num())+ else:+ q = (2 * pos[0] - last_ctrl[0], 2 * pos[1] - last_ctrl[1]) if last_ctrl and last_ctrl[2] == 'q' else pos+ p3 = (base[0] + num(), base[1] + num())+ p1 = (pos[0] + 2 / 3 * (q[0] - pos[0]), pos[1] + 2 / 3 * (q[1] - pos[1])); p2 = (p3[0] + 2 / 3 * (q[0] - p3[0]), p3[1] + 2 / 3 * (q[1] - p3[1]))+ cur += _bezier(pos, p1, p2, p3, tol); pos = p3; last_ctrl = (q[0], q[1], 'q')+ elif C == 'A':+ rx, ry, phi, large, sweep = num(), num(), num(), num(), num()+ p3 = (base[0] + num(), base[1] + num())+ cur += _arc(pos, rx, ry, phi, bool(large), bool(sweep), p3, tol); pos = p3; last_ctrl = None+ else:+ i += 1+ if cur: rings.append(cur)+ return [r for r in rings if len(r) >= 3]+++def _svg_style(el, inherited):+ st = dict(inherited)+ for k in ('fill', 'stroke', 'display', 'visibility', 'fill-rule'):+ if el.get(k) is not None: st[k] = el.get(k).strip()+ for part in (el.get('style') or '').split(';'):+ if ':' in part:+ k, v = part.split(':', 1); st[k.strip()] = v.strip()+ return st+++def svg_shapes(text, tol=0.02):+ """Every filled shape in an SVG as rings in its own user units (y down), with what was skipped.+ Curves and arcs are flattened to tol (in user units); transforms on groups and shapes applied."""+ try:+ root = ET.fromstring(text)+ except ET.ParseError as e:+ fail('invalid_svg', 'Not an SVG: %s' % e)+ tag = lambda e: e.tag.split('}')[-1]+ if tag(root) != 'svg':+ fail('invalid_svg', 'The root element is <%s>, not <svg>' % tag(root))+ shapes, skipped = [], []+ def visit(el, M, st):+ t = tag(el)+ if el.get('transform'):+ A = _svg_matrix(el.get('transform'))+ a1, b1, c1, d1, e1, f1 = M; a2, b2, c2, d2, e2, f2 = A+ M = (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, a1 * e2 + c1 * f2 + e1, b1 * e2 + d1 * f2 + f1)+ st = _svg_style(el, st)+ if st.get('display') == 'none' or st.get('visibility') == 'hidden' or t in ('defs', 'clipPath', 'mask', 'symbol', 'title', 'desc', 'metadata', 'style'):+ return+ if t in ('svg', 'g', 'a'):+ for c in el:+ visit(c, M, st)+ return+ f = lambda k: float(_svg_floats(el.get(k) or '0')[0]) if _svg_floats(el.get(k) or '0') else 0.0+ rings = []+ if t == 'path':+ rings = _svg_path_rings(el.get('d'), tol)+ elif t == 'rect':+ x, y, w, h = f('x'), f('y'), f('width'), f('height')+ rings = [[(x, y), (x + w, y), (x + w, y + h), (x, y + h)]] if w > 0 and h > 0 else []+ elif t in ('circle', 'ellipse'):+ cx, cy = f('cx'), f('cy')+ rx = f('r') if t == 'circle' else f('rx'); ry = f('r') if t == 'circle' else f('ry')+ if rx > 0 and ry > 0:+ n = max(12, min(180, int(math.ceil(math.pi / max(1e-3, math.acos(max(-1.0, 1 - tol / max(rx, ry))))))))+ rings = [[(cx + rx * math.cos(2 * math.pi * k / n), cy + ry * math.sin(2 * math.pi * k / n)) for k in range(n)]]+ elif t in ('polygon', 'polyline'):+ v = _svg_floats(el.get('points'))+ pts = list(zip(v[0::2], v[1::2]))+ rings = [pts] if len(pts) >= 3 else []+ elif t in ('text', 'image', 'use', 'line', 'foreignObject'):+ skipped.append({'element': t, 'id': el.get('id'), 'why': {'text': 'text is not a shape: convert it to paths first', 'image': 'a bitmap is not a shape',+ 'use': 'a <use> reference is not followed: expand it first', 'line': 'a line has no fill'}.get(t, 'not a shape')})+ return+ else:+ return+ if st.get('fill', 'black') in ('none', 'transparent'):+ skipped.append({'element': t, 'id': el.get('id'), 'why': 'no fill (stroke-only artwork imports nothing)'})+ return+ if not rings:+ return+ shapes.append([[_svg_apply(M, p) for p in r] for r in rings])+ visit(root, (1.0, 0.0, 0.0, 1.0, 0.0, 0.0), {})+ w_attr, h_attr = root.get('width'), root.get('height')+ def mm_of(v):+ m = re.fullmatch(r'\s*([-+0-9.eE]+)\s*(mm|cm|in|pt|pc|px)?\s*', v or '')+ if not m: return None+ return float(m[1]) * {'mm': 1, 'cm': 10, 'in': 25.4, 'pt': 25.4 / 72, 'pc': 25.4 / 6, 'px': 25.4 / 96, None: 25.4 / 96}[m[2]]+ vb = _svg_floats(root.get('viewBox'))+ unit = None+ if len(vb) == 4 and vb[2] > 0 and mm_of(w_attr):+ unit = mm_of(w_attr) / vb[2]+ elif mm_of(w_attr) and not vb:+ unit = 25.4 / 96 if not re.search(r'(mm|cm|in|pt|pc)', w_attr) else (mm_of(w_attr) / float(_svg_floats(w_attr)[0]))+ return shapes, skipped, unit or 25.4 / 96+++def _ring_area(r):+ return sum(r[k][0] * r[(k + 1) % len(r)][1] - r[(k + 1) % len(r)][0] * r[k][1] for k in range(len(r))) / 2+++def _point_in(p, r):+ x, y, inside = p[0], p[1], False+ for k in range(len(r)):+ (x1, y1), (x2, y2) = r[k], r[(k + 1) % len(r)]+ if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-30) + x1:+ inside = not inside+ return inside+++def svg_rects(rings, pitch):+ """Filled rings (even-odd, so a ring inside another is a hole) as rectangles: one scan row per+ pitch, each row's spans cut where the row's centre line crosses the outlines, and a span that+ repeats on the next row grown into one taller rectangle. Fusion's POLYGON asks for a signal+ name (a modal, live 2026-10-27) and has no holes, so the artwork is drawn with RECT."""+ pts = [p for r in rings for p in r]+ if not pts:+ return []+ y0, y1 = min(p[1] for p in pts), max(p[1] for p in pts)+ edges = [(r[k], r[(k + 1) % len(r)]) for r in rings for k in range(len(r))]+ rows = max(1, int(math.ceil((y1 - y0) / pitch)))+ h = (y1 - y0) / rows+ out, open_ = [], {}+ for row in range(rows):+ yc = y0 + (row + 0.5) * h+ xs = sorted(a[0] + (yc - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) for a, b in edges if (a[1] > yc) != (b[1] > yc))+ spans = {(round(xs[k], 4), round(xs[k + 1], 4)) for k in range(0, len(xs) - 1, 2) if xs[k + 1] - xs[k] > 1e-6}+ for key in list(open_):+ if key not in spans:+ out.append((key[0], open_.pop(key), key[1], y0 + row * h))+ for key in spans:+ open_.setdefault(key, y0 + row * h)+ for key, ys in open_.items():+ out.append((key[0], ys, key[1], y1))+ return out+++def import_svg(app, root, board, meta, args):+ _guard(meta, args)+ text = args.get('svg')+ if not text and args.get('svgPath'):+ sp = Path(args['svgPath'])+ if not sp.exists():+ fail('file_not_found', 'No SVG at %s on the Fusion machine' % args['svgPath'])+ text = sp.read_text(encoding='utf-8', errors='replace')+ if not text:+ fail('bad_args', 'svgPath or svg (the SVG text) is required')+ at = args.get('at')+ if not (isinstance(at, (list, tuple)) and len(at) == 2):+ fail('bad_args', 'at: [x, y] mm is required')+ lay = args.get('layer') or 'F.SilkS'+ n = SIDE_LAYERS[lay][0] if lay in SIDE_LAYERS else ({'F.Cu': 1, 'B.Cu': 16, 'F.Mask': 29, 'B.Mask': 30, 'F.Paste': 31, 'B.Paste': 32}.get(lay) or _layer_number(board, lay))+ if root.find("drawing/layers/layer[@number='%d']" % n) is None:+ fail('invalid_layer', 'This board has no layer %d' % n)+ if 1 <= n <= 16:+ # POLYGON on copper opens Fusion's "Signal Name" modal (live 2026-10-27); RECT on copper is+ # unproven, and a modal must never be risked, so copper artwork is refused, not attempted+ fail('not_supported', 'Artwork on copper is not imported: Fusion draws copper shapes as signal pours and asks for the name in a dialog. Import onto F.SilkS, B.SilkS, F.Mask, B.Mask or a documentation layer')+ bottom = n in (16, 22, 26, 30, 32, 52)+ mirror = args.get('mirror', bottom)+ tol_mm = float(args.get('toleranceMm') or 0.02)+ shapes0, skipped, unit = svg_shapes(text, 1.0)+ pts = [p for sh in shapes0 for r in sh for p in r]+ if not pts:+ fail('nothing_to_import', 'The SVG has no filled shape to import (skipped: %s)' % '; '.join('%s: %s' % (k['element'], k['why']) for k in skipped[:5]))+ x0, x1 = min(p[0] for p in pts), max(p[0] for p in pts)+ y0, y1 = min(p[1] for p in pts), max(p[1] for p in pts)+ if args.get('widthMm'):+ k = float(args['widthMm']) / max(x1 - x0, 1e-9)+ else:+ k = unit * float(args.get('scale') or 1)+ shapes, _, _ = svg_shapes(text, tol_mm / k)+ w, h = (x1 - x0) * k, (y1 - y0) * k+ ax, ay = float(at[0]), float(at[1])+ if (args.get('anchor') or 'topleft') == 'center':+ ax, ay = ax - w / 2, ay + h / 2+ def to_board(p):+ u = (p[0] - x0) * k+ return (ax + (w - u if mirror else u), ay - (p[1] - y0) * k)+ pitch = max(tol_mm, 0.01)+ rects = []+ for sh in shapes:+ rects += [r for r in svg_rects([[to_board(p) for p in ring] for ring in sh], pitch) if r[2] - r[0] >= 0.005 and r[3] - r[1] >= 0.005]+ plan = {'shapes': len(shapes), 'rects': len(rects), 'pitchMm': pitch, 'sizeMm': [round(w, 3), round(h, 3)], 'layer': n, 'mirrored': bool(mirror), 'skipped': skipped,+ 'boxMm': [round(ax, 3), round(ay - h, 3), round(ax + w, 3), round(ay, 3)]}+ if len(rects) > 4000:+ fail('too_detailed', 'The artwork needs %d rectangles at %.3f mm: give a larger toleranceMm or a smaller widthMm' % (len(rects), pitch))+ if args.get('dryRun'):+ return {'success': True, 'mutated': False, **meta, **plan}+ before = {g['id'] for g in plain_graphics(board, Layers(board))}+ visible = _visible_layers(root)+ done = 0+ try:+ for b in range(0, len(rects), 60):+ chunk = rects[b:b + 60]+ app.executeTextCommand('Electron.run DISPLAY NONE %d; LAYER %d; %s' % (n, n, ' '.join('RECT R0 (%.4fmm %.4fmm) (%.4fmm %.4fmm);' % r for r in chunk)))+ done += len(chunk)+ finally:+ _restore_display(app, visible)+ _, after, meta2 = snapshot(app, args)+ added = [g for g in plain_graphics(after, Layers(after)) if g['id'] not in before and g['kind'] == 'rect']+ _, _, w0, v0 = objects(board)+ _, _, w1, v1 = objects(after)+ if len(added) != len(rects) or _elements_of(after) != _elements_of(board) or copper_signature(w0 + v0) != copper_signature(w1 + v1):+ for _ in range(done):+ app.executeTextCommand('Electron.run UNDO;')+ fail('readback_mismatch', 'The board read back did not show %d new rectangles on layer %d with nothing else changed (found %d); every RECT was undone' % (len(rects), n, len(added)))+ return {'success': True, 'mutated': True, **meta2, **plan, 'ids': [g['id'] for g in added], 'undoSteps': done,+ '_hint': 'Filled shapes became rectangles row by row (%.3f mm rows; rows that repeat merged), since Fusion\'s POLYGON asks for a signal name in a dialog. A shape inside another is a hole. Remove the artwork with fusion_remove_graphics {ids}. Text and images are listed in skipped (convert text to paths first).' % pitch}+++# โโ copy_routing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++def copy_routing_plan(board, args):+ """What copy_routing would draw: the tracks with both ends in the box and the through vias with+ their centre in it, moved by the offset, each on its mapped net. Nets map through paired parts+ (refMap, refPattern: each source pad's net goes to the partner pad's net), then netMap. A+ destination end that lands on another net's pad, via or track end is a conflict: Fusion would+ ask whether to join the two nets, so the copy is refused before anything is drawn."""+ import fnmatch+ box = args.get('box'); off = args.get('offset')+ if not (isinstance(box, (list, tuple)) and len(box) == 4) or not (isinstance(off, (list, tuple)) and len(off) == 2):+ fail('bad_args', 'box [x1, y1, x2, y2] and offset [dx, dy] (mm) are required')+ x1, y1, x2, y2 = min(box[0], box[2]), min(box[1], box[3]), max(box[0], box[2]), max(box[1], box[3])+ dx, dy = float(off[0]), float(off[1])+ L = Layers(board)+ want_layers = {_layer_number(board, l) for l in args.get('layers') or []} or None+ _, pads, wires, vias = objects(board)+ inside = lambda p: x1 - 1e-6 <= p[0] <= x2 + 1e-6 and y1 - 1e-6 <= p[1] <= y2 + 1e-6+ # parts paired by designator+ refs = sorted({k.split('.')[0] for k in pads})+ pairs = dict(args.get('refMap') or {})+ pat = args.get('refPattern') or {}+ if pat.get('from') and pat.get('to'):+ f_, t_ = pat['from'], pat['to']+ for r in refs:+ if fnmatch.fnmatchcase(r, f_):+ m = re.fullmatch(re.escape(f_).replace(r'\*', '(.*)').replace(r'\?', '(.)'), r)+ if m:+ target = t_+ for g in m.groups():+ target = re.sub(r'[*?]', lambda _: g, target, count=1)+ if target in refs:+ pairs.setdefault(r, target)+ missing = [r for pair in pairs.items() for r in pair if r not in refs]+ if missing:+ fail('designator_not_found', 'No part %s on the board' % ', '.join(sorted(set(missing))[:5]))+ net_map, conflicts = {}, []+ for src, dst in pairs.items():+ for key, p_ in pads.items():+ ref, pin = key.split('.', 1)+ if ref != src or not p_['net']:+ continue+ q = pads.get(dst + '.' + pin)+ if q is None or not q['net']:+ continue+ if net_map.get(p_['net'], q['net']) != q['net']:+ conflicts.append({'net': p_['net'], 'to': sorted({net_map[p_['net']], q['net']})})+ net_map.setdefault(p_['net'], q['net'])+ for k, v in (args.get('netMap') or {}).items():+ net_map[k] = v+ conflicts = [c for c in conflicts if c['net'] != k]+ if conflicts:+ fail('net_map_conflict', 'A source net pairs to two target nets: %s. Settle it with netMap' % '; '.join('%s -> %s' % (c['net'], ' or '.join(c['to'])) for c in conflicts[:5]))+ nets_on_board = {s_.get('name') for s_ in board.findall('signals/signal')}+ keep = bool(args.get('keepUnmappedNets'))+ new_w, new_v, skipped = [], [], []+ def target_net(n):+ if n in net_map:+ return net_map[n]+ return n if keep else None+ for w in wires:+ if want_layers and w['layer'] not in want_layers:+ continue+ if not (inside(w['start']) and inside(w['end'])):+ continue+ tn = target_net(w['net'])+ if tn is None:+ skipped.append({'item': 'track %s %s' % (w['net'], L.name(w['layer'])), 'why': 'its net is not mapped (pair the parts, give netMap, or keepUnmappedNets)'})+ continue+ if tn not in nets_on_board:+ skipped.append({'item': 'track %s' % w['net'], 'why': 'target net %s is not on the board' % tn}); continue+ new_w.append(dict(w, net=tn, start=[w['start'][0] + dx, w['start'][1] + dy], end=[w['end'][0] + dx, w['end'][1] + dy], src=w['net']))+ for v in vias:+ if not inside(v['at']):+ continue+ if v['extent'] not in (None, '', '1-16'):+ skipped.append({'item': 'via %s at %s' % (v['net'], v['at']), 'why': 'blind or buried (%s): only through vias are copied' % v['extent']}); continue+ tn = target_net(v['net'])+ if tn is None:+ skipped.append({'item': 'via %s' % v['net'], 'why': 'its net is not mapped'}); continue+ if tn not in nets_on_board:+ skipped.append({'item': 'via %s' % v['net'], 'why': 'target net %s is not on the board' % tn}); continue+ new_v.append(dict(v, net=tn, at=[v['at'][0] + dx, v['at'][1] + dy], src=v['net']))+ # destination conflicts: an end on another net's copper would raise Fusion's join question+ def touches(pt, layer, net):+ for key, p_ in pads.items():+ if p_['net'] == net or (layer is not None and layer not in p_['layers'] and p_['layers'] != [1, 16]):+ continue+ if p_['poly']:+ if _point_in(pt, p_['poly']): return key+ elif math.hypot(pt[0] - p_['at'][0], pt[1] - p_['at'][1]) <= p_['radius']: return key+ for v in vias + new_v:+ if v['net'] != net and math.hypot(pt[0] - v['at'][0], pt[1] - v['at'][1]) <= (v['diameter'] or v['drill'] * 2) / 2:+ return 'via %s' % v['net']+ for w in wires + new_w:+ if w['net'] != net and (layer is None or w['layer'] == layer):+ for e in (w['start'], w['end']):+ if math.hypot(pt[0] - e[0], pt[1] - e[1]) <= w['width'] / 2 + 1e-6:+ return 'track %s' % w['net']+ return None+ clashes = []+ for w in new_w:+ for e in (w['start'], w['end']):+ hit = touches(e, w['layer'], w['net'])+ if hit: clashes.append({'item': 'track %s' % w['net'], 'at': [round(e[0], 4), round(e[1], 4)], 'touches': hit})+ for v in new_v:+ hit = touches(v['at'], None, v['net'])+ if hit: clashes.append({'item': 'via %s' % v['net'], 'at': [round(v['at'][0], 4), round(v['at'][1], 4)], 'touches': hit})+ used = sorted({(w['src'], w['net']) for w in new_w} | {(v['src'], v['net']) for v in new_v})+ return {'tracks': new_w, 'vias': new_v, 'skipped': skipped, 'clashes': clashes, 'pairs': pairs, 'netMap': {a: b for a, b in used}}+++def copy_routing(app, root, board, meta, args):+ _guard(meta, args)+ plan = copy_routing_plan(board, args)+ L = Layers(board)+ summary = {'copied': {'tracks': len(plan['tracks']), 'vias': len(plan['vias'])}, 'netMap': plan['netMap'], 'pairs': plan['pairs'],+ 'skipped': plan['skipped'], 'clashes': plan['clashes']}+ if plan['clashes']:+ fail('destination_conflict', 'Copied copper would land on another net (%s): place the target parts at the same offset first, or fix the pairing. Nothing was drawn'+ % '; '.join('%s at %s touches %s' % (c['item'], c['at'], c['touches']) for c in plan['clashes'][:4]))+ if args.get('dryRun'):+ return {'success': True, 'mutated': False, **meta, **summary}+ if not plan['tracks'] and not plan['vias']:+ return {'success': True, 'mutated': False, **meta, **summary, 'note': 'nothing to copy in the box'}+ validate = args.get('validate', True)+ drc0 = native_drc(app) if validate else None+ _, _, w0, v0 = objects(board)+ cmds = []+ for w in plan['tracks']:+ c = w['curve']+ cmds.append("LAYER %d; WIRE '%s' %.6fmm (%.6fmm %.6fmm) %s(%.6fmm %.6fmm);" % (w['layer'], identifier(w['net']), w['width'], w['start'][0], w['start'][1], ('%+g ' % c) if c else '', w['end'][0], w['end'][1]))+ for v in plan['vias']:+ cmds.append("CHANGE DRILL %.6fmm; VIA '%s' %s1-16 (%.6fmm %.6fmm);" % (v['drill'], identifier(v['net']), ('%.6fmm ' % v['diameter']) if v['diameter'] else '', v['at'][0], v['at'][1]))+ done = 0+ for k in range(0, len(cmds), 25):+ chunk = cmds[k:k + 25]+ # SET CONFIRM NO on the same line: a join question, if one is ever asked, is answered No+ app.executeTextCommand('Electron.run SET CONFIRM NO; ' + ' '.join(chunk) + ' SET CONFIRM OFF;')+ done += len(chunk)+ _, after, meta2 = snapshot(app, args)+ _, _, w1, v1 = objects(after)+ expect = copper_signature(w0 + v0 + [dict(w) for w in plan['tracks']] + [dict(v) for v in plan['vias']])+ def undo_all(why):+ for _ in range(done):+ app.executeTextCommand('Electron.run UNDO;')+ fail(why[0], why[1])+ if copper_signature(w1 + v1) != expect or _elements_of(after) != _elements_of(board):+ undo_all(('readback_mismatch', 'The copper read back is not the old copper plus the copy; every command was undone'))+ drc = {'ran': False}+ if validate:+ drc1 = native_drc(app)+ new_errors = drc1['errorCount'] - drc0['errorCount']+ drc = {'ran': True, 'newErrors': max(0, new_errors), 'before': drc0['errorCount'], 'after': drc1['errorCount']}+ if new_errors > 0:+ undo_all(('drc_failed', 'The copy adds %d DRC error(s) (%s); every command was undone. Dry-run and check the target placement, or pass validate:false' % (+ new_errors, '; '.join(list(drc1['summary'])[:3]))))+ return {'success': True, 'mutated': True, **meta2, **summary, 'drc': drc,+ '_hint': 'Tracks and through vias copied by the offset on their mapped nets; read back exactly. Run fusion_routing_state to see what is still unrouted.'}+++# โโ set_layer_count โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++LAYER_SETS = {2: [1, 16], 4: [1, 2, 15, 16], 6: [1, 2, 3, 14, 15, 16], 8: [1, 2, 3, 4, 13, 14, 15, 16]}+LAYER_SETUP = {2: '(1*16)', 4: '(1+2*15+16)', 6: '(1+2*3+14*15+16)', 8: '(1+2*3+4*13+14*15+16)'}+++def layers_used(board, numbers):+ """Copper and graphics per layer for the layers named: tracks, pours, pads, plain items."""+ used = {}+ for n in numbers:+ c = 0+ for s_ in board.findall('signals/signal'):+ c += sum(1 for w in s_.findall('wire') if w.get('layer') == str(n))+ c += sum(1 for p_ in list(s_.findall('polygon')) + list(s_.findall('polygonpour')) if p_.get('layer') == str(n))+ c += sum(1 for g in board.find('plain') if g.get('layer') == str(n)) if board.find('plain') is not None else 0+ for pkg in board.findall('libraries/library/packages/package'):+ c += sum(1 for g in pkg.findall('smd') if g.get('layer') == str(n))+ if c:+ used[n] = c+ return used+++def stackup_for(count, total_mm, copper_mm, template):+ """A Fusion layerstackup for the count: copper keeps each layer's thickness where it had one+ (outer 0.035, inner 0.0175 mm otherwise); prepreg 0.2 mm next to the outer layers and between+ core pairs, the cores sharing what is left of the board thickness. Fusion numbers the cores+ Dielectric-1.. first, then the prepregs, top down."""+ import uuid+ sig = LAYER_SETS[count]+ names = {1: 'Top', 16: 'Bottom'}+ cu = [copper_mm.get(n, 0.035 if n in (1, 16) else 0.0175) for n in sig]+ kinds = ['Core'] if count == 2 else ['Prepreg' if i % 2 == 0 else 'Core' for i in range(count - 1)]+ n_pre, n_core = kinds.count('Prepreg'), kinds.count('Core')+ core = (total_mm - sum(cu) - 0.2 * n_pre) / n_core+ if core < 0.1:+ fail('stackup_too_thin', 'A %d-layer board %.3f mm thick leaves %.3f mm per core: give the board more thickness first' % (count, total_mm, core))+ root = ET.Element('layerstackup', dict(template.attrib) if template is not None else {'name': 'Default Layer Stack', 'type': 'Standard Stackup', 'display_unit': 'mic', 'roughness_model': '0'})+ gid = lambda: '{%s}' % uuid.uuid4()+ core_no, pre_no = 0, n_core+ for i, n in enumerate(sig):+ d = ET.SubElement(root, 'layerdef', {'type': 'Signal', 'id': gid(), 'layer': str(n), 'name': names.get(n, 'Route%d' % n), 'locally_modified_material': 'yes'})+ ET.SubElement(d, 'material', {'name': 'User Defined', 'thickness': '%gmm' % cu[i], 'weight': '1oz', 'process': 'Electro Deposited', 'source': 'User'})+ if i < len(kinds):+ k = kinds[i]+ if k == 'Core':+ core_no += 1; num = core_no; th = core+ mat = {'name': 'User Defined', 'construction': '', 'thickness': '%.4fmm' % th}+ else:+ pre_no += 1; num = pre_no; th = 0.2+ mat = {'name': 'User Defined', 'glass_style': '', 'thickness': '0.2mm'}+ mat.update({'resin': '50%', 'dielectric_constant_1g': '4.5', 'dissipation_factor_1g': '0.02', 'glass_transition_temperature': '125degC', 'source': 'User'})+ d = ET.SubElement(root, 'layerdef', {'type': k, 'id': gid(), 'layer': str(num), 'name': 'Dielectric-%d' % num, 'locally_modified_material': 'yes'})+ ET.SubElement(d, 'material', mat)+ ET.SubElement(root, 'viadef', {'name': 'Thru', 'layers': '1:16'})+ return root+++def set_layer_count(app, root, board, meta, args):+ _guard(meta, args)+ try:+ count = int(args.get('count'))+ except (TypeError, ValueError):+ count = 0+ if count not in LAYER_SETS:+ fail('bad_args', 'count is 2, 4, 6 or 8')+ L = Layers(board)+ now = list(L.order)+ want = LAYER_SETS[count]+ stack = layer_stack(board)+ plan = {'from': [L.name(n) for n in now], 'to': ['F.Cu'] + ['In%d.Cu' % i for i in range(1, count - 1)] + ['B.Cu'], 'layerSetup': LAYER_SETUP[count],+ 'thicknessMm': round((stack.get('totalUm') or 1600) / 1000.0, 4)}+ if now == want:+ return {'success': True, 'mutated': False, **meta, **plan, 'note': 'the board already has %d copper layers' % count}+ gone = [n for n in now if n not in want]+ used = layers_used(board, gone)+ if used:+ fail('layers_in_use', 'Layers to be removed still carry copper or graphics: %s. Move or delete it first' % ', '.join('%s (%d)' % (L.name(n), c) for n, c in used.items()))+ copper_mm = {}+ for row in stack.get('layers') or []:+ n = row.get('layerId')+ if n in want and row.get('copperUm'):+ copper_mm[n] = row['copperUm'] / 1000.0+ total = (stack.get('totalUm') or 1600.0) / 1000.0+ new_stack = stackup_for(count, total, copper_mm, board.find('.//layerstackup'))+ plan['stackup'] = [{'type': d.get('type'), 'name': d.get('name'), 'thickness': d.find('material').get('thickness')} for d in new_stack.findall('layerdef')]+ if args.get('dryRun'):+ return {'success': True, 'mutated': False, **meta, **plan}+ with tempfile.TemporaryDirectory(prefix='adom-layers-') as d:+ orig, new = Path(d) / 'before.edru', Path(d) / 'after.edru'+ app.executeTextCommand("Electron.run DRC SAVE '%s';" % orig.as_posix())+ if not orig.exists():+ fail('rules_unreadable', 'Fusion did not save the board\'s design rules, so the layer count was not changed')+ tree = ET.parse(str(orig))+ rules = tree.getroot().find('designrules') if tree.getroot().tag != 'designrules' else tree.getroot()+ for p_ in rules.findall('param'):+ if p_.get('name') == 'layerSetup':+ p_.set('value', LAYER_SETUP[count])+ old = rules.find('layerstackup')+ if old is not None:+ rules.remove(old)+ rules.append(new_stack)+ tree.write(str(new), encoding='utf-8', xml_declaration=True)+ app.executeTextCommand("Electron.run SET CONFIRM NO; DRC LOAD '%s'; SET CONFIRM OFF;" % new.as_posix())+ _, after, meta2 = snapshot(app, args)+ got = list(Layers(after).order)+ st2 = layer_stack(after)+ same_parts = _elements_of(after) == _elements_of(board)+ if got != want or not same_parts:+ app.executeTextCommand("Electron.run SET CONFIRM NO; DRC LOAD '%s'; SET CONFIRM OFF;" % orig.as_posix())+ fail('readback_mismatch', 'Fusion read back copper layers %s (asked %s); the board\'s own rules were loaded back' % ([Layers(after).name(n) for n in got], plan['to']))+ return {'success': True, 'mutated': True, **meta2, **plan, 'layers': [Layers(after).name(n) for n in got], 'stackupRead': st2,+ '_hint': 'New inner layers are signal layers with generic prepreg and core (Dk 4.5); the board thickness is kept. Copper stays on its Fusion layer, so the standard names move with the count: Route15 is In2.Cu on 4 layers and In4.Cu on 6. Re-check an impedance-controlled stack with fusion_layer_stack. Net classes and other design rules are unchanged.'}++ 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)."""@@ -2704,6 +3303,9 @@ def handle(app,args,action): if action=='export_ipc356': return export_ipc356(app,root,board,meta,args) if action=='export_gencad': return export_gencad(app,root,board,meta,args) if action=='export_ipc2581': return export_ipc2581(app,root,board,meta,args)+ if action=='import_svg': return import_svg(app,root,board,meta,args)+ if action=='copy_routing': return copy_routing(app,root,board,meta,args)+ if action=='set_layer_count': return set_layer_count(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)
rust/bridge/src/sch.rs+1−1@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { if cmd == "render_schematic" { return render_schematic(args, pass); } let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/sch_script.py+120@@ -1411,6 +1411,124 @@ def sch_create(app, args): return e.reply(sheet=count + 1, page=count + 1, sheetSymbol=None, created=True) ++# โโ cross probe โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ++def board_doc_of(app, sdoc):+ """The board tab of the schematic's design, found the way sch_doc finds a schematic."""+ docs = [app.documents.item(i) for i in range(app.documents.count)]+ if sdoc.dataFile is not None:+ ref = sdoc.dataFile+ for i, d in enumerate(docs):+ if _pt_of(d) == 'BoardProductType' and d.dataFile is not None and d.dataFile.name == ref.name and d.dataFile.parentFolder.id == ref.parentFolder.id:+ return i, d+ for i, d in enumerate(docs):+ if _pt_of(d) == 'BoardProductType' and d.name == sdoc.name and (d.dataFile is None) == (sdoc.dataFile is None):+ return i, d+ return None, None+++def copy_tab(app, i, front_index, suffix):+ with tempfile.TemporaryDirectory(prefix='adom-xp-') as d:+ p = Path(d) / ('snapshot.' + suffix)+ try:+ app.executeTextCommand('UI.SwitchToTab %d' % i)+ app.executeTextCommand('Document.CopyToDesktop ' + p.as_posix())+ finally:+ if front_index is not None and front_index != i:+ app.executeTextCommand('UI.SwitchToTab %d' % front_index)+ raw = p.read_bytes()+ if zipfile.is_zipfile(p):+ # a copied Fusion document is a zip with the EAGLE file inside+ with zipfile.ZipFile(p) as z:+ inner = [n for n in z.namelist() if n.lower().endswith('.' + suffix[1:])]+ if len(inner) != 1:+ sfail('invalid_file', 'Expected one .%s inside the copied %s, found %d' % (suffix[1:], suffix, len(inner)))+ raw = z.read(inner[0])+ return ET.fromstring(raw)+++def cross_probe_pairs(sroot, broot, args):+ """Pair schematic parts and board parts by designator, the link Fusion keeps between them+ (forward and back annotation rename both). A schematic part with no package (a supply symbol,+ a frame) has no board part and is not reported."""+ sch = sroot.find('drawing/schematic')+ pk = {}+ for lib in sch.findall('libraries/library'):+ for ds in lib.findall('devicesets/deviceset'):+ for dev in ds.findall('devices/device'):+ pk[(lib.get('name'), ds.get('name'), dev.get('name', ''))] = dev.get('package')+ parts = {p.get('name'): p for p in sch.findall('parts/part')}+ where = {}+ for n, sh in enumerate(sheets_of(sroot), 1):+ for inst in sh.findall('instances/instance'):+ where.setdefault(inst.get('part'), []).append((n, inst))+ els = {e.get('name'): e for e in broot.findall('drawing/board/elements/element')} if broot is not None else {}+ want = set()+ if args.get('ref'):+ want.add(str(args['ref']))+ if args.get('refs'):+ want.update(str(r) for r in args['refs'])+ if args.get('symbolId'):+ want.add(str(args['symbolId']).split(':')[0])+ if args.get('path') not in (None, ''):+ sheet_no = int(str(args['path']).strip('/').split('/')[0] or 0)+ want.update(r for r, ws in where.items() if any(n == sheet_no for n, _ in ws))+ names = sorted(want) if want else sorted(set(parts) | set(els))+ matches, without = [], []+ for r in names:+ p, e = parts.get(r), els.get(r)+ has_pkg = p is not None and bool(pk.get((p.get('library'), p.get('deviceset'), p.get('device', ''))))+ if p is not None and not has_pkg and e is None:+ continue+ m = {'ref': r, 'linked': p is not None and e is not None}+ if e is not None:+ rot = e.get('rot') or 'R0'+ m['board'] = {'ref': r, 'id': r, 'side': 'bottom' if 'M' in rot else 'top', 'x': float(e.get('x')), 'y': float(e.get('y')),+ 'rotation': float(re.sub(r'[^0-9.\-]', '', rot) or 0), 'package': e.get('package'), 'value': e.get('value')}+ if p is not None:+ ws = where.get(r, [])+ m['schematic'] = {'sheet': ws[0][0] if ws else None, 'id': '%s:%s' % (r, ws[0][1].get('gate')) if ws else r, 'ref': r,+ 'symbol': p.get('deviceset'), 'value': p.get('value') or '', 'gates': [{'sheet': n, 'gate': i.get('gate'),+ 'at': [float(i.get('x')), float(i.get('y'))]} for n, i in ws]}+ if p is not None and e is None:+ without.append(r)+ if p is None and e is None:+ m['found'] = False+ matches.append(m)+ return matches, without+++def cross_probe(app, args):+ i, sdoc = sch_doc(app, args)+ front = app.activeDocument+ docs = [app.documents.item(k) for k in range(app.documents.count)]+ j = docs.index(front) if front in docs else None+ sroot = copy_tab(app, i, j, 'fsch')+ bi, bdoc = board_doc_of(app, sdoc)+ broot = copy_tab(app, bi, j, 'fbrd') if bdoc is not None else None+ matches, without = cross_probe_pairs(sroot, broot, args)+ selected = []+ if args.get('select'):+ refs = [m['ref'] for m in matches if m.get('linked')][:50]+ if refs:+ show = 'SHOW ' + ' '.join(quote(r) for r in refs)+ try:+ for k in ([i] + ([bi] if bi is not None else [])):+ app.executeTextCommand('UI.SwitchToTab %d' % k)+ app.executeTextCommand('Electron.run ' + show) # no ';' so the highlight stays+ finally:+ if j is not None:+ app.executeTextCommand('UI.SwitchToTab %d' % j)+ selected = refs+ unknown = [m['ref'] for m in matches if m.get('found') is False]+ hint = '%d match(es); linked:false means one side is missing. Fusion links a schematic part to its board part by designator (renames go both ways).' % len(matches)+ if unknown:+ hint = 'No part named %s in the schematic or on the board. ' % ', '.join(unknown) + hint+ return {'success': not unknown or len(unknown) < len(matches), 'mutated': False, 'schematic': sdoc.name, 'board': bdoc.name if bdoc is not None else None,+ 'matches': matches, 'symbolWithoutPart': without, 'selected': selected, 'errorCode': 'designator_not_found' if unknown and len(unknown) == len(matches) else None,+ '_hint': hint + ('' if bdoc is not None else ' No board is open beside this schematic, so every part reads linked:false.')}+ EDITS = {'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins, 'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region, 'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create}@@ -1531,6 +1649,8 @@ def handle(app, args, action): return EDITS[action](app, args) if action == 'sch_render': return sch_render(app, args)+ if action == 'cross_probe':+ return cross_probe(app, args) root, meta, _ = sch_snapshot(app, args) n, sheet = pick_sheet(root, args) if action != 'sch_hierarchy' else (None, None) base = {'success': True, 'mutated': False, **meta, 'sheet': n, 'sheetCount': len(sheets_of(root))}
rust/bridge/src/testproject.rs+51@@ -302,3 +302,54 @@ mod tests { } } }+++/// The standard's project_create: an empty schematic and board as EAGLE source in+/// <parentDir>/<name>/ (the board's copper count in its layer setup; title and text variables as+/// the schematic's global attributes), opened in Fusion as a new design through the dialog-free+/// Document.newDesignFromLocal. Saving it to the cloud is fusion_save_to_cloud.+pub fn project_create(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let name = args["name"].as_str().unwrap_or("").trim().to_string();+ let parent = args["parentDir"].as_str().unwrap_or("").replace('\\', "/");+ if name.is_empty() || parent.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "parentDir and name are required"}); }+ if !regex::Regex::new(r"^[A-Za-z0-9 _.\-]{1,64}$").unwrap().is_match(&name) { return json!({"success": false, "errorCode": "bad_args", "error": "name: letters, digits, space, _ . - (64 at most)"}); }+ let layers = args["layers"].as_u64().unwrap_or(2);+ let setup = match layers { 2 => "(1*16)", 4 => "(1+2*15+16)", 6 => "(1+2*3+14*15+16)", 8 => "(1+2*3+4*13+14*15+16)", _ => return json!({"success": false, "errorCode": "bad_args", "error": "layers is 2, 4, 6 or 8 in Fusion"}) };+ let dir = format!("{}/{}", parent.trim_end_matches('/'), name);+ let (sch_path, brd_path) = (format!("{dir}/{name}.sch"), format!("{dir}/{name}.brd"));+ let esc = |t: &str| t.replace('&', "&").replace('"', """).replace('<', "<");+ let mut attrs = String::new();+ if let Some(t) = args["title"].as_str() { attrs.push_str(&format!(r#"<attribute name="TITLE" value="{}"/>"#, esc(t))); }+ if let Some(vars) = args["textVariables"].as_object() { for (k, v) in vars { attrs.push_str(&format!(r#"<attribute name="{}" value="{}"/>"#, esc(&k.to_uppercase()), esc(v.as_str().unwrap_or(&v.to_string())))); } }+ let plan = json!({"project": dir, "files": [sch_path, brd_path], "layers": layers, "layerSetup": setup});+ if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "plan": plan}); }+ if std::path::Path::new(&sch_path).exists() && args["overwrite"].as_bool() != Some(true) {+ return json!({"success": false, "errorCode": "exists", "error": format!("{sch_path} exists"), "_hint": "Pass overwrite:true, or another name."});+ }+ let names = [(1, "Top"), (16, "Bottom"), (17, "Pads"), (18, "Vias"), (19, "Unrouted"), (20, "Dimension"), (21, "tPlace"), (22, "bPlace"), (23, "tOrigins"), (24, "bOrigins"), (25, "tNames"), (26, "bNames"), (27, "tValues"), (28, "bValues"), (29, "tStop"), (30, "bStop"), (31, "tCream"), (32, "bCream"), (35, "tGlue"), (36, "bGlue"), (39, "tKeepout"), (40, "bKeepout"), (41, "tRestrict"), (42, "bRestrict"), (43, "vRestrict"), (44, "Drills"), (45, "Holes"), (46, "Milling"), (47, "Measures"), (48, "Document"), (49, "Reference"), (51, "tDocu"), (52, "bDocu")];+ let mut brd_layers = String::from("<layers>");+ for n in 1..=16 { let nm = names.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()).unwrap_or(format!("Route{n}")); brd_layers.push_str(&format!(r#"<layer number="{n}" name="{nm}" color="{}" fill="1" visible="yes" active="yes"/>"#, if n == 1 { 4 } else if n == 16 { 1 } else { 6 })); }+ for (n, nm) in names.iter().filter(|(k, _)| *k > 16) { brd_layers.push_str(&format!(r#"<layer number="{n}" name="{nm}" color="7" fill="1" visible="yes" active="yes"/>"#)); }+ brd_layers.push_str(&SCH_LAYERS["<layers>".len()..SCH_LAYERS.len() - "</layers>".len()]);+ brd_layers.push_str("</layers>");+ let sch = format!("{HEAD}{SCH_LAYERS}<schematic xreflabel=\"%F%N/%S.%C%R\" xrefpart=\"/%S.%C%R\"><libraries/><attributes>{attrs}</attributes><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><parts/><sheets><sheet><plain/><instances/><busses/><nets/></sheet></sheets></schematic></drawing></eagle>\n");+ let brd = format!("{HEAD}{brd_layers}<board><plain/><libraries/><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><designrules name=\"default\"><param name=\"layerSetup\" value=\"{setup}\"/></designrules><autorouter/><elements/><signals/></board></drawing></eagle>\n");+ if let Err(e) = std::fs::create_dir_all(&dir) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{dir}: {e}")}); }+ if let Err(e) = std::fs::write(&sch_path, &sch).and_then(|_| std::fs::write(&brd_path, &brd)) { return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()}); }+ let mut steps: Vec<String> = Vec::new();+ for (label, fpath) in [("board", &brd_path), ("schematic", &sch_path)] {+ let fp = crate::fusionpath::for_fusion(&ctx.state_dir, fpath).unwrap_or_else(|_| fpath.clone());+ let r = pass("execute_text_command", json!({"command": format!("Document.newDesignFromLocal {}", fp)}));+ let mut okd = r["success"].as_bool() == Some(true);+ if !okd {+ for _ in 0..10 { std::thread::sleep(Duration::from_secs(3)); if pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_lowercase().contains(&name.to_lowercase()) { okd = true; break; } }+ }+ steps.push(format!("{label}: {}", if okd { "opened" } else { "not opened" }));+ if !okd { return json!({"success": false, "errorCode": "open_failed", "error": format!("Fusion did not open the {label}: {}", r["error"].as_str().unwrap_or("")), "plan": plan, "steps": steps}); }+ std::thread::sleep(Duration::from_secs(2));+ }+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &name);+ json!({"success": true, "mutated": true, "project": dir, "files": [sch_path, brd_path], "layers": layers, "layerSetup": setup, "steps": steps,+ "fabRules": args["fab"].clone().as_str().or(args["fabRules"].as_str()).map(|f| json!({"recorded": f, "apply": "fusion_set_design_rules {fab} once the board has content"})),+ "_hint": "An empty schematic and board, open in Fusion as a new design. Save it to the cloud with fusion_save_to_cloud {name, projectName, folderPath}; the EAGLE files stay in project as the source."})+}
rust/lbr/src/lib.rs+189@@ -1045,3 +1045,192 @@ pub fn read_footprint(xml: &str, library: Option<&str>, name: &str) -> Result<se "models": models, })) }++/// The standard's edit_footprint on one package of a .lbr (or a board-embedded library's text):+/// pads changed, deleted, added or renumbered (a device's connects follow a renamed pad, and a+/// deleted pad's connects are dropped and reported); graphics appended to, replaced on or deleted+/// from a layer; the description set. 3D models are Fusion cloud links (package3d URNs), so a+/// models edit is refused. Returns the new document text and what changed.+pub fn edit_footprint(xml: &str, name: &str, edits: &serde_json::Value) -> Result<(String, serde_json::Value), String> {+ use serde_json::{json, Value};+ let mut root = Element::parse(xml).map_err(|e| e.to_string())?;+ let layer_names: Vec<(String, String)> = {+ let mut v = Vec::new();+ fn walk(e: &Element, v: &mut Vec<(String, String)>) { if e.tag == "layer" { v.push((e.attr("number").unwrap_or("").into(), e.attr("name").unwrap_or("").into())); } for c in &e.children { walk(c, v); } }+ walk(&root, &mut v); v+ };+ let layer_of = |l: &Value| -> Result<String, String> {+ if let Some(n) = l.as_u64() { return Ok(n.to_string()); }+ let s = l.as_str().unwrap_or("");+ let std_ = [("F.Cu", "1"), ("B.Cu", "16"), ("F.SilkS", "21"), ("B.SilkS", "22"), ("F.Fab", "51"), ("B.Fab", "52"), ("F.CrtYd", "39"), ("B.CrtYd", "40"),+ ("F.Mask", "29"), ("B.Mask", "30"), ("F.Paste", "31"), ("B.Paste", "32"), ("Edge.Cuts", "20"), ("Dwgs.User", "48"), ("Cmts.User", "48")];+ if let Some((_, n)) = std_.iter().find(|(k, _)| k.eq_ignore_ascii_case(s)) { return Ok(n.to_string()); }+ if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() { return Ok(s.to_string()); }+ layer_names.iter().find(|(_, nm)| nm.eq_ignore_ascii_case(s)).map(|(n, _)| n.clone()).ok_or_else(|| format!("unknown layer {s}"))+ };+ if !edits["models"].is_null() { return Err("models: Fusion links a package to its 3D model by cloud URN (package3d); edit it in Fusion's 3D package editor (fusion_show_3d_package)".into()); }+ let f = |v: f64| { let s = format!("{:.4}", v); s.trim_end_matches('0').trim_end_matches('.').to_string() };+ let mut changed: Vec<Value> = Vec::new();+ let mut renames: Vec<(String, String)> = Vec::new();+ let mut deleted: Vec<String> = Vec::new();+ {+ let lib = root.find_deep_mut("library").ok_or("no <library>")?;+ let pkgs = lib.find_child_mut("packages").ok_or("no <packages>")?;+ let pkg = pkgs.children.iter_mut().find(|p| p.tag == "package" && p.attr("name") == Some(name)).ok_or_else(|| format!("no footprint {name}"))?;+ let set_attr = |e: &mut Element, k: &str, v: String| { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v, None => e.attrs.push((k.into(), v)) } };+ let is_pad = |e: &Element| e.tag == "smd" || e.tag == "pad";+ let make_pad = |spec: &Value, number: &str| -> Result<Element, String> {+ let thru = matches!(spec["type"].as_str(), Some("thru") | Some("tht") | Some("pad"));+ let mut e = Element::new(if thru { "pad" } else { "smd" });+ let at = spec["at"].as_array().ok_or("a new pad needs at [x, y]")?;+ e.attrs = vec![("name".into(), number.into()), ("x".into(), f(at[0].as_f64().unwrap_or(0.0))), ("y".into(), f(at[1].as_f64().unwrap_or(0.0)))];+ let size = spec["sizeMm"].as_array();+ if thru {+ let d = spec["drillMm"].as_f64().ok_or("a through pad needs drillMm")?;+ e.attrs.push(("drill".into(), f(d)));+ if let Some(s) = size { e.attrs.push(("diameter".into(), f(s[0].as_f64().unwrap_or(0.0)))); }+ if let Some(sh) = spec["shape"].as_str() { e.attrs.push(("shape".into(), match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into())); }+ } else {+ let s = size.ok_or("an SMD pad needs sizeMm [w, h]")?;+ e.attrs.push(("dx".into(), f(s[0].as_f64().unwrap_or(0.0)))); e.attrs.push(("dy".into(), f(s[1].as_f64().unwrap_or(0.0))));+ let layer = spec["layers"].as_array().and_then(|l| l.first()).map(|l| if l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false) { "16" } else { "1" }).unwrap_or("1");+ e.attrs.push(("layer".into(), layer.into()));+ if let Some(sh) = spec["shape"].as_str() { if sh != "rect" { e.attrs.push(("roundness".into(), if sh == "roundrect" { "25" } else { "100" }.into())); } }+ }+ if let Some(r) = spec["rotation"].as_f64() { if r != 0.0 { e.attrs.push(("rot".into(), format!("R{}", f(r)))); } }+ Ok(e)+ };+ // pad edits+ for pe in edits["pads"].as_array().cloned().unwrap_or_default() {+ let num = pe["number"].as_str().map(str::to_string).or(pe["number"].as_u64().map(|n| n.to_string())).ok_or("each pad edit needs number")?;+ let idx = pkg.children.iter().position(|c| is_pad(c) && c.attr("name") == Some(num.as_str())).ok_or_else(|| format!("no pad {num} in {name}"))?;+ if pe["delete"].as_bool() == Some(true) { pkg.children.remove(idx); deleted.push(num.clone()); changed.push(json!({"pad": num, "what": "deleted"})); continue; }+ let set = &pe["set"];+ if let Some(t) = set["type"].as_str() {+ let want_thru = matches!(t, "thru" | "tht" | "pad");+ if want_thru != (pkg.children[idx].tag == "pad") {+ let old = &pkg.children[idx];+ let mut spec = set.clone();+ if spec["at"].is_null() { spec["at"] = json!([old.attr("x").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0), old.attr("y").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0)]); }+ let e = make_pad(&spec, &num)?;+ pkg.children[idx] = Element { tail: pkg.children[idx].tail.clone(), ..e };+ changed.push(json!({"pad": num, "what": format!("type {t}")}));+ }+ }+ let e = &mut pkg.children[idx];+ if let Some(at) = set["at"].as_array() { set_attr(e, "x", f(at[0].as_f64().unwrap_or(0.0))); set_attr(e, "y", f(at[1].as_f64().unwrap_or(0.0))); changed.push(json!({"pad": num, "what": "at"})); }+ if let Some(s) = set["sizeMm"].as_array() {+ if e.tag == "smd" { set_attr(e, "dx", f(s[0].as_f64().unwrap_or(0.0))); set_attr(e, "dy", f(s[1].as_f64().unwrap_or(0.0))); } else { set_attr(e, "diameter", f(s[0].as_f64().unwrap_or(0.0))); }+ changed.push(json!({"pad": num, "what": "size"}));+ }+ if let Some(d) = set["drillMm"].as_f64() { if e.tag != "pad" { return Err(format!("pad {num} is SMD: drillMm needs type thru")); } set_attr(e, "drill", f(d)); changed.push(json!({"pad": num, "what": "drill"})); }+ if let Some(r) = set["rotation"].as_f64() { set_attr(e, "rot", format!("R{}", f(r))); changed.push(json!({"pad": num, "what": "rotation"})); }+ if let Some(sh) = set["shape"].as_str() {+ if e.tag == "smd" { set_attr(e, "roundness", match sh { "rect" => "0", "roundrect" => "25", _ => "100" }.into()); }+ else { set_attr(e, "shape", match sh { "rect" | "square" => "square", "oval" | "long" => "long", "octagon" => "octagon", _ => "round" }.into()); }+ changed.push(json!({"pad": num, "what": "shape"}));+ }+ if let Some(ls) = set["layers"].as_array() { if e.tag == "smd" { let l = if ls.iter().any(|l| l.as_str().map(|s| s.starts_with("B.")).unwrap_or(false)) { "16" } else { "1" }; set_attr(e, "layer", l.into()); changed.push(json!({"pad": num, "what": "layer"})); } }+ if let Some(nn) = set["newNumber"].as_str().map(str::to_string).or(set["newNumber"].as_u64().map(|n| n.to_string())) { set_attr(e, "name", nn.clone()); renames.push((num.clone(), nn.clone())); changed.push(json!({"pad": num, "what": format!("renumbered {nn}")})); }+ }+ // new pads+ for spec in edits["addPads"].as_array().cloned().unwrap_or_default() {+ let num = spec["number"].as_str().map(str::to_string).or(spec["number"].as_u64().map(|n| n.to_string())).ok_or("a new pad needs number")?;+ if pkg.children.iter().any(|c| is_pad(c) && c.attr("name") == Some(num.as_str())) { return Err(format!("pad {num} already exists in {name}")); }+ let e = make_pad(&spec, &num)?;+ let at = pkg.children.iter().rposition(|c| is_pad(c)).map(|i| i + 1).unwrap_or(pkg.children.len());+ pkg.children.insert(at, e);+ changed.push(json!({"pad": num, "what": "added"}));+ }+ // renumbering+ let rn = &edits["renumber"];+ let mut map: Vec<(String, String)> = Vec::new();+ if let Some(m) = rn["map"].as_object() { map = m.iter().map(|(k, v)| (k.clone(), v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect(); }+ else if rn["sequential"].as_bool() == Some(true) {+ let start = rn["start"].as_u64().unwrap_or(1);+ map = pkg.children.iter().filter(|c| is_pad(c)).enumerate().map(|(i, c)| (c.attr("name").unwrap_or("").to_string(), (start + i as u64).to_string())).collect();+ }+ if !map.is_empty() {+ let olds: Vec<String> = pkg.children.iter().filter(|c| is_pad(c)).map(|c| c.attr("name").unwrap_or("").to_string()).collect();+ for (from, _) in &map { if !olds.contains(from) { return Err(format!("renumber: no pad {from}")); } }+ let finals: Vec<String> = olds.iter().map(|o| map.iter().find(|(f_, _)| f_ == o).map(|(_, t)| t.clone()).unwrap_or(o.clone())).collect();+ let mut seen = std::collections::HashSet::new();+ if let Some(d) = finals.iter().find(|n| !seen.insert(n.as_str())) { return Err(format!("renumber gives two pads the number {d}")); }+ for c in pkg.children.iter_mut().filter(|c| is_pad(c)) {+ let old = c.attr("name").unwrap_or("").to_string();+ if let Some((_, to)) = map.iter().find(|(f_, _)| *f_ == old) { if *to != old { set_attr(c, "name", to.clone()); renames.push((old, to.clone())); } }+ }+ changed.push(json!({"what": format!("renumbered {} pads", map.len())}));+ }+ // graphics+ for g in edits["graphics"].as_array().cloned().unwrap_or_default() {+ let layer = layer_of(&g["layer"])?;+ let mode = g["mode"].as_str().unwrap_or("append");+ if mode == "replace" || mode == "delete" {+ let before = pkg.children.len();+ pkg.children.retain(|c| is_pad(c) || c.tag == "description" || c.tag == "hole" || c.attr("layer") != Some(layer.as_str()));+ changed.push(json!({"layer": layer, "what": format!("{} graphics removed", before - pkg.children.len())}));+ if mode == "delete" { continue; }+ }+ let w = f(g["widthMm"].as_f64().unwrap_or(0.12));+ let mut n = 0;+ for s in g["shapes"].as_array().cloned().unwrap_or_default() {+ let pt = |v: &Value| -> (String, String) { (f(v[0].as_f64().unwrap_or(0.0)), f(v[1].as_f64().unwrap_or(0.0))) };+ let mut e: Element;+ match s["type"].as_str().unwrap_or("") {+ "line" | "arc" => {+ let (a, b) = (pt(&s["start"]), pt(&s["end"]));+ e = Element::new("wire");+ e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("width".into(), w.clone()), ("layer".into(), layer.clone())];+ if let Some(c) = s["angle"].as_f64() { e.attrs.push(("curve".into(), f(c))); }+ }+ "rect" => {+ let (a, b) = (pt(&s["start"]), pt(&s["end"]));+ e = Element::new("rectangle");+ e.attrs = vec![("x1".into(), a.0), ("y1".into(), a.1), ("x2".into(), b.0), ("y2".into(), b.1), ("layer".into(), layer.clone())];+ }+ "circle" => {+ let c = pt(&s["center"]);+ e = Element::new("circle");+ e.attrs = vec![("x".into(), c.0), ("y".into(), c.1), ("radius".into(), f(s["radius"].as_f64().unwrap_or(0.5))), ("width".into(), w.clone()), ("layer".into(), layer.clone())];+ }+ "poly" => {+ e = Element::new("polygon");+ e.attrs = vec![("width".into(), w.clone()), ("layer".into(), layer.clone())];+ for p in s["points"].as_array().cloned().unwrap_or_default() { let q = pt(&p); let mut v = Element::new("vertex"); v.attrs = vec![("x".into(), q.0), ("y".into(), q.1)]; e.children.push(v); }+ if e.children.len() < 3 { return Err("a poly needs three points".into()); }+ }+ other => return Err(format!("unknown shape type {other}")),+ }+ pkg.children.push(e); n += 1;+ }+ if n > 0 { changed.push(json!({"layer": layer, "what": format!("{n} graphics added")})); }+ }+ // metadata+ if let Some(d) = edits["metadata"]["description"].as_str() {+ match pkg.children.iter_mut().find(|c| c.tag == "description") { Some(e) => e.text = d.to_string(), None => { let mut e = Element::new("description"); e.text = d.to_string(); pkg.children.insert(0, e); } }+ changed.push(json!({"what": "description"}));+ }+ set_attr(pkg, "locally_modified", "yes".into());+ }+ // devices that use the package follow renamed and deleted pads+ let mut dropped: Vec<Value> = Vec::new();+ fn fix(e: &mut Element, name: &str, renames: &[(String, String)], deleted: &[String], dropped: &mut Vec<Value>) {+ if e.tag == "device" && e.attr("package") == Some(name) {+ if let Some(cs) = e.find_child_mut("connects") {+ cs.children.retain(|c| { let keep = !deleted.iter().any(|d| c.attr("pad").map(|p| p.split(' ').any(|q| q == d)).unwrap_or(false)); if !keep { dropped.push(serde_json::json!({"pin": c.attr("pin"), "pad": c.attr("pad")})); } keep });+ for c in cs.children.iter_mut() {+ if let Some(pad) = c.attr("pad").map(str::to_string) {+ let np: Vec<String> = pad.split(' ').map(|q| renames.iter().find(|(f_, _)| f_ == q).map(|(_, t)| t.clone()).unwrap_or(q.to_string())).collect();+ let v = np.join(" ");+ if v != pad { if let Some(a) = c.attrs.iter_mut().find(|(k, _)| k == "pad") { a.1 = v; } }+ }+ }+ }+ }+ for c in e.children.iter_mut() { fix(c, name, renames, deleted, dropped); }+ }+ fix(&mut root, name, &renames, &deleted, &mut dropped);+ let text = root.to_xml_document();+ Ok((text, json!({"changed": changed, "connectsDropped": dropped, "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>()})))+}
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.1.2"+version = "0.1.4" edition = "2021" description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+1−1@@ -33,7 +33,7 @@ Every check takes one JSON object: | `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) | | `stack` | the stack-up companion, `<prefix>_layer_stack` | | `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |-| `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` |+| `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped | | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` | | `args` | the check's own arguments, as the standard verb documents them |
rust/vendor/adom-eda-checks/VENDOR.md+4−4@@ -3,10 +3,10 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.4.3-- Standard commit: 36e8072e0b6aa524169534eb43fda219f51b7a71-- Crate version: 0.1.2-- Copied: 2026-10-27, by fusion-lane+- Standard version: 0.4.5+- Standard commit: bb2b810d9ac934c5e65438a7cd4441bb81d41256+- Crate version: 0.1.4+- Copied: 2026-10-28, by fusion-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`, `engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests.
rust/vendor/adom-eda-checks/src/current.rs+4−2@@ -69,7 +69,9 @@ pub fn check(state: &Value, copper_um: &[f64], nets: &Map<String, Value>, rise: let each = via_amps(smallest, plating_um, rise); json!({"count": net_vias.len(), "smallestDrillMm": r3(smallest), "ampsEach": r2(each), "neededPerLayerChange": (amps / each).ceil() as i64}) };- let pours: Vec<Value> = polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()).collect();+ // each layer once, however many pours of the net it holds (#15)+ let mut pours: Vec<Value> = Vec::new();+ for l in polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()) { if !pours.contains(&l) { pours.push(l); } } json!({"net": net, "amps": amps, "layers": layers, "shortOn": short, "ok": short.is_empty(), "vias": via, "poursOn": pours}) }).collect() }@@ -118,7 +120,7 @@ mod tests { #[test] fn check_finds_the_narrow_layer() { let st = json!({"tracks": [{"net": "VIN", "layer": "F.Cu", "width": 1.5, "start": {"x": 0.0, "y": 0.0}}, {"net": "VIN", "layer": "B.Cu", "width": 0.3, "start": {"x": 4.0, "y": 1.0}}],- "vias": [{"net": "VIN", "drill": 0.3}, {"net": "VIN", "drill": 0.4}], "polygons": [{"net": "VIN", "layer": "B.Cu"}]});+ "vias": [{"net": "VIN", "drill": 0.3}, {"net": "VIN", "drill": 0.4}], "polygons": [{"net": "VIN", "layer": "B.Cu"}, {"net": "VIN", "layer": "B.Cu"}]}); let nets = json!({"VIN": 3.0}).as_object().unwrap().clone(); let r = &check(&st, &[35.0, 35.0], &nets, 10.0, 25.0)[0]; assert_eq!(r["shortOn"], json!(["B.Cu"]));
rust/vendor/adom-eda-checks/src/fab.rs+22−3@@ -132,14 +132,19 @@ fn length_mm(text: &str) -> Option<f64> { } } -/// The smallest clearance among the board's enabled clearance rules, in mm. A rule gives it as a number+/// A clearance rule that names another object than copper of another net: the board edge, solder mask, silkscreen,+/// holes, keepouts, the courtyard, or the same net (Fusion's Solder-Mask Clearance is a mask expansion, often 0.05 mm).+const NOT_COPPER_CLEARANCE: &[&str] = &["dimension", "edge", "board", "mask", "silk", "paste", "hole", "restrict", "keepout", "keep-out",+ "courtyard", "component", "body", "same net", "samenet", "same-net"];++/// The smallest copper-to-copper clearance among the board's enabled rules, in mm. A rule gives it as a number /// (`clearanceMm`, `gapMm` or `minMm`), as a `value` with its unit ("0.1mm": Fusion's form), or in a descriptor the tool-/// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Same-net spacing rules are not clearance between nets: left out.+/// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Clearance rules about anything but copper of another net are left out. pub fn min_clearance(rules: &[Value]) -> Option<f64> { let text = |r: &Value, k: &str| r[k].as_str().map(|s| s.to_lowercase()).unwrap_or_default(); rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || text(r, "kind").contains("clearance") || r["kindName"].as_str() == Some("Clearance") || text(r, "name").contains("clearance"))- .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); t.contains("same net") || t.contains("samenet") || t.contains("same-net") }))+ .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); NOT_COPPER_CLEARANCE.iter().any(|w| t.contains(w)) })) .filter(|r| r["enabled"].as_bool() != Some(false)) .filter_map(|r| { if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }@@ -320,4 +325,18 @@ mod tests { assert_eq!(length_mm("100um"), Some(0.1)); assert_eq!(length_mm("0.2"), None, "a bare number has no unit: never guessed"); }++ #[test]+ fn only_copper_to_copper_clearance_counts() {+ // Fusion's rule set (#13): only Copper Clearance is the clearance between nets+ let fusion = vec![json!({"kind": "Copper Clearance", "value": "0.1mm"}), json!({"kind": "Dimension Clearance", "value": "0.3mm"}),+ json!({"kind": "Solder-Mask Clearance", "value": "0.05mm"}), json!({"kind": "Restrict Clearance", "value": "0.02mm"}),+ json!({"kind": "Via Restrict Clearance", "value": "0.02mm"}), json!({"kind": "Keepout Clearance", "value": "0.01mm"})];+ assert_eq!(min_clearance(&fusion), Some(0.1));+ // Altium names its other clearances by object too+ let altium = vec![json!({"kind": 0, "name": "Clearance", "descriptor": "Clearance Constraint (Gap=0.2mm) (All),(All)"}),+ json!({"kind": 17, "name": "HoleToHoleClearance", "descriptor": "Gap=0.1mm"}),+ json!({"kind": 20, "name": "SilkToSolderMaskClearance", "descriptor": "Gap=0.05mm"})];+ assert_eq!(min_clearance(&altium), Some(0.2));+ } }
tests/run_verb_tests.py+14−1@@ -247,6 +247,17 @@ t("fusion_export_ipc356", "daily", dict(B, outputDir=f"{SCRATCH}/ipc356"), {"ok" t("fusion_export_gencad", "daily", dict(B, outputDir=f"{SCRATCH}/gencad"), {"ok": True, "fields": ["path", "components", "signals"]}) t("fusion_export_ipc2581", "daily", dict(B, outputDir=f"{SCRATCH}/ipc2581"), {"ok": True, "fields": ["path", "components", "pourIslands"]}) t("fusion_read_footprint", "daily", dict(B, libId="Capacitor:CAPC2012X110"), {"ok": True, "fields": ["pads", "courtyardMm", "layers"]})+t("fusion_project_info", "daily", dict(B), {"ok": True, "fields": ["files", "board", "sheets"]})+t("fusion_project_rename", "negative", {"project": "Main/Molecules/BQ25792", "newName": "X"}, {"errorCode": "not_supported"}, "a cloud design is renamed in Fusion's data panel, not by path")+t("fusion_project_create", "daily", {"parentDir": "C:/Users/olive/AppData/Local/Temp/fusion-lane", "name": "lane-new", "layers": 4, "dryRun": True}, {"ok": True, "fields": ["plan"]})+t("fusion_project_snapshot", "daily", dict(B, label="daily", outputDir=f"{SCRATCH}/snapshots"), {"ok": True, "fields": ["folder", "files", "pdfs"]})+t("fusion_cross_probe", "daily", dict(B, ref="C11"), {"ok": True, "fields": ["matches", "symbolWithoutPart"]})+t("fusion_set_layer_count", "daily", dict(B, count=6, dryRun=True), {"ok": True, "fields": ["stackup", "to"]})+t("fusion_set_layer_count", "negative", dict(B, count=2), {"errorCode": "layers_in_use"}, "BQ25792's inner layers carry pours and tracks")+t("fusion_copy_routing", "daily", dict(B, box=[20, 13, 30, 16], offset=[0, -10], dryRun=True), {"ok": True, "fields": ["copied", "skipped"]})+t("fusion_copy_routing", "negative", dict(B, box=[20, 13, 30, 16], offset=[0, -10], keepUnmappedNets=True), {"errorCode": "destination_conflict"}, "the copy would land on other nets' pads: refused before drawing")+t("fusion_import_svg", "daily", dict(B, svg='<svg xmlns="http://www.w3.org/2000/svg" width="4mm" height="2mm" viewBox="0 0 40 20"><rect width="40" height="20"/></svg>', at=[2, 24], dryRun=True), {"ok": True, "fields": ["polygons", "sizeMm"]})+t("fusion_import_svg", "negative", dict(B, svg='<svg xmlns="http://www.w3.org/2000/svg" width="4mm" height="2mm" viewBox="0 0 40 20"><rect width="40" height="20"/></svg>', at=[2, 24], layer="F.Cu"), {"errorCode": "not_supported"}, "copper is refused before any command runs (no Signal Name modal)") t("fusion_read_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "name": "X"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") 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")@@ -375,6 +386,8 @@ t("fusion_set_window_sizing", "daily", {}, {"ok": True}, "no-args form is a pure t("fusion_cancel_run", "negative", {"runId": "no-such-run"}, {"errorCode": "unknown_run_id"}, "expect an honest 'unknown runId'") t("fusion_preflight_lbr", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {"pred": "must_refuse"}, "expect a refusal naming the missing file") t("fusion_library_parts", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file")+t("fusion_edit_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "name": "X", "dryRun": True}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file")+t("fusion_export_library_part", "negative", {"libraryPath": "C:/nope/missing.lbr", "part": "X"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") t("fusion_take_screenshot", "weekly", {}, {"ok": True}, "needs Fusion up; hwnd-targeted background capture") t("fusion_activate_document", "weekly", {"name": "Untitled"}, {}, "needs an open document; shape only") t("fusion_library_manager", "weekly", {}, {"ok": True}, "drives the GUI (Library Manager), reads nothing destructive")@@ -439,7 +452,7 @@ _SECTION_BY_VERB = { "fusion_action_ledger": "bridge-lifecycle", "fusion_addin_status": "python-addin", "fusion_alerts": "gui-driving", "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom", "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",- "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",+ "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving", "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom", "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom", "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",
tests/test_routing.py+76@@ -1,6 +1,7 @@ import copy import importlib.util from pathlib import Path+import math import unittest import xml.etree.ElementTree as ET @@ -402,4 +403,79 @@ class Ipc2581Tests(unittest.TestCase): self.assertEqual('10kohm +/-1% 4.7uF', r.ascii_text('10k\u03a9 \u00b11% 4.7\u00b5F')) ++class ImportSvgTests(unittest.TestCase):+ def test_shapes_holes_and_skips(self):+ text = (ROOT/'tests/fixtures/import-test.svg').read_text()+ shapes, skipped, unit = r.svg_shapes(text, 0.1)+ self.assertAlmostEqual(0.1, unit) # 20 mm over a 200-unit viewBox+ self.assertEqual(3, len(shapes))+ self.assertEqual({'text', 'path'}, {k['element'] for k in skipped})+ square = r.svg_rects(shapes[0], 1.0)+ # the square's hole is cut out: 80x80 minus 40x40, in 4 rectangles (below, the two sides, above)+ self.assertAlmostEqual(80 * 80 - 40 * 40, sum((x2 - x1) * (y2 - y1) for x1, y1, x2, y2 in square), places=3)+ self.assertEqual(4, len(square))+ disc = r.svg_rects(shapes[1], 0.5)+ self.assertAlmostEqual(math.pi * 30 * 30, sum((x2 - x1) * (y2 - y1) for x1, y1, x2, y2 in disc), delta=60)++ def test_arc_sweep_follows_svg(self):+ up = r._arc((110, 70), 30, 30, 0, False, True, (170, 70), 0.5)+ self.assertAlmostEqual(40, min(p[1] for p in up), places=0)+ down = r._arc((110, 70), 30, 30, 0, False, False, (170, 70), 0.5)+ self.assertAlmostEqual(100, max(p[1] for p in down), places=0)++ def test_transform(self):+ M = r._svg_matrix('translate(10,0) rotate(90)')+ x, y = r._svg_apply(M, (1, 0))+ self.assertAlmostEqual(10, x); self.assertAlmostEqual(1, y)++++class CopyRoutingTests(unittest.TestCase):+ BQ = Path('/home/adom/project/fusion-lane/bq25792-charger/bq25792-charger.brd')++ def setUp(self):+ if not self.BQ.exists():+ self.skipTest('BQ25792 fixture not on this machine')+ self.b = ET.parse(self.BQ).getroot().find('drawing/board')++ def test_unmapped_nets_are_skipped(self):+ pl = r.copy_routing_plan(self.b, {'box': [20, 13, 30, 16], 'offset': [0, -10]})+ self.assertEqual([], pl['tracks'])+ self.assertTrue(pl['skipped'])++ def test_clash_with_another_net_is_found_before_drawing(self):+ pl = r.copy_routing_plan(self.b, {'box': [20, 13, 30, 16], 'offset': [0, -10], 'keepUnmappedNets': True})+ self.assertTrue(pl['clashes'])+ self.assertTrue(all(c['touches'] for c in pl['clashes']))++ def test_pairs_map_nets_pad_by_pad(self):+ # C12 and C13 are the same package: pairing them maps each of C12's nets to C13's+ pl = r.copy_routing_plan(self.b, {'box': [0, 0, 0.1, 0.1], 'offset': [1, 0], 'refMap': {'C12': 'C13'}})+ self.assertEqual({'C12': 'C13'}, pl['pairs'])+ with self.assertRaises(r.RoutingError):+ r.copy_routing_plan(self.b, {'box': [0, 0, 1, 1], 'offset': [1, 0], 'refMap': {'C12': 'NOPE9'}})++++class LayerCountTests(unittest.TestCase):+ def test_stackup_keeps_thickness_and_reads_back(self):+ root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+ for n in (2, 4, 6, 8):+ st = r.stackup_for(n, 1.6, {}, None)+ b2 = ET.fromstring(ET.tostring(b))+ old = b2.find('.//layerstackup')+ if old is not None:+ [p for p in b2.iter() if old in list(p)][0].remove(old)+ b2.find('designrules').append(st)+ for p_ in b2.findall('designrules/param'):+ if p_.get('name') == 'layerSetup': p_.set('value', r.LAYER_SETUP[n])+ self.assertEqual(r.LAYER_SETS[n], r.Layers(b2).order)+ self.assertAlmostEqual(1600, r.layer_stack(b2)['totalUm'], delta=1)++ def test_too_thin_is_refused(self):+ with self.assertRaises(r.RoutingError):+ r.stackup_for(8, 0.8, {}, None)++ if __name__=='__main__':unittest.main()
tests/test_sch.py+24@@ -124,5 +124,29 @@ class SchEditPlanTests(unittest.TestCase): self.assertEqual("'it''s'", s.quote("it's")) ++class CrossProbeTests(unittest.TestCase):+ def test_pairs_by_designator(self):+ import xml.etree.ElementTree as ET+ sr = ET.parse(str(FIXTURE)).getroot()+ br = ET.parse(str(ROOT / 'demo/routing/fixture/fusion-routing.brd')).getroot()+ m, without = s.cross_probe_pairs(sr, br, {})+ self.assertTrue(m and all(x['linked'] for x in m))+ self.assertEqual([], without)+ j1 = s.cross_probe_pairs(sr, br, {'ref': 'J1'})[0]+ self.assertEqual(['J1'], [x['ref'] for x in j1])+ self.assertEqual('J1:G$1', j1[0]['schematic']['id'])+ self.assertEqual('top', j1[0]['board']['side'])+ miss = s.cross_probe_pairs(sr, br, {'ref': 'ZZ9'})[0]+ self.assertIs(False, miss[0]['found'])++ def test_no_board_means_unlinked(self):+ import xml.etree.ElementTree as ET+ sr = ET.parse(str(FIXTURE)).getroot()+ m, without = s.cross_probe_pairs(sr, None, {})+ self.assertTrue(m and not any(x['linked'] for x in m))+ self.assertEqual(sorted(x['ref'] for x in m), sorted(without))++ if __name__ == '__main__': unittest.main()