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 28 file(s)
28 files changed
+1487−51
BRIDGE_VERSION+1−1CHANGELOG.md+19bridge.json+9−1contracts/describe.json+183dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+16install.sh+1−1package.json+1−1page.json+5−11rust/Cargo.lock+6−6rust/Cargo.toml+1−1rust/bridge/src/boardgen.rs+486rust/bridge/src/checks.rs+2−1rust/bridge/src/main.rs+25−4rust/bridge/src/partimport.rs+59rust/bridge/src/testproject.rs+13−7rust/lbr/src/authoring.rs+118−7rust/lbr/src/import.rs+237rust/lbr/src/lbr_template_head.xml+2rust/lbr/src/lib.rs+1rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+2−1rust/vendor/adom-eda-checks/VENDOR.md+4−4rust/vendor/adom-eda-checks/src/features.rs+3−1rust/vendor/adom-eda-checks/src/landpattern.rs+276rust/vendor/adom-eda-checks/src/lib.rs+3−1tests/run_verb_tests.py+11BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.13+1.13.14
CHANGELOG.md+19@@ -3,6 +3,25 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.14 (2026-10-29)++- Engine 0.18.0 (standard 0.21.0). plan_route and the other planners take the track width from the board's minimum+ width rule (msWidth) when no net class sets one, instead of the engine's 0.25 mm default.+- `fusion_footprint_from_land_pattern` (the standard's footprint_from_land_pattern): the engine lays out a datasheet's+ land pattern (chip, dual, SOT, quad, BGA, exposed pad) and the bridge writes it into a .lbr offline: pads with EAGLE+ roundness from the engine's cornerRatio, silkscreen on tPlace, the fab outline on tDocu, the courtyard on tKeepout.+ New libraries now carry the tDocu and bDocu layers.+- `fusion_build_board` (the standard's build_board): a new board from a parts list and a netlist, written as EAGLE+ source (outline with rounded corners or points, footprints by origin, pad nets), opened in Fusion as a local design+ dialog-free and read back pin by pin (wrongPins, missingParts, unconnected).+- `fusion_test_board` (the standard's test_board): footprints from a .lbr on a new board, or appended to an existing+ .brd with designators continuing; a plain rectangular outline grows to hold appended parts, and parts outside any+ other outline are named. assigns and stubs apply to the last part.+- `fusion_import_part` (the standard's import_part): the footprint and symbol spec files Altium's bridge takes go into+ one .lbr offline (pins joined to pads by number, a repeated pad number as N@2 on the same pin, params and comment as+ attributes), preflighted before writing; a STEP is minted and bound to the component dialog-free. Importing again+ replaces the footprint, the symbol and the 3D body. 330 verbs.+ ## 1.13.13 (2026-10-29) - Engine 0.17.2 (standard 0.20.2). The checks get the board's minimum track width and drill (EAGLE's msWidth,
bridge.json+9−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.13",+ "version": "1.13.14", "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.",@@ -65,6 +65,7 @@ "fusion_board_stackup", "fusion_board_summary", "fusion_bodies",+ "fusion_build_board", "fusion_build_library_3d", "fusion_cancel_run", "fusion_capture_library_views",@@ -146,6 +147,7 @@ "fusion_fetch_optimized_glb", "fusion_find_space", "fusion_find_symbol",+ "fusion_footprint_from_land_pattern", "fusion_footprint_primitives", "fusion_generate_body", "fusion_generate_package",@@ -156,6 +158,7 @@ "fusion_height_check", "fusion_impedance", "fusion_import_electronics",+ "fusion_import_part", "fusion_import_step", "fusion_import_svg", "fusion_inspect_bodies",@@ -337,6 +340,7 @@ "fusion_status", "fusion_stop", "fusion_take_screenshot",+ "fusion_test_board", "fusion_thermal_vias", "fusion_uninstall", "fusion_update_board_from_eagle",@@ -505,6 +509,10 @@ "project_info": 120, "project_snapshot": 300, "project_create": 180,+ "test_board": 180,+ "import_part": 600,+ "footprint_from_land_pattern": 30,+ "build_board": 180, "export_pdf": 300, "cross_check": 120, "doc_params": 120,
contracts/describe.json+183@@ -880,6 +880,60 @@ "summary": "Each part's 3D body as a box, from the design's 3D PCB: footprint rectangle in the board frame (x0, y0, x1, y1 mm) and height range above the surface it sits on (z0 standoff, z1 top; below the board for bottom parts). Names the parts with no 3D model (noBody) and the ones whose body is not where the board puts the part (suspect: off its footprint, floating or sunk, from a wrong library model or a 3D PCB not yet updated). The tallest part per side. What a height or enclosure check reads. Switches the design to its 3D PCB and back to the 2D board inside Fusion; takes no window or mouse.", "timeoutSeconds": 120 },+ {+ "example": {+ "libraryPath": "C:/libs/buck.lbr",+ "nets": {+ "GND": [+ "R1.2"+ ]+ },+ "outline": {+ "cornerRadius": 1,+ "rect": [+ 0,+ 0,+ 20,+ 15+ ]+ },+ "parts": [+ {+ "footprint": "R0603",+ "ref": "R1",+ "rotation": 0,+ "x": 5,+ "y": 5+ }+ ],+ "pcbPath": "C:/Boards/buck.brd"+ },+ "input": {+ "layers": "2|4|6|8",+ "libraryPath": "a .lbr holding every footprint (required)",+ "nets": "{net: ['R1.1', 'U1.3']}",+ "open": "bool, default true",+ "outline": "{rect: [x0, y0, w, h], cornerRadius?} | {points: [[x, y]...]} (default: the parts' box + 2 mm)",+ "parts": "[{ref, footprint, x, y, rotation, comment?}] (required)",+ "pcbPath": "the .brd to write (required)"+ },+ "longRunning": false,+ "name": "fusion_build_board",+ "output": {+ "counts": "{}",+ "documentName": "str",+ "missingParts": "[ref]",+ "padsWired": "int",+ "placed": "int",+ "revision": "str",+ "unconnected": "int - the ratsnest to route",+ "unknownPins": "[]",+ "wrongPins": "[{pin, want, got}]"+ },+ "statusVerb": null,+ "summary": "A NEW board from a parts list and a netlist (the standard's build_board): outline (rect with rounded corners, or points), every footprint from one .lbr placed by its own origin, pad nets assigned, written as EAGLE source (pcbPath overwritten), opened in Fusion as a local design dialog-free, then read back pin by pin. Footprint pad names must match the netlist's pin numbers (R1.1 = pad 1 of R1). Coordinates mm, Y up.",+ "timeoutSeconds": 180+ }, { "example": { "lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr",@@ -2604,6 +2658,65 @@ "summary": "Find components (or footprints with kind:'footprint') by part of their name across .lbr files, offline (the standard's find_symbol): the exact match first, else the shortest name that contains the query. Fusion keeps no installed-library list a script can read, so name the libraries or a folder.", "timeoutSeconds": 30 },+ {+ "example": {+ "body": {+ "l": 4.9,+ "w": 3.9+ },+ "dryRun": true,+ "family": "dual",+ "name": "SOIC-8",+ "padLength": 1.55,+ "padWidth": 0.6,+ "pins": 8,+ "pitch": 1.27,+ "span": 5.4+ },+ "input": {+ "ballPad": "bga mm",+ "body": "{w, l, height?, standoff?}",+ "cols": "bga",+ "courtyard": "margin mm, default 0.25",+ "description": "str",+ "dryRun": "bool",+ "exposedPad": "{w, h, x?, y?, number?}",+ "family": "chip | dual | sot | quad | bga",+ "leftPins": "sot",+ "libraryPath": ".lbr on the Fusion machine (required unless dryRun; created if missing)",+ "missing": "bga ['B2']",+ "name": "str (required)",+ "padLength": "mm, along the lead",+ "padShape": "roundrect | rect | round | oval",+ "padWidth": "mm, along the row",+ "pins": "dual/quad total",+ "pinsPerSideX": "quad",+ "pinsPerSideY": "quad",+ "pitch": "mm",+ "polarized": "chip: pin-1 dot",+ "rightPins": "sot",+ "rows": "bga",+ "silkClearance": "default 0.2",+ "silkWidth": "default 0.15",+ "span": "mm, pad centre to pad centre across the rows",+ "spanY": "quad top-bottom"+ },+ "longRunning": false,+ "name": "fusion_footprint_from_land_pattern",+ "output": {+ "courtyardLines": "int",+ "courtyardMm": "[x0, y0, x1, y1]",+ "engine": "str",+ "fabLines": "int",+ "footprint": "str",+ "padTable": "[[name, x, y, w, h]]",+ "pads": "int",+ "silkLines": "int"+ },+ "statusVerb": null,+ "summary": "A complete footprint from the land pattern a datasheet prints (the standard's footprint_from_land_pattern), laid out by the shared engine and written into a .lbr offline: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard (tKeepout), silkscreen kept 0.2 mm clear of copper with a pin-1 dot (tPlace), a fab outline with a pin-1 chamfer (tDocu). dryRun returns the pad table only. Preflighted before writing.",+ "timeoutSeconds": 30+ }, { "example": { "designator": "U1"@@ -2831,6 +2944,40 @@ "summary": "Import a Fusion-NATIVE electronics file (.fsch schematic, .fbrd board, .flbr library; NOT legacy EAGLE .sch/.brd/.lbr) from the Fusion machine as a NEW local document via Document.newDesignFromLocal. The document is unsaved: fusion_save_to_cloud persists it, fusion_close_document discards it. The bridge captures a post-open screenshot because Fusion can raise a blocking dialog the API cannot see.", "timeoutSeconds": 60 },+ {+ "example": {+ "dryRun": true,+ "footprintSpec": "C:/specs/fp.spec",+ "libraryPath": "C:/libs/parts.lbr",+ "params": {+ "MPN": "TPS7A2033"+ },+ "symbolSpec": "C:/specs/sym.spec"+ },+ "input": {+ "comment": "str",+ "description": "str",+ "dryRun": "bool",+ "footprint": "an existing footprint for the symbol when no footprintSpec",+ "footprintSpec": "path or text: META|name|desc PAD|des|x|y|w|h|shape|hole|layer|crpct LINE ARC DISC (mm)",+ "libraryPath": ".lbr on the Fusion machine (pcbLib/schLib accepted: one Fusion library holds both)",+ "mode": "symbol display mode, default 0",+ "params": "{MPN: ...}",+ "step": ".step path (needs a symbol; signed-in Fusion)",+ "symbolSpec": "path or text: META|libref|desc|designator MODE RECT PIN|mode|number|name|x|y|orient|len|hide|etype LINE ARC (mils; x,y the pin's body end)"+ },+ "longRunning": false,+ "name": "fusion_import_part",+ "output": {+ "backup": "path",+ "footprint": "str",+ "steps": "[{build_footprint, padAliases, replaced}, {build_symbol, modesInSpec}, {add_models}, {params}, {add_body, wipUrn}]",+ "symbol": "str"+ },+ "statusVerb": null,+ "summary": "One call imports a whole part (the standard's import_part): the footprint spec and the symbol spec (the same files Altium's bridge takes) become a footprint, a symbol and a component in one .lbr, every pin joined to its pad by number (a repeated pad number becomes N@2... on the same pin), params and comment as attributes, preflighted before writing; a STEP is then minted and bound to the component, dialog-free. Importing again REPLACES the footprint, the symbol and the 3D body. An EAGLE symbol has one look: the spec's mode (default 0) is drawn.",+ "timeoutSeconds": 600+ }, { "example": { "filePath": "C:/Temp/part.step"@@ -20805,6 +20952,42 @@ "summary": "Screenshot the Fusion window in the BACKGROUND (no focus steal, reads through occlusion). Use it to VERIFY your own work: geometry at the wrong Z, an empty view, or a Placeholder 3D body all render successfully and only a look at the pixels catches them.", "timeoutSeconds": 60 },+ {+ "example": {+ "footprintName": "VL53L5CX;VL53L7CX",+ "libraryPath": "C:/libs/sensors.lbr",+ "pcbPath": "C:/Boards/tof-test.brd"+ },+ "input": {+ "assigns": "pad=NET;... (last part)",+ "footprintName": "str | 'A;B;C' (required)",+ "hMm": "number (new board only)",+ "layers": "2|4|6|8 (new board)",+ "libraryPath": "a .lbr on the Fusion machine (required)",+ "open": "bool, default true",+ "pcbPath": "a .brd: existing = append, new = create (default %TEMP%/adom-test-boards/<name>.brd)",+ "prefix": "designator prefix, default U",+ "stubs": "pad=x,y;... mm from the part's origin (last part): a 0.2 mm top track",+ "wMm": "number (new board only)"+ },+ "longRunning": false,+ "name": "fusion_test_board",+ "output": {+ "appended": "bool",+ "bodiesOnComp": "int",+ "count": "int (parts on the board)",+ "documentName": "str",+ "existingParts": "int",+ "padsOnComp": "int",+ "pcb": "path",+ "placed": "'U1;U2'",+ "saved": "bool (checked on disk)",+ "unknownPins": "[]"+ },+ "statusVerb": null,+ "summary": "Footprints from a .lbr on ONE board (the standard's test_board): a NEW board (wMm x hMm outline, or one that fits the row) when pcbPath is new or omitted, APPENDED right of the current parts when pcbPath is an existing .brd, designators continuing (U4...). Written as EAGLE source, opened in Fusion as a local design dialog-free, and read back. assigns and stubs apply to the LAST part placed.",+ "timeoutSeconds": 180+ }, { "example": { "dryRun": true,
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.13"+version = "1.13.14" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.13"+version = "1.13.14" edition = "2021" [dependencies]
describe.py+16@@ -386,6 +386,22 @@ _T = [ {"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_footprint_from_land_pattern', "A complete footprint from the land pattern a datasheet prints (the standard's footprint_from_land_pattern), laid out by the shared engine and written into a .lbr offline: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard (tKeepout), silkscreen kept 0.2 mm clear of copper with a pin-1 dot (tPlace), a fab outline with a pin-1 chamfer (tDocu). dryRun returns the pad table only. Preflighted before writing.",+ {"libraryPath": ".lbr on the Fusion machine (required unless dryRun; created if missing)", "name": "str (required)", "family": "chip | dual | sot | quad | bga", "pitch": "mm", "span": "mm, pad centre to pad centre across the rows", "spanY": "quad top-bottom", "padLength": "mm, along the lead", "padWidth": "mm, along the row", "pins": "dual/quad total", "pinsPerSideX": "quad", "pinsPerSideY": "quad", "leftPins": "sot", "rightPins": "sot", "rows": "bga", "cols": "bga", "ballPad": "bga mm", "missing": "bga ['B2']", "exposedPad": "{w, h, x?, y?, number?}", "body": "{w, l, height?, standoff?}", "padShape": "roundrect | rect | round | oval", "courtyard": "margin mm, default 0.25", "silkClearance": "default 0.2", "silkWidth": "default 0.15", "polarized": "chip: pin-1 dot", "description": "str", "dryRun": "bool"},+ {"footprint": "str", "pads": "int", "silkLines": "int", "fabLines": "int", "courtyardLines": "int", "padTable": "[[name, x, y, w, h]]", "courtyardMm": "[x0, y0, x1, y1]", "engine": "str"},+ 30, None, False, {"family": "dual", "name": "SOIC-8", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}, "dryRun": True}),+ ('fusion_import_part', "One call imports a whole part (the standard's import_part): the footprint spec and the symbol spec (the same files Altium's bridge takes) become a footprint, a symbol and a component in one .lbr, every pin joined to its pad by number (a repeated pad number becomes N@2... on the same pin), params and comment as attributes, preflighted before writing; a STEP is then minted and bound to the component, dialog-free. Importing again REPLACES the footprint, the symbol and the 3D body. An EAGLE symbol has one look: the spec's mode (default 0) is drawn.",+ {"libraryPath": ".lbr on the Fusion machine (pcbLib/schLib accepted: one Fusion library holds both)", "footprintSpec": "path or text: META|name|desc PAD|des|x|y|w|h|shape|hole|layer|crpct LINE ARC DISC (mm)", "symbolSpec": "path or text: META|libref|desc|designator MODE RECT PIN|mode|number|name|x|y|orient|len|hide|etype LINE ARC (mils; x,y the pin's body end)", "step": ".step path (needs a symbol; signed-in Fusion)", "footprint": "an existing footprint for the symbol when no footprintSpec", "params": "{MPN: ...}", "comment": "str", "description": "str", "mode": "symbol display mode, default 0", "dryRun": "bool"},+ {"footprint": "str", "symbol": "str", "steps": "[{build_footprint, padAliases, replaced}, {build_symbol, modesInSpec}, {add_models}, {params}, {add_body, wipUrn}]", "backup": "path"},+ 600, None, False, {"libraryPath": "C:/libs/parts.lbr", "footprintSpec": "C:/specs/fp.spec", "symbolSpec": "C:/specs/sym.spec", "params": {"MPN": "TPS7A2033"}, "dryRun": True}),+ ('fusion_test_board', "Footprints from a .lbr on ONE board (the standard's test_board): a NEW board (wMm x hMm outline, or one that fits the row) when pcbPath is new or omitted, APPENDED right of the current parts when pcbPath is an existing .brd, designators continuing (U4...). Written as EAGLE source, opened in Fusion as a local design dialog-free, and read back. assigns and stubs apply to the LAST part placed.",+ {"libraryPath": "a .lbr on the Fusion machine (required)", "footprintName": "str | 'A;B;C' (required)", "pcbPath": "a .brd: existing = append, new = create (default %TEMP%/adom-test-boards/<name>.brd)", "wMm": "number (new board only)", "hMm": "number (new board only)", "assigns": "pad=NET;... (last part)", "stubs": "pad=x,y;... mm from the part's origin (last part): a 0.2 mm top track", "prefix": "designator prefix, default U", "layers": "2|4|6|8 (new board)", "open": "bool, default true"},+ {"appended": "bool", "pcb": "path", "placed": "'U1;U2'", "count": "int (parts on the board)", "existingParts": "int", "padsOnComp": "int", "bodiesOnComp": "int", "saved": "bool (checked on disk)", "documentName": "str", "unknownPins": "[]"},+ 180, None, False, {"libraryPath": "C:/libs/sensors.lbr", "footprintName": "VL53L5CX;VL53L7CX", "pcbPath": "C:/Boards/tof-test.brd"}),+ ('fusion_build_board', "A NEW board from a parts list and a netlist (the standard's build_board): outline (rect with rounded corners, or points), every footprint from one .lbr placed by its own origin, pad nets assigned, written as EAGLE source (pcbPath overwritten), opened in Fusion as a local design dialog-free, then read back pin by pin. Footprint pad names must match the netlist's pin numbers (R1.1 = pad 1 of R1). Coordinates mm, Y up.",+ {"libraryPath": "a .lbr holding every footprint (required)", "pcbPath": "the .brd to write (required)", "parts": "[{ref, footprint, x, y, rotation, comment?}] (required)", "nets": "{net: ['R1.1', 'U1.3']}", "outline": "{rect: [x0, y0, w, h], cornerRadius?} | {points: [[x, y]...]} (default: the parts' box + 2 mm)", "layers": "2|4|6|8", "open": "bool, default true"},+ {"placed": "int", "missingParts": "[ref]", "padsWired": "int", "wrongPins": "[{pin, want, got}]", "unconnected": "int - the ratsnest to route", "counts": "{}", "revision": "str", "documentName": "str", "unknownPins": "[]"},+ 180, None, False, {"libraryPath": "C:/libs/buck.lbr", "pcbPath": "C:/Boards/buck.brd", "parts": [{"ref": "R1", "footprint": "R0603", "x": 5, "y": 5, "rotation": 0}], "nets": {"GND": ["R1.2"]}, "outline": {"rect": [0, 0, 20, 15], "cornerRadius": 1}}), ('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"},
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.13.13"+DASH_VERSION="1.13.14" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.13",+ "version": "1.13.14", "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+5−11@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.13.13",+ "version": "1.13.14", "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": {@@ -17,9 +17,7 @@ "pcb", "exports", "aps",- "modeling",- "mechanical",- "assemblies"+ "modeling" ], "org": "adom", "title": "Fusion - the Fusion 360 Bridge",@@ -92,13 +90,9 @@ "pin electrical types from a schematic", "part heights on my fusion board", "which fusion parts have no 3d model",- "will my fusion board fit the enclosure",- "build a parametric part in fusion",- "assemble mechanical parts in fusion",- "insert a McMaster part in fusion",- "generate a mechanical assembly BOM"+ "will my fusion board fit the enclosure" ],- "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, author parametric mechanical parts and internal assemblies, ingest McMaster CAD and generate mechanical BOMs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",+ "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "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.", "sample_prompts": [ {@@ -149,4 +143,4 @@ "name": "John Lauer", "email": "[email protected]" }-}+}
rust/Cargo.lock+6−6@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.17.2"+version = "0.18.0" dependencies = [ "roxmltree", "serde_json",@@ -19,7 +19,7 @@ dependencies = [ [[package]] name = "adom-fusion-bridge"-version = "1.13.13"+version = "1.13.14" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.13"+version = "1.13.14" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.13"+version = "1.13.14" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.13"+version = "1.13.14" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.13"+version = "1.13.14" dependencies = [ "fusion-core", "windows-sys 0.59.0",
rust/Cargo.toml+1−1@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"] # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.13"+version = "1.13.14" edition = "2021" license = "MIT"
rust/bridge/src/boardgen.rsadded+486@@ -0,0 +1,486 @@+//! The standard's test_board and build_board: a board written as EAGLE source from a .lbr's+//! footprints, offline, then opened in Fusion through the dialog-free Document.newDesignFromLocal+//! (as fusion_make_showcase_project does) and read back pad by pad from the live board. Fusion+//! keeps a board-only design for a .brd with no schematic beside it, so nothing is annotated.+//! Coordinates are mm, Y up, each footprint placed by its own origin.++use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::time::Duration;++use crate::addin::Ctx;+use fusion_lbr::Element;++fn f(v: f64) -> String { let s = format!("{:.4}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); if s == "-0" { "0".into() } else { s } }+fn el(tag: &str, attrs: &[(&str, String)]) -> Element { let mut e = Element::new(tag); e.attrs = attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); e }+fn num(e: &Element, k: &str) -> f64 { e.attr(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }+fn rot_deg(e: &Element) -> f64 { e.attr("rot").map(|r| r.trim_start_matches(['M', 'S']).trim_start_matches('R')).and_then(|r| r.parse().ok()).unwrap_or(0.0) }+fn deep<'a>(e: &'a Element, tag: &str) -> Option<&'a Element> { if e.tag == tag { return Some(e); } e.children.iter().find_map(|c| deep(c, tag)) }+fn child_named<'a>(e: &'a Element, tag: &str, name: &str) -> Option<&'a Element> { e.children.iter().find(|c| c.tag == tag && c.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)) }++/// One footprint to place: designator, package, origin (mm) and rotation (degrees).+#[derive(Debug, Clone)]+pub struct Placement { pub r: String, pub pkg: String, pub x: f64, pub y: f64, pub rot: f64, pub value: String }++/// What compose builds: on `base` (an existing .brd, appended to) or a new board with `outline`.+pub struct Plan<'a> {+ pub base: Option<&'a str>,+ pub lbr: &'a str,+ pub lib_name: &'a str,+ pub outline: Vec<Element>,+ pub layer_setup: &'a str,+ pub parts: Vec<Placement>,+ /// net -> [(designator, pad)]+ pub nets: BTreeMap<String, Vec<(String, String)>>,+ /// (designator, pad, dx, dy): a 0.2 mm top-copper track from the pad to the part's origin + (dx, dy)+ pub stubs: Vec<(String, String, f64, f64)>,+}++/// The packages a .lbr holds, by name.+pub fn lbr_packages(lbr: &str) -> Result<Vec<Element>, String> {+ let root = Element::parse(lbr).map_err(|e| e.to_string())?;+ let pk = deep(&root, "packages").ok_or("the library has no <packages>")?;+ Ok(pk.children.iter().filter(|c| c.tag == "package").cloned().collect())+}++/// A package's pads: (name, x, y), in the package's own frame.+fn pads_of(pkg: &Element) -> Vec<(String, f64, f64)> {+ pkg.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| Some((c.attr("name")?.to_string(), num(c, "x"), num(c, "y")))).collect()+}++/// Everything a package draws (pads, wires, circles, rectangles), unrotated: x0, y0, x1, y1.+pub fn extent(pkg: &Element) -> (f64, f64, f64, f64) {+ let mut b = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+ let mut add = |x: f64, y: f64, hw: f64, hh: f64| { b.0 = b.0.min(x - hw); b.1 = b.1.min(y - hh); b.2 = b.2.max(x + hw); b.3 = b.3.max(y + hh); };+ for c in &pkg.children {+ match c.tag.as_str() {+ "smd" => { let (w, h) = (num(c, "dx") / 2.0, num(c, "dy") / 2.0); let turned = (rot_deg(c) / 90.0).round() as i64 % 2 != 0; if turned { add(num(c, "x"), num(c, "y"), h, w) } else { add(num(c, "x"), num(c, "y"), w, h) } }+ "pad" => { let d = num(c, "diameter").max(num(c, "drill") * 1.6) / 2.0; add(num(c, "x"), num(c, "y"), d, d) }+ "wire" => { let w = num(c, "width") / 2.0; add(num(c, "x1"), num(c, "y1"), w, w); add(num(c, "x2"), num(c, "y2"), w, w) }+ "circle" => { let r = num(c, "radius") + num(c, "width") / 2.0; add(num(c, "x"), num(c, "y"), r, r) }+ "rectangle" => { add(num(c, "x1"), num(c, "y1"), 0.0, 0.0); add(num(c, "x2"), num(c, "y2"), 0.0, 0.0) }+ _ => {}+ }+ }+ if b.0 == f64::MAX { (-0.5, -0.5, 0.5, 0.5) } else { b }+}++/// The extent of a package turned by `rot` degrees (its box's corners, turned).+pub fn turned_extent(pkg: &Element, rot: f64) -> (f64, f64, f64, f64) {+ let (x0, y0, x1, y1) = extent(pkg);+ let (s, c) = rot.to_radians().sin_cos();+ let pts = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| (x * c - y * s, x * s + y * c));+ pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)))+}++/// A rectangle (with rounded corners when `r` > 0) or a closed polygon, as layer-20 wires.+pub fn outline_wires(outline: &Value) -> Result<Vec<Element>, String> {+ let w = |x1: f64, y1: f64, x2: f64, y2: f64, curve: Option<f64>| { let mut e = el("wire", &[("x1", f(x1)), ("y1", f(y1)), ("x2", f(x2)), ("y2", f(y2)), ("width", "0.1".into()), ("layer", "20".into())]); if let Some(c) = curve { e.attrs.push(("curve".into(), f(c))); } e };+ if let Some(r) = outline["rect"].as_array() {+ let g = |k: usize| r.get(k).and_then(|v| v.as_f64()).ok_or("outline.rect is [x0, y0, w, h] in mm");+ let (x0, y0, wd, ht) = (g(0)?, g(1)?, g(2)?, g(3)?);+ if wd <= 0.0 || ht <= 0.0 { return Err("outline.rect: w and h must be above 0".into()); }+ let (x1, y1) = (x0 + wd, y0 + ht);+ let cr = outline["cornerRadius"].as_f64().unwrap_or(0.0).clamp(0.0, wd.min(ht) / 2.0);+ if cr <= 0.0 { return Ok(vec![w(x0, y0, x1, y0, None), w(x1, y0, x1, y1, None), w(x1, y1, x0, y1, None), w(x0, y1, x0, y0, None)]); }+ // counter-clockwise: each edge, then a 90-degree arc round the next corner+ let mut v = Vec::new();+ for (a, b, arc_to) in [((x0 + cr, y0), (x1 - cr, y0), (x1, y0 + cr)), ((x1, y0 + cr), (x1, y1 - cr), (x1 - cr, y1)), ((x1 - cr, y1), (x0 + cr, y1), (x0, y1 - cr)), ((x0, y1 - cr), (x0, y0 + cr), (x0 + cr, y0))] {+ if (a.0 - b.0).abs() + (a.1 - b.1).abs() > 1e-9 { v.push(w(a.0, a.1, b.0, b.1, None)); }+ v.push(w(b.0, b.1, arc_to.0, arc_to.1, Some(90.0)));+ }+ return Ok(v);+ }+ if let Some(p) = outline["points"].as_array() {+ let pts: Vec<(f64, f64)> = p.iter().filter_map(|q| Some((q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect();+ if pts.len() < 3 || pts.len() != p.len() { return Err("outline.points is [[x, y], ...] in mm, three at least".into()); }+ return Ok((0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); w(a.0, a.1, b.0, b.1, None) }).collect());+ }+ Err("outline is {rect: [x0, y0, w, h], cornerRadius?} or {points: [[x, y], ...]} (mm)".into())+}++/// The board XML, and what could not be wired (pads the footprint lacks, pads already on another net).+pub fn compose(p: &Plan) -> Result<(String, Value), String> {+ let lib_pkgs = lbr_packages(p.lbr)?;+ let lib_root = Element::parse(p.lbr).map_err(|e| e.to_string())?;+ let lib_p3d: Vec<Element> = deep(&lib_root, "packages3d").map(|s| s.children.iter().filter(|c| c.tag == "package3d").cloned().collect()).unwrap_or_default();+ let skeleton;+ let text = match p.base { Some(t) => t, None => {+ skeleton = format!("{}{}<board><plain/><libraries/><attributes/><variantdefs/><classes><class number=\"0\" name=\"default\" width=\"0\" drill=\"0\"/></classes><designrules name=\"default\"><param name=\"layerSetup\" value=\"{}\"/></designrules><autorouter/><elements/><signals/></board></drawing></eagle>\n",+ crate::testproject::HEAD, crate::testproject::board_layers(), p.layer_setup);+ &skeleton+ } };+ let mut root = Element::parse(text).map_err(|e| e.to_string())?;+ let board = root.find_deep_mut("board").ok_or("not a board: no <board>")?;+ for t in ["plain", "libraries", "elements", "signals"] { if board.find_child(t).is_none() { board.children.push(Element::new(t)); } }+ // the library: the footprints the parts use (and the 3D packages that instance them)+ let mut urn: BTreeMap<String, String> = BTreeMap::new();+ let mut added = Vec::new();+ {+ let libs = board.find_child_mut("libraries").unwrap();+ if child_named(libs, "library", p.lib_name).is_none() { let mut l = el("library", &[("name", p.lib_name.to_string())]); l.children.push(Element::new("packages")); libs.children.push(l); }+ let lib = libs.children.iter_mut().find(|c| c.tag == "library" && c.attr("name").map(|n| n.eq_ignore_ascii_case(p.lib_name)).unwrap_or(false)).unwrap();+ if lib.find_child("packages").is_none() { lib.children.insert(0, Element::new("packages")); }+ for part in &p.parts {+ let src = lib_pkgs.iter().find(|k| k.attr("name").map(|n| n.eq_ignore_ascii_case(&part.pkg)).unwrap_or(false)).ok_or(format!("footprint {} is not in the library", part.pkg))?;+ let name = src.attr("name").unwrap_or("").to_string();+ let pk = lib.find_child_mut("packages").unwrap();+ if child_named(pk, "package", &name).is_none() { pk.children.push(src.clone()); added.push(name.clone()); }+ for d in &lib_p3d {+ let inst = d.find_child("packageinstances").map(|pi| pi.children.iter().any(|c| c.attr("name") == Some(name.as_str()))).unwrap_or(false);+ let u = d.attr("wip_urn").or(d.attr("urn")).unwrap_or("");+ if !inst || u.is_empty() { continue; }+ urn.insert(name.clone(), u.to_string());+ if lib.find_child("packages3d").is_none() { let at = lib.children.iter().position(|c| c.tag == "packages").map(|i| i + 1).unwrap_or(lib.children.len()); lib.children.insert(at, Element::new("packages3d")); }+ let s3 = lib.find_child_mut("packages3d").unwrap();+ if child_named(s3, "package3d", d.attr("name").unwrap_or("")).is_none() { s3.children.push(d.clone()); }+ break;+ }+ }+ }+ // every footprint on the board by designator, for pads and origins+ let mut on_board: BTreeMap<String, (Element, f64, f64, f64)> = BTreeMap::new();+ {+ let libs = board.find_child("libraries").unwrap();+ for e in board.find_child("elements").unwrap().children.iter().filter(|c| c.tag == "element") {+ let (Some(n), Some(l), Some(pk)) = (e.attr("name"), e.attr("library"), e.attr("package")) else { continue };+ if let Some(pkg) = child_named(libs, "library", l).and_then(|lb| lb.find_child("packages")).and_then(|s| child_named(s, "package", pk)) { on_board.insert(n.to_uppercase(), (pkg.clone(), num(e, "x"), num(e, "y"), rot_deg(e))); }+ }+ }+ {+ let elements = board.find_child_mut("elements").unwrap();+ for part in &p.parts {+ if on_board.contains_key(&part.r.to_uppercase()) { return Err(format!("{} is already on the board", part.r)); }+ let src = lib_pkgs.iter().find(|k| k.attr("name").map(|n| n.eq_ignore_ascii_case(&part.pkg)).unwrap_or(false)).unwrap();+ let name = src.attr("name").unwrap_or("").to_string();+ let mut e = el("element", &[("name", part.r.clone()), ("library", p.lib_name.to_string()), ("package", name.clone()), ("value", part.value.clone()), ("x", f(part.x)), ("y", f(part.y))]);+ let r = part.rot.rem_euclid(360.0);+ if r.abs() > 1e-9 { e.attrs.push(("rot".into(), format!("R{}", f(r)))); }+ if let Some(u) = urn.get(&name) { e.attrs.push(("package3d_urn".into(), u.clone())); }+ elements.children.push(e);+ on_board.insert(part.r.to_uppercase(), (src.clone(), part.x, part.y, r));+ }+ }+ let pad_at = |r: &str, pad: &str| -> Option<(f64, f64)> {+ let (pkg, x, y, rot) = on_board.get(&r.to_uppercase())?;+ let (_, px, py) = pads_of(pkg).into_iter().find(|q| q.0 == pad)?;+ let (s, c) = rot.to_radians().sin_cos();+ Some((x + px * c - py * s, y + px * s + py * c))+ };+ let mut unknown: Vec<Value> = Vec::new();+ let mut conflicts: Vec<Value> = Vec::new();+ {+ let signals = board.find_child_mut("signals").unwrap();+ // the pads each existing signal already holds+ let mut held: BTreeMap<(String, String), String> = BTreeMap::new();+ for s in signals.children.iter().filter(|c| c.tag == "signal") { for c in s.children.iter().filter(|c| c.tag == "contactref") { held.insert((c.attr("element").unwrap_or("").to_uppercase(), c.attr("pad").unwrap_or("").to_string()), s.attr("name").unwrap_or("").to_string()); } }+ for (net, pins) in &p.nets {+ for (r, pad) in pins {+ if pad_at(r, pad).is_none() { unknown.push(json!({"pin": format!("{r}.{pad}"), "net": net, "why": if on_board.contains_key(&r.to_uppercase()) { "the footprint has no pad of that name" } else { "no such part on the board" }})); continue; }+ let key = (r.to_uppercase(), pad.clone());+ match held.get(&key) {+ Some(n) if n == net => continue,+ Some(n) => { conflicts.push(json!({"pin": format!("{r}.{pad}"), "want": net, "already": n})); continue; }+ None => {}+ }+ if !signals.children.iter().any(|c| c.tag == "signal" && c.attr("name") == Some(net.as_str())) { signals.children.push(el("signal", &[("name", net.clone())])); }+ let s = signals.children.iter_mut().find(|c| c.tag == "signal" && c.attr("name") == Some(net.as_str())).unwrap();+ let real = on_board.get_key_value(&r.to_uppercase()).map(|_| r.clone()).unwrap();+ s.children.push(el("contactref", &[("element", real), ("pad", pad.clone())]));+ held.insert(key, net.clone());+ }+ }+ for (r, pad, dx, dy) in &p.stubs {+ let Some((px, py)) = pad_at(r, pad) else { unknown.push(json!({"pin": format!("{r}.{pad}"), "why": "stub: the footprint has no pad of that name"})); continue };+ let (_, ox, oy, _) = on_board.get(&r.to_uppercase()).unwrap();+ let wire = el("wire", &[("x1", f(px)), ("y1", f(py)), ("x2", f(ox + dx)), ("y2", f(oy + dy)), ("width", "0.2".into()), ("layer", "1".into())]);+ match held.get(&(r.to_uppercase(), pad.clone())).cloned() {+ Some(net) => signals.children.iter_mut().find(|c| c.tag == "signal" && c.attr("name") == Some(net.as_str())).unwrap().children.push(wire),+ None => { let mut s = el("signal", &[("name", format!("STUB_{r}_{pad}"))]); s.children.push(el("contactref", &[("element", r.clone()), ("pad", pad.clone())])); s.children.push(wire); signals.children.push(s); }+ }+ }+ }+ let mut grown = Value::Null;+ let mut outside: Vec<String> = Vec::new();+ if p.base.is_none() { board.find_child_mut("plain").unwrap().children.extend(p.outline.iter().cloned()); }+ else if !p.parts.is_empty() {+ // appended parts: a plain rectangular outline grows to hold them (2 mm round them); any other+ // shape stays, and the parts that fall outside it are named+ let need = p.parts.iter().map(|q| { let (pkg, x, y, r) = on_board.get(&q.r.to_uppercase()).unwrap(); let e = turned_extent(pkg, *r); (x + e.0, y + e.1, x + e.2, y + e.3) })+ .fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+ let plain = board.find_child_mut("plain").unwrap();+ let edges: Vec<usize> = plain.children.iter().enumerate().filter(|(_, c)| c.tag == "wire" && c.attr("layer") == Some("20")).map(|(i, _)| i).collect();+ let rect = edges.len() == 4 && edges.iter().all(|&i| { let c = &plain.children[i]; c.attr("curve").map(|v| v.parse::<f64>().unwrap_or(0.0) == 0.0).unwrap_or(true) && ((num(c, "x1") - num(c, "x2")).abs() < 1e-6 || (num(c, "y1") - num(c, "y2")).abs() < 1e-6) });+ let bb = edges.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, &i| { let c = &plain.children[i]; (a.0.min(num(c, "x1").min(num(c, "x2"))), a.1.min(num(c, "y1").min(num(c, "y2"))), a.2.max(num(c, "x1").max(num(c, "x2"))), a.3.max(num(c, "y1").max(num(c, "y2")))) });+ let fits = need.0 >= bb.0 && need.1 >= bb.1 && need.2 <= bb.2 && need.3 <= bb.3;+ if !fits && rect {+ let n = (bb.0.min(need.0 - 2.0), bb.1.min(need.1 - 2.0), bb.2.max(need.2 + 2.0), bb.3.max(need.3 + 2.0));+ let width = plain.children[edges[0]].attr("width").unwrap_or("0.1").to_string();+ let mut new_edges = outline_wires(&json!({"rect": [n.0, n.1, n.2 - n.0, n.3 - n.1]})).unwrap();+ for e in new_edges.iter_mut() { if let Some(a) = e.attrs.iter_mut().find(|a| a.0 == "width") { a.1 = width.clone(); } }+ for (k, &i) in edges.iter().enumerate() { plain.children[i] = new_edges[k].clone(); }+ grown = json!({"fromMm": [bb.0, bb.1, bb.2, bb.3], "toMm": [n.0, n.1, n.2, n.3]});+ } else if !fits && !edges.is_empty() {+ for q in &p.parts { let (pkg, x, y, r) = on_board.get(&q.r.to_uppercase()).unwrap(); let e = turned_extent(pkg, *r); if x + e.0 < bb.0 || y + e.1 < bb.1 || x + e.2 > bb.2 || y + e.3 > bb.3 { outside.push(q.r.clone()); } }+ }+ }+ Ok((root.to_xml_document(), json!({"unknownPins": unknown, "conflicts": conflicts, "packagesAdded": added, "outlineGrown": grown, "outsideOutline": outside})))+}++/// "A;B;C" or a list.+fn list(v: &Value, sep: char) -> Vec<String> {+ match v { Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect(),+ Value::String(s) => s.split(sep).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect(), _ => vec![] }+}++fn layer_setup(args: &Value) -> Result<&'static str, Value> {+ match args["layers"].as_u64().unwrap_or(2) { 2 => Ok("(1*16)"), 4 => Ok("(1+2*15+16)"), 6 => Ok("(1+2*3+14*15+16)"), 8 => Ok("(1+2*3+4*13+14*15+16)"),+ _ => Err(json!({"success": false, "errorCode": "bad_args", "error": "layers is 2, 4, 6 or 8 in Fusion"})) }+}++fn lib_name_of(path: &str) -> String {+ regex::Regex::new(r"[^A-Za-z0-9_-]").unwrap().replace_all(path.rsplit('/').next().unwrap_or("").rsplit_once('.').map(|(s, _)| s).unwrap_or("lib"), "_").to_string()+}++fn read_lbr(args: &Value) -> Result<(String, String), Value> {+ let path = args["libraryPath"].as_str().unwrap_or("").replace('\\', "/");+ if path.is_empty() { return Err(json!({"success": false, "errorCode": "bad_args", "error": "libraryPath (.lbr on the Fusion machine) is required", "_hint": "A Fusion or EAGLE library: fusion_create_footprint and fusion_footprint_from_land_pattern write one."})); }+ match std::fs::read(&path) { Ok(b) => Ok((path, String::from_utf8_lossy(&b).to_string())), Err(e) => Err(json!({"success": false, "errorCode": "library_unreadable", "error": format!("{path}: {e}")})) }+}++/// Open the written board in Fusion (a new local design, dialog-free) and read it back.+fn open_and_read(ctx: &Ctx, path: &str, pass: &dyn Fn(&str, Value) -> Value) -> Result<(String, Value), Value> {+ let stem = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let fp = crate::fusionpath::for_fusion(&ctx.state_dir, path).unwrap_or_else(|_| path.to_string());+ let r = pass("execute_text_command", json!({"command": format!("Document.newDesignFromLocal {fp}")}));+ let mut doc = String::new();+ for _ in 0..12 {+ let a = pass("get_app_state", json!({}))["data"]["activeDocument"].as_str().unwrap_or("").to_string();+ if a.to_lowercase().contains(&stem.to_lowercase()) { doc = a; break; }+ std::thread::sleep(Duration::from_secs(3));+ }+ if doc.is_empty() { return Err(json!({"success": false, "errorCode": "open_failed", "error": format!("Fusion did not open {path}: {}", r["error"].as_str().unwrap_or("no document came up")), "pcbPath": path, "saved": true})); }+ crate::chains::note_bridge_doc_pub(&ctx.state_dir, &stem);+ let _ = pass("execute_text_command", json!({"command": "Electron.run WINDOW FIT;"}));+ let st = crate::routing::call("routing_state", &json!({"expectDocument": doc}), pass);+ if st["success"].as_bool() == Some(false) { return Err(json!({"success": false, "errorCode": "readback_failed", "error": st["error"], "documentName": doc, "pcbPath": path, "saved": true})); }+ Ok((doc, st))+}++fn write(path: &str, xml: &str) -> Result<(), Value> {+ if let Some(d) = std::path::Path::new(path).parent() { let _ = std::fs::create_dir_all(d); }+ std::fs::write(path, xml).map_err(|e| json!({"success": false, "errorCode": "not_saved", "error": format!("{path}: {e}")}))+}++/// Pins as the board reads them: "REF.PAD" -> net ("" for none).+fn board_pins(st: &Value) -> BTreeMap<String, String> {+ st["pads"].as_array().into_iter().flatten().filter_map(|p| Some((format!("{}.{}", p["ref"].as_str()?.to_uppercase(), p["name"].as_str()?), p["net"].as_str().unwrap_or("").to_string()))).collect()+}++/// `fusion_test_board`: footprints from a .lbr on one board, a new one (wMm x hMm) when pcbPath is+/// new or omitted, appended right of the current parts when pcbPath is an existing .brd.+pub fn test_board(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (lbr_path, lbr) = match read_lbr(args) { Ok(x) => x, Err(e) => return e };+ let names = list(&args["footprintName"], ';');+ if names.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "footprintName is required: one name or 'A;B;C'"}); }+ let pkgs = match lbr_packages(&lbr) { Ok(p) => p, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e}) };+ let find = |n: &str| pkgs.iter().find(|p| p.attr("name").map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false));+ let missing: Vec<&String> = names.iter().filter(|n| find(n).is_none()).collect();+ if !missing.is_empty() { return json!({"success": false, "errorCode": "footprint_not_found", "missing": missing, "available": pkgs.iter().filter_map(|p| p.attr("name")).collect::<Vec<_>>(), "error": "some footprints are not in the library"}); }+ let setup = match layer_setup(args) { Ok(s) => s, Err(e) => return e };+ let lib_name = lib_name_of(&lbr_path);+ let pcb = args["pcbPath"].as_str().filter(|s| !s.is_empty()).map(|s| s.replace('\\', "/"))+ .unwrap_or_else(|| std::env::temp_dir().join("adom-test-boards").join(format!("{}.brd", lib_name_of(&format!("{}.x", names[0])))).to_string_lossy().replace('\\', "/"));+ if !pcb.to_lowercase().ends_with(".brd") { return json!({"success": false, "errorCode": "bad_args", "error": "pcbPath is an EAGLE board (.brd)"}); }+ let base = if args["pcbPath"].is_string() { std::fs::read_to_string(&pcb).ok() } else { None };+ let appended = base.is_some();+ let prefix = args["prefix"].as_str().unwrap_or("U").to_string();+ let gap = 3.0;+ // where the row starts, and the next designator number+ let (mut cx, mut row_y, mut next, existing) = match &base {+ Some(t) => {+ let root = match Element::parse(t) { Ok(r) => r, Err(e) => return json!({"success": false, "errorCode": "board_unreadable", "error": e.to_string()}) };+ let board = deep(&root, "board");+ let libs = board.and_then(|b| b.find_child("libraries"));+ let els: Vec<&Element> = board.and_then(|b| b.find_child("elements")).map(|e| e.children.iter().filter(|c| c.tag == "element").collect()).unwrap_or_default();+ let mut right = f64::MIN; let mut y = 0.0;+ for e in &els {+ let ext = libs.and_then(|l| child_named(l, "library", e.attr("library").unwrap_or(""))).and_then(|l| l.find_child("packages")).and_then(|s| child_named(s, "package", e.attr("package").unwrap_or(""))).map(|p| turned_extent(p, rot_deg(e))).unwrap_or((0.0, 0.0, 0.0, 0.0));+ if num(e, "x") + ext.2 > right { right = num(e, "x") + ext.2; y = num(e, "y"); }+ }+ let re = regex::Regex::new(&format!(r"^{}(\d+)$", regex::escape(&prefix))).unwrap();+ let n = els.iter().filter_map(|e| re.captures(e.attr("name")?).and_then(|c| c[1].parse::<u32>().ok())).max().unwrap_or(0);+ (if right == f64::MIN { gap } else { right + gap }, y, n + 1, els.len())+ }+ None => (gap, 0.0, 1, 0),+ };+ let mut parts = Vec::new();+ let mut tallest = 0.0f64;+ for n in &names {+ let pkg = find(n).unwrap();+ let (x0, y0, x1, y1) = extent(pkg);+ tallest = tallest.max(y1 - y0);+ parts.push(Placement { r: format!("{prefix}{next}"), pkg: pkg.attr("name").unwrap_or(n).to_string(), x: cx - x0, y: 0.0, rot: 0.0, value: pkg.attr("name").unwrap_or(n).to_string() });+ parts.last_mut().unwrap().y = -(y0 + y1) / 2.0; // centred on the row, fixed below+ cx += (x1 - x0) + gap; next += 1;+ }+ // a new board: the given size, else one that fits the row+ let (bw, bh) = (args["wMm"].as_f64().unwrap_or(cx), args["hMm"].as_f64().unwrap_or(tallest + 2.0 * gap + 2.0));+ if !appended { row_y = bh / 2.0; }+ for p in parts.iter_mut() { p.y += row_y; }+ let last = parts.last().unwrap().r.clone();+ let mut nets: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();+ for a in list(&args["assigns"], ';') { if let Some((pad, net)) = a.split_once('=') { nets.entry(net.trim().to_string()).or_default().push((last.clone(), pad.trim().to_string())); } }+ let mut stubs = Vec::new();+ for s in list(&args["stubs"], ';') {+ let Some((pad, at)) = s.split_once('=') else { continue };+ let v: Vec<f64> = at.split(',').filter_map(|t| t.trim().parse().ok()).collect();+ if v.len() == 2 { stubs.push((last.clone(), pad.trim().to_string(), v[0], v[1])); } else { return json!({"success": false, "errorCode": "bad_args", "error": format!("stubs: want pad=x,y (mm from the part's origin), got {s}")}); }+ }+ let outline = if appended { vec![] } else { match outline_wires(&json!({"rect": [0, 0, bw, bh]})) { Ok(o) => o, Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}) } };+ let placed: Vec<String> = parts.iter().map(|p| p.r.clone()).collect();+ let plan = Plan { base: base.as_deref(), lbr: &lbr, lib_name: &lib_name, outline, layer_setup: setup, parts, nets, stubs };+ let (xml, report) = match compose(&plan) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "build_refused", "error": e}) };+ if let Err(e) = write(&pcb, &xml) { return e; }+ let saved = std::fs::read_to_string(&pcb).map(|t| placed.iter().all(|r| t.contains(&format!("name=\"{r}\"")))).unwrap_or(false);+ let mut out = json!({"success": true, "mutated": true, "appended": appended, "pcb": pcb, "placed": placed.join(";"), "existingParts": existing, "saved": saved,+ "unknownPins": report["unknownPins"], "conflicts": report["conflicts"], "outlineGrown": report["outlineGrown"], "outsideOutline": report["outsideOutline"], "boardMm": if appended { Value::Null } else { json!([bw, bh]) }});+ if args["open"].as_bool() == Some(false) { out["count"] = json!(existing + placed.len()); out["_hint"] = json!("Written, not opened (open:false). fusion_test_board again with this pcbPath appends."); return out; }+ match open_and_read(ctx, &pcb, pass) {+ Err(e) => { let mut e = e; e["pcb"] = json!(pcb); e }+ Ok((doc, st)) => {+ let comps = st["components"].as_array().cloned().unwrap_or_default();+ let lc = comps.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(&last)).unwrap_or(false));+ out["documentName"] = json!(doc); out["count"] = json!(comps.len()); out["revision"] = st["revision"].clone();+ out["padsOnComp"] = json!(st["pads"].as_array().map(|a| a.iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(&last)).unwrap_or(false)).count()).unwrap_or(0));+ out["bodiesOnComp"] = json!(if lc.map(|c| c["has3d"] == true).unwrap_or(false) { 1 } else { 0 });+ out["_hint"] = json!(format!("{} on the board, open in Fusion as {doc} (a local design; fusion_save_to_cloud keeps it). Run it again with this pcbPath to add parts: they land right of these, designators continue. fusion_show_3d_board for the bodies.", placed.join(", ")));+ out+ }+ }+}++/// `fusion_build_board`: a NEW board from a parts list and a netlist: outline, every footprint from+/// one .lbr placed by its origin, pad nets assigned, written, opened, then read back pin by pin.+pub fn build_board(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let (lbr_path, lbr) = match read_lbr(args) { Ok(x) => x, Err(e) => return e };+ let pcb = args["pcbPath"].as_str().unwrap_or("").replace('\\', "/");+ if !pcb.to_lowercase().ends_with(".brd") { return json!({"success": false, "errorCode": "bad_args", "error": "pcbPath (the .brd to write) is required"}); }+ let Some(list_) = args["parts"].as_array().filter(|a| !a.is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "parts [{ref, footprint, x, y, rotation, comment?}] is required"}) };+ let setup = match layer_setup(args) { Ok(s) => s, Err(e) => return e };+ let pkgs = match lbr_packages(&lbr) { Ok(p) => p, Err(e) => return json!({"success": false, "errorCode": "library_unreadable", "error": e}) };+ let mut parts = Vec::new();+ let mut missing = Vec::new();+ for (i, p) in list_.iter().enumerate() {+ let (Some(r), Some(fp)) = (p["ref"].as_str(), p["footprint"].as_str()) else { return json!({"success": false, "errorCode": "bad_args", "error": format!("parts[{i}]: ref and footprint are required")}) };+ let Some(pkg) = pkgs.iter().find(|k| k.attr("name").map(|n| n.eq_ignore_ascii_case(fp)).unwrap_or(false)) else { missing.push(json!({"ref": r, "footprint": fp})); continue };+ parts.push(Placement { r: r.to_string(), pkg: pkg.attr("name").unwrap_or(fp).to_string(), x: p["x"].as_f64().unwrap_or(0.0), y: p["y"].as_f64().unwrap_or(0.0), rot: p["rotation"].as_f64().unwrap_or(0.0), value: p["comment"].as_str().unwrap_or(fp).to_string() });+ }+ if !missing.is_empty() { return json!({"success": false, "errorCode": "footprint_not_found", "missing": missing, "available": pkgs.iter().filter_map(|p| p.attr("name")).collect::<Vec<_>>(), "error": "a footprint is not in the library: nothing was built"}); }+ let mut seen = std::collections::HashSet::new();+ if let Some(d) = parts.iter().find(|p| !seen.insert(p.r.to_uppercase())) { return json!({"success": false, "errorCode": "bad_args", "error": format!("{} is in parts twice", d.r)}); }+ let mut nets: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();+ for (net, pins) in args["nets"].as_object().cloned().unwrap_or_default() {+ for pin in list(&pins, ',') {+ let Some((r, pad)) = pin.rsplit_once('.') else { return json!({"success": false, "errorCode": "bad_args", "error": format!("nets.{net}: pins are REF.PAD, got {pin}")}) };+ nets.entry(net.clone()).or_default().push((r.to_string(), pad.to_string()));+ }+ }+ // no outline given: the parts' box and 2 mm+ let outline_v = if args["outline"].is_object() { args["outline"].clone() } else {+ let b = parts.iter().map(|p| { let pk = pkgs.iter().find(|k| k.attr("name") == Some(p.pkg.as_str())).unwrap(); let e = turned_extent(pk, p.rot); (p.x + e.0, p.y + e.1, p.x + e.2, p.y + e.3) })+ .fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+ json!({"rect": [b.0 - 2.0, b.1 - 2.0, b.2 - b.0 + 4.0, b.3 - b.1 + 4.0]})+ };+ let outline = match outline_wires(&outline_v) { Ok(o) => o, Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}) };+ let want: Vec<(String, String)> = nets.iter().flat_map(|(n, pins)| pins.iter().map(move |(r, p)| (format!("{}.{p}", r.to_uppercase()), n.clone()))).collect();+ let refs: Vec<String> = parts.iter().map(|p| p.r.clone()).collect();+ let plan = Plan { base: None, lbr: &lbr, lib_name: &lib_name_of(&lbr_path), outline, layer_setup: setup, parts, nets, stubs: vec![] };+ let (xml, report) = match compose(&plan) { Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "build_refused", "error": e}) };+ if let Err(e) = write(&pcb, &xml) { return e; }+ let mut out = json!({"success": true, "mutated": true, "pcbPath": pcb, "outline": outline_v, "unknownPins": report["unknownPins"], "conflicts": report["conflicts"]});+ if args["open"].as_bool() == Some(false) { out["_hint"] = json!("Written, not opened (open:false): no read back."); return out; }+ let (doc, st) = match open_and_read(ctx, &pcb, pass) { Ok(x) => x, Err(e) => return e };+ let got = board_pins(&st);+ let on: std::collections::HashSet<String> = st["components"].as_array().into_iter().flatten().filter_map(|c| c["ref"].as_str().map(str::to_uppercase)).collect();+ let missing_parts: Vec<&String> = refs.iter().filter(|r| !on.contains(&r.to_uppercase())).collect();+ let mut wrong = Vec::new(); let mut wired = 0;+ for (pin, net) in &want {+ match got.get(pin) { Some(g) if g == net => wired += 1, g => wrong.push(json!({"pin": pin, "want": net, "got": g})) }+ }+ out["documentName"] = json!(doc); out["revision"] = st["revision"].clone(); out["counts"] = st["counts"].clone();+ out["placed"] = json!(refs.len() - missing_parts.len()); out["missingParts"] = json!(missing_parts); out["padsWired"] = json!(wired); out["wrongPins"] = json!(wrong);+ out["unconnected"] = st["unconnected"].clone();+ out["_hint"] = json!(if wrong.is_empty() && missing_parts.is_empty() { format!("Built and read back pin by pin: every pad on its net. {} connections to route (fusion_plan_route / fusion_route_net, or fusion_autoroute). The board is a local design: fusion_save_to_cloud keeps it.", st["unconnected"]) }+ else { "Read back differs: wrongPins lists each pad with the net asked for and the one the board has; a footprint's pad names must match the netlist's pin numbers (R1.1 = pad 1 of R1).".to_string() });+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ const LBR: &str = r#"<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE eagle SYSTEM "eagle.dtd">+<eagle version="9.6.2"><drawing><layers><layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/></layers><library><packages>+<package name="R0603"><smd name="1" x="-0.8" y="0" dx="0.9" dy="0.9" layer="1"/><smd name="2" x="0.8" y="0" dx="0.9" dy="0.9" layer="1"/></package>+<package name="SOT23"><smd name="1" x="-1.1" y="0.95" dx="1" dy="0.6" layer="1"/><smd name="2" x="-1.1" y="-0.95" dx="1" dy="0.6" layer="1"/><smd name="3" x="1.1" y="0" dx="1" dy="0.6" layer="1"/></package>+</packages></library></drawing></eagle>"#;++ fn plan<'a>(base: Option<&'a str>, parts: Vec<Placement>, nets: &[(&str, &[(&str, &str)])]) -> Plan<'a> {+ Plan { base, lbr: LBR, lib_name: "demo", outline: outline_wires(&json!({"rect": [0, 0, 20, 10], "cornerRadius": 1})).unwrap(), layer_setup: "(1*16)", parts,+ nets: nets.iter().map(|(n, p)| (n.to_string(), p.iter().map(|(r, q)| (r.to_string(), q.to_string())).collect())).collect(), stubs: vec![] }+ }+ fn part(r: &str, pkg: &str, x: f64, y: f64, rot: f64) -> Placement { Placement { r: r.into(), pkg: pkg.into(), x, y, rot, value: pkg.into() } }++ #[test]+ fn a_board_from_a_netlist_carries_parts_nets_and_a_rounded_outline() {+ let (xml, rep) = compose(&plan(None, vec![part("R1", "R0603", 5.0, 5.0, 90.0), part("Q1", "sot23", 12.0, 5.0, 0.0)], &[("GND", &[("R1", "1"), ("Q1", "2")]), ("X", &[("R1", "9"), ("U9", "1")])])).unwrap();+ assert!(xml.contains(r#"<element name="R1" library="demo" package="R0603" value="R0603" x="5" y="5" rot="R90" />"#), "{xml}");+ assert!(xml.contains(r#"package="SOT23""#));+ assert!(xml.contains(r#"<signal name="GND"><contactref element="R1" pad="1" /><contactref element="Q1" pad="2" /></signal>"#), "{xml}");+ assert_eq!(rep["unknownPins"].as_array().unwrap().len(), 2);+ assert_eq!(xml.matches(r#"curve="90""#).count(), 4);+ assert_eq!(xml.matches(r#"layer="20""#).count(), 8);+ assert!(xml.contains(r#"<param name="layerSetup" value="(1*16)" />"#));+ // the footprint copies once into the board's library+ assert_eq!(xml.matches(r#"<package name="R0603">"#).count(), 1);+ }++ #[test]+ fn appending_keeps_the_board_and_refuses_a_pad_on_two_nets() {+ let (first, _) = compose(&plan(None, vec![part("U1", "R0603", 3.0, 5.0, 0.0)], &[("A", &[("U1", "1")])])).unwrap();+ let (second, rep) = compose(&plan(Some(&first), vec![part("U2", "SOT23", 9.0, 5.0, 0.0)], &[("B", &[("U1", "1"), ("U2", "3")])])).unwrap();+ assert!(second.contains(r#"<element name="U1""#) && second.contains(r#"<element name="U2""#));+ assert_eq!(second.matches(r#"curve="90""#).count(), 4, "the outline is not drawn twice");+ // a rounded outline is not a plain rectangle: it stays, and a part off it is named+ let (_, far) = compose(&plan(Some(&first), vec![part("U9", "R0603", 40.0, 5.0, 0.0)], &[])).unwrap();+ assert_eq!(far["outsideOutline"][0], "U9");+ let mut sq = plan(None, vec![part("U1", "R0603", 3.0, 5.0, 0.0)], &[]);+ sq.outline = outline_wires(&json!({"rect": [0, 0, 10, 10]})).unwrap();+ let (sqx, _) = compose(&sq).unwrap();+ let (grown, rep2) = compose(&plan(Some(&sqx), vec![part("U2", "SOT23", 12.0, 5.0, 0.0)], &[])).unwrap();+ assert_eq!(rep2["outlineGrown"]["toMm"][2].as_f64().unwrap(), 12.0 + 1.6 + 2.0, "{rep2}");+ assert_eq!(grown.matches(r#"layer="20""#).count(), 4);+ assert_eq!(rep["conflicts"][0]["already"], "A");+ assert!(second.contains(r#"<signal name="B"><contactref element="U2" pad="3" /></signal>"#));+ assert!(compose(&plan(Some(&second), vec![part("U2", "R0603", 0.0, 0.0, 0.0)], &[])).is_err());+ }++ #[test]+ fn a_stub_runs_from_the_turned_pad_on_its_net() {+ let mut p = plan(None, vec![part("U1", "R0603", 10.0, 5.0, 90.0)], &[("N", &[("U1", "2")])]);+ p.stubs = vec![("U1".into(), "2".into(), 0.0, 3.0)];+ let (xml, _) = compose(&p).unwrap();+ // pad 2 at (0.8, 0) turned 90 degrees lands at (10, 5.8)+ assert!(xml.contains(r#"<wire x1="10" y1="5.8" x2="10" y2="8" width="0.2" layer="1" />"#), "{xml}");+ }++ #[test]+ fn outlines_refuse_bad_shapes() {+ assert!(outline_wires(&json!({"rect": [0, 0, 0, 5]})).is_err());+ assert!(outline_wires(&json!({"points": [[0, 0], [1, 0]]})).is_err());+ assert_eq!(outline_wires(&json!({"points": [[0, 0], [4, 0], [4, 3]]})).unwrap().len(), 3);+ assert_eq!(turned_extent(&lbr_packages(LBR).unwrap()[0], 90.0).3.round(), 1.0);+ }+}
rust/bridge/src/checks.rs+2−1@@ -5,7 +5,8 @@ //! places (place_planned_vias: read back, and undone on a mismatch) unless dryRun. use serde_json::{json, Value}; -pub fn owns(cmd: &str) -> bool { adom_eda_checks::CHECKS.contains(&cmd) }+// footprint_from_land_pattern needs no board: it writes a .lbr offline (main.rs native_lib_authoring)+pub fn owns(cmd: &str) -> bool { cmd != "footprint_from_land_pattern" && adom_eda_checks::CHECKS.contains(&cmd) } /// A pcb-design-rules record for a fab (adom/pcb-design-rules rules/records/<vendor>.json): the /// record for the board's copper layer count, or the asked process.
rust/bridge/src/main.rs+25−4@@ -35,6 +35,8 @@ mod prove; mod services; mod maintenance; mod testproject;+mod boardgen;+mod partimport; mod livefeed; mod chainexec; mod epg;@@ -554,9 +556,9 @@ 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_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 },+ 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_test_board" | "test_board" | "fusion_build_board" | "build_board" | "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), "test_board" => small_verbs::merge_dialog_array(boardgen::test_board(&ctx, &parsed["args"], &pass), &caller, &pids), "build_board" => small_verbs::merge_dialog_array(boardgen::build_board(&ctx, &parsed["args"], &pass), &caller, &pids), "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_make_3d_package" | "make_3d_package" | "fusion_build_library_3d" | "build_library_3d" | "fusion_bind_library_3d" | "bind_library_3d" | "fusion_prove_library" | "prove_library" | "fusion_attach_3d_package" | "attach_3d_package" | "fusion_show_3d_package" | "show_3d_package" | "fusion_import_part" | "import_part") if addin_owner_is_exe(&**h) => { let (sc, ca, ca2) = (sidecar.clone(), caller.clone(), caller.clone()); let side = move |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command": verb, "args": a}).to_string(), &ca, t); let sd = h.bridge_state_dir(); let pids: Vec<u32> = h.fusion_processes().iter().map(|p| p.pid).collect(); let dlg = |r: Value| small_verbs::merge_dialog_array(r, &ca2, &pids); let ctx = addin::Ctx { host: &**h, fusion_exe: h.fusion_executable(), short_path: |p| host().short_path(p), state_dir: sd.clone(), caller: caller.clone(), pids: pids.clone(), sidecar: &side, dialogs: &dlg }; let cmd = v.strip_prefix("fusion_").unwrap_or(v); let t0 = Instant::now(); *LONG_OP.lock().unwrap() = Some(cmd.to_string()); *LONG_OP_SINCE.lock().unwrap() = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); let pass = |verb: &str, a: Value| addin::pass_through(verb, &a, &ctx); let r0 = match cmd { "make_3d_package" => mint::make_3d_package(&ctx, &parsed["args"], &pass), "bind_library_3d" => mint::bind_library_3d(&ctx, &parsed["args"], &pass), "prove_library" => prove::prove_library(&ctx, &parsed["args"], &pass), "attach_3d_package" => mint::attach_3d_package(&ctx, &parsed["args"], &pass), "show_3d_package" => mint::show_3d_package(&ctx, &parsed["args"], &pass), "import_part" => partimport::import_part(&ctx, &parsed["args"], &pass), _ => mint::build_library_3d(&ctx, &parsed["args"], &pass) }; *LONG_OP.lock().unwrap() = None; let mut r = small_verbs::apply_failure_dialogs(r0, &caller, &pids); timings::log_run(&sd, cmd, t0.elapsed().as_secs_f64(), r["success"].as_bool() != Some(false)); r["shell"] = json!("rust"); r }, v @ ("fusion_apply_fusion_update" | "apply_fusion_update" | "fusion_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), "signin" => { let readiness = || owned_readiness(&**h, &sidecar, &caller); let start = || launch::start(&ctx, &json!({})); let aps_state = || { let st = native_aps_status(&sidecar, &caller); (st["data"]["configured"].as_bool().unwrap_or(false), st["data"]["signedIn"].as_bool().unwrap_or(false)) }; let aps_signin = |p: &str| aps_signin::signin(&ctx, &json!({"profile": p})); signin::signin(&ctx, &parsed["args"], &signin::Env { readiness: &readiness, start: &start, aps_state: &aps_state, aps_signin: &aps_signin }) }, "signin_2fa" => signin::signin_2fa(&ctx, &parsed["args"]), "aps_signin" => aps_signin::signin(&ctx, &parsed["args"]),@@ -680,7 +682,7 @@ fn main() { "fusion_edit_footprint" | "edit_footprint" => native_edit_footprint(&parsed["args"]), v @ ("fusion_create_footprint" | "create_footprint" | "fusion_create_symbol" | "create_symbol" | "fusion_read_symbol" | "read_symbol" | "fusion_find_symbol" | "find_symbol" | "fusion_copy_footprint" | "copy_footprint" | "fusion_rename_footprint" | "rename_footprint" | "fusion_remove_footprint" | "remove_footprint" | "fusion_xcopy_footprint" | "xcopy_footprint"- | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]),+ | "fusion_clone_symbol" | "clone_symbol" | "fusion_rename_symbol" | "rename_symbol" | "fusion_remove_symbol" | "remove_symbol" | "fusion_set_params" | "set_params" | "fusion_footprint_from_land_pattern" | "footprint_from_land_pattern") => native_lib_authoring(v.strip_prefix("fusion_").unwrap_or(v), &parsed["args"]), "fusion_preflight_lbr" | "preflight_lbr" => native_preflight_lbr(&parsed["args"]), // Native since Phase 2: the raw authenticated APS GET (never-charge guarded). "fusion_aps_get" | "aps_get" => native_aps_get(&parsed["args"]),@@ -1002,9 +1004,23 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value { r["success"] = json!(true); r["searched"] = json!(libs.len()); r["shell"] = json!("rust"); return r; }+ // the land pattern is laid out by the standard's engine; this writes it as an EAGLE package+ let mut land = Value::Null;+ if verb == "footprint_from_land_pattern" {+ land = adom_eda_checks::run("footprint_from_land_pattern", &json!({"args": args}));+ if land["success"].as_bool() != Some(true) {+ let mut r = land.clone(); r["shell"] = json!("rust");+ if r["_hint"].is_null() { r["_hint"] = json!("Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span, padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."); }+ return r;+ }+ if args["dryRun"].as_bool() == Some(true) {+ return json!({"success": true, "mutated": false, "dryRun": true, "name": land["name"], "description": land["description"], "padTable": land["padTable"], "courtyardMm": land["courtyardMm"], "pads": land["pads"], "lines": land["lines"].as_array().map(|a| a.len()), "engine": land["engine"], "shell": "rust",+ "_hint": "Nothing written. Check padTable (name, x, y, w, h in mm, Y up, pin 1 top-left) against the datasheet, then call again without dryRun and with libraryPath."});+ }+ } let path = match s(if verb == "xcopy_footprint" { "toLib" } else { "libraryPath" }) { Some(p) => p.replace('\\', "/"), None => return json!({"success": false, "errorCode": "bad_args", "error": format!("{} is required", if verb == "xcopy_footprint" { "toLib (the destination .lbr)" } else { "libraryPath" }), "shell": "rust"}) }; let existing = std::fs::read_to_string(&path).ok();- let creates = matches!(verb, "create_footprint" | "create_symbol" | "xcopy_footprint");+ let creates = matches!(verb, "create_footprint" | "create_symbol" | "xcopy_footprint" | "footprint_from_land_pattern"); if existing.is_none() && !creates { return json!({"success": false, "errorCode": "library_unreadable", "error": format!("cannot read {path}"), "libraryPath": path, "shell": "rust"}); } if verb == "read_symbol" { let name = s("symbolName").or(s("name")).unwrap_or_default();@@ -1014,6 +1030,11 @@ fn native_lib_authoring(verb: &str, args: &Value) -> Value { let r: Result<(String, Value), String> = match verb { "create_footprint" => a::create_footprint(text.as_deref(), args), "create_symbol" => a::create_symbol(text.as_deref(), args),+ "footprint_from_land_pattern" => a::create_from_layout(text.as_deref(), land["name"].as_str().unwrap_or(""), land["description"].as_str().unwrap_or(""), &land).map(|(t, mut v)| {+ for k in ["padTable", "courtyardMm", "engine"] { v[k] = land[k].clone(); }+ if land["body"]["height"].is_number() { v["body3d"] = json!({"attached": false, "next": "fusion_generate_body {family, ...} writes the STEP; fusion_attach_3d_package binds it to this footprint"}); }+ (t, v)+ }), "copy_footprint" => a::copy_footprint(text.as_deref().unwrap(), None, &s("from").unwrap_or_default(), &s("to").unwrap_or_default()), "xcopy_footprint" => { let from_lib = s("fromLib").unwrap_or_default().replace('\\', "/");
rust/bridge/src/partimport.rsadded+59@@ -0,0 +1,59 @@+//! `fusion_import_part`: the standard's import_part. The footprint and symbol specs (the same files+//! Altium's bridge takes) go into one .lbr offline (fusion_lbr::import), preflighted before it is+//! written; a STEP is then minted and bound to the component by bind_library_3d, dialog-free.++use serde_json::{json, Value};++use crate::addin::Ctx;++/// A spec given as a path on this machine, or as the spec text itself.+fn spec(args: &Value, key: &str) -> Result<Option<String>, Value> {+ let Some(v) = args[key].as_str().filter(|s| !s.trim().is_empty()) else { return Ok(None) };+ if v.contains('|') && v.contains('\n') { return Ok(Some(v.to_string())); }+ std::fs::read_to_string(v.replace('\\', "/")).map(Some).map_err(|e| json!({"success": false, "errorCode": "spec_unreadable", "error": format!("{key} {v}: {e}"), "_hint": "A spec file on the Fusion machine, or the spec text itself (META|... lines)."}))+}++pub fn import_part(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+ let s = |k: &str| args[k].as_str().filter(|v| !v.is_empty()).map(|v| v.replace('\\', "/"));+ // a Fusion library holds the footprint and the symbol together: one file+ let path = match (s("libraryPath"), s("pcbLib"), s("schLib")) {+ (Some(p), _, _) => p,+ (None, Some(a), Some(b)) if !a.eq_ignore_ascii_case(&b) => return json!({"success": false, "errorCode": "bad_args", "error": "pcbLib and schLib differ: a Fusion library (.lbr) holds the footprint and the symbol together, so pass one libraryPath"}),+ (None, Some(a), _) | (None, None, Some(a)) => a,+ _ => return json!({"success": false, "errorCode": "bad_args", "error": "libraryPath (a .lbr on the Fusion machine) is required"}),+ };+ let (fp, sym) = match (spec(args, "footprintSpec"), spec(args, "symbolSpec")) { (Ok(a), Ok(b)) => (a, b), (Err(e), _) | (_, Err(e)) => return e };+ if fp.is_none() && sym.is_none() && !args["params"].is_object() {+ return json!({"success": false, "errorCode": "nothing_to_import", "error": "import_part was given nothing to import: pass footprintSpec and/or symbolSpec, optionally step and params"});+ }+ let existing = std::fs::read_to_string(&path).ok();+ let (text, mut summary) = match fusion_lbr::import::import_part(existing.as_deref(), fp.as_deref(), sym.as_deref(), s("footprint").as_deref(), args) {+ Ok(x) => x, Err(e) => return json!({"success": false, "errorCode": "import_refused", "error": e, "libraryPath": path}),+ };+ let tmp = std::env::temp_dir().join(format!("adom-import-part-{}.lbr", std::process::id()));+ let _ = std::fs::write(&tmp, &text);+ let (ok, errors) = fusion_lbr::authoring::preflight_text(&tmp);+ let _ = std::fs::remove_file(&tmp);+ if !ok { return json!({"success": false, "errorCode": "lbr_preflight_failed", "error": "the library would not open cleanly in Fusion; nothing was written", "preflight": errors, "summary": summary}); }+ if args["dryRun"].as_bool() == Some(true) { return json!({"success": true, "mutated": false, "dryRun": true, "libraryPath": path, "footprint": summary["footprint"], "symbol": summary["symbol"], "steps": summary["steps"]}); }+ if let Some(d) = std::path::Path::new(&path).parent() { let _ = std::fs::create_dir_all(d); }+ let backup = existing.as_ref().map(|_| { let b = format!("{path}.bak"); let _ = std::fs::copy(&path, &b); b });+ if let Err(e) = std::fs::write(&path, &text) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{path}: {e}")}); }+ let mut out = json!({"success": true, "mutated": true, "libraryPath": path, "backup": backup, "footprint": summary["footprint"], "symbol": summary["symbol"]});+ if let Some(step) = s("step") {+ let r = match summary["symbol"].as_str() {+ // the 3D package binds to a component's footprint: a footprint alone has none to bind to.+ // force: importing again replaces the body, the way the footprint and symbol are replaced+ None => json!({"success": false, "error": "no component in this import: the 3D package binds through a component (pass symbolSpec)"}),+ Some(ds) => crate::mint::bind_library_3d(ctx, &json!({"libraryPath": path, "stepPath": step, "deviceset": ds, "force": true}), pass),+ };+ let okb = r["success"].as_bool() == Some(true);+ summary["steps"].as_array_mut().unwrap().push(json!({"add_body": okb, "error": r["error"], "wipUrn": r["wip_urn"]}));+ if !okb { out["success"] = json!(false); out["errorCode"] = json!("add_body_failed"); out["failedAt"] = json!("add_body"); out["error"] = json!(format!("the library is written, but the 3D body did not bind: {}", r["error"].as_str().unwrap_or("?"))); }+ }+ out["steps"] = summary["steps"].clone();+ if out["success"] == true {+ out["_hint"] = json!("Written and preflighted. Importing again replaces the footprint and the component of these names. Place it with fusion_sch_place_part from a design holding the library, or see it with fusion_show_symbol / fusion_show_footprint.");+ }+ out+}
rust/bridge/src/testproject.rs+13−7@@ -36,7 +36,7 @@ const R_SYM: &str = r#"<symbol name="ADOMR"><wire x1="-2.54" y1="1.016" x2="2.54 const R_PKG: &str = r#"<package name="ADOMR0603"><description>Resistor 0603</description><smd name="1" x="-0.85" y="0" dx="1" dy="1" layer="1"/><smd name="2" x="0.85" y="0" dx="1" dy="1" layer="1"/><wire x1="-0.4" y1="0.5" x2="0.4" y2="0.5" width="0.127" layer="21"/><wire x1="-0.4" y1="-0.5" x2="0.4" y2="-0.5" width="0.127" layer="21"/><text x="-1" y="0.8" size="0.8" layer="25">>NAME</text></package>"#; const R_DS: &str = r#"<deviceset name="ADOMR" prefix="R"><description>generated test resistor</description><gates><gate name="G$1" symbol="ADOMR" x="0" y="0"/></gates><devices><device name="" package="ADOMR0603"><connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects><technologies><technology name=""/></technologies></device></devices></deviceset>"#; const SCH_LAYERS: &str = r#"<layers><layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/><layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/><layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/><layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/><layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/><layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/><layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/><layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/></layers>"#;-const HEAD: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n<eagle version=\"9.6.2\">\n<drawing>\n<settings><setting alwaysvectorfont=\"no\"/></settings><grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>";+pub(crate) const HEAD: &str = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<!DOCTYPE eagle SYSTEM \"eagle.dtd\">\n<eagle version=\"9.6.2\">\n<drawing>\n<settings><setting alwaysvectorfont=\"no\"/></settings><grid distance=\"0.1\" unitdist=\"inch\" unit=\"inch\" style=\"lines\" multiple=\"1\" display=\"no\" altdistance=\"0.01\" altunitdist=\"inch\" altunit=\"inch\"/>"; /// `_pkg_extent_mm`: everything a package draws, on any layer. pub fn pkg_extent_mm(p: &str) -> (f64, f64, f64, f64) {@@ -304,6 +304,17 @@ mod tests { } +/// A board's <layers>: the sixteen copper layers, EAGLE's board layers and the schematic ones.+pub(crate) fn board_layers() -> String {+ 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>");+ brd_layers+}+ /// 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@@ -326,12 +337,7 @@ pub fn project_create(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Val 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 brd_layers = board_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}")}); }
rust/lbr/src/authoring.rs+118−7@@ -17,11 +17,11 @@ pub fn new_library(description: &str) -> String { description.replace('&', "&").replace('<', "<")) } -fn f(v: f64) -> String { let s = format!("{:.4}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); if s == "-0" { "0".into() } else { s } }-fn el(tag: &str, attrs: &[(&str, String)]) -> Element { let mut e = Element::new(tag); e.attrs = attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); e }-fn set(e: &mut Element, k: &str, v: &str) { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v.to_string(), None => e.attrs.push((k.into(), v.into())) } }-fn lib_mut(root: &mut Element) -> Result<&mut Element, String> { root.find_deep_mut("library").ok_or_else(|| "not a library: no <library>".to_string()) }-fn section<'a>(lib: &'a mut Element, tag: &str) -> &'a mut Element {+pub(crate) fn f(v: f64) -> String { let s = format!("{:.4}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); if s == "-0" { "0".into() } else { s } }+pub(crate) fn el(tag: &str, attrs: &[(&str, String)]) -> Element { let mut e = Element::new(tag); e.attrs = attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); e }+pub(crate) fn set(e: &mut Element, k: &str, v: &str) { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v.to_string(), None => e.attrs.push((k.into(), v.into())) } }+pub(crate) fn lib_mut(root: &mut Element) -> Result<&mut Element, String> { root.find_deep_mut("library").ok_or_else(|| "not a library: no <library>".to_string()) }+pub(crate) fn section<'a>(lib: &'a mut Element, tag: &str) -> &'a mut Element { if lib.find_child(tag).is_none() { // EAGLE order inside <library>: description?, packages, packages3d?, symbols, devicesets let order = ["description", "packages", "packages3d", "symbols", "devicesets"];@@ -31,8 +31,8 @@ fn section<'a>(lib: &'a mut Element, tag: &str) -> &'a mut Element { } lib.find_child_mut(tag).unwrap() }-fn names(sec: &Element, tag: &str) -> Vec<String> { sec.children.iter().filter(|c| c.tag == tag).filter_map(|c| c.attr("name").map(str::to_string)).collect() }-fn find_ci<'a>(list: &'a [String], want: &str) -> Option<&'a String> { list.iter().find(|n| *n == want).or_else(|| list.iter().find(|n| n.eq_ignore_ascii_case(want))) }+pub(crate) fn names(sec: &Element, tag: &str) -> Vec<String> { sec.children.iter().filter(|c| c.tag == tag).filter_map(|c| c.attr("name").map(str::to_string)).collect() }+pub(crate) fn find_ci<'a>(list: &'a [String], want: &str) -> Option<&'a String> { list.iter().find(|n| *n == want).or_else(|| list.iter().find(|n| n.eq_ignore_ascii_case(want))) } /// Pads as the standard writes them: "des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;..." (hole 0 = SMD), /// or a JSON list of {number, at:[x,y] mm, sizeMm:[w,h], shape, drillMm}.@@ -113,6 +113,85 @@ pub fn create_footprint(xml: Option<&str>, args: &Value) -> Result<(String, Valu Ok((root.to_xml_document(), json!({"footprints": made, "libraryState": state}))) } +/// A package from a laid-out land pattern (the shape the standard's footprint_from_land_pattern+/// returns): pads {name, x, y, w, h, shape, hole} in mm, Y up; lines {layer: overlay|fab|courtyard,+/// x1, y1, x2, y2, width}; discs {layer, x, y, r}. Silk goes on tPlace (21), fab on tDocu (51), the+/// courtyard on tKeepout (39), which is where read_footprint looks for it.+pub fn create_from_layout(xml: Option<&str>, name: &str, description: &str, layout: &Value) -> Result<(String, Value), String> {+ let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));+ let mut root = Element::parse(&text).map_err(|e| e.to_string())?;+ let layer = |l: &str| -> Result<&'static str, String> { match l { "overlay" | "silk" => Ok("21"), "fab" | "balls" => Ok("51"), "courtyard" => Ok("39"), "comment" | "document" => Ok("48"), "copper" | "top" => Ok("1"), o => Err(format!("unknown layer {o}")) } };+ let g = |v: &Value, k: &str| v[k].as_f64().unwrap_or(0.0);+ let pads = layout["pads"].as_array().filter(|a| !a.is_empty()).ok_or("the layout has no pads")?;+ let mut pkg = el("package", &[("name", name.to_string())]);+ if !description.is_empty() { let mut de = Element::new("description"); de.text = description.into(); pkg.children.push(de); }+ let mut seen: std::collections::BTreeMap<String, Vec<String>> = Default::default();+ for p in pads {+ let mut n = p["name"].as_str().map(str::to_string).or(p["name"].as_u64().map(|n| n.to_string())).ok_or("a pad has no name")?;+ // EAGLE pad names are unique; a spec that repeats one (the heatsink-via convention) gets+ // N@2, N@3 ..., which a device connects to the same pin+ let seen_n = seen.entry(n.clone()).or_default();+ if !seen_n.is_empty() {+ if layout["repeatedPads"].as_bool() != Some(true) { return Err(format!("two pads are numbered {n}")); }+ n = format!("{n}@{}", seen_n.len() + 1);+ }+ seen_n.push(n.clone());+ let (x, y, w, h, hole) = (g(p, "x"), g(p, "y"), g(p, "w"), g(p, "h"), g(p, "hole"));+ let shape = p["shape"].as_str().unwrap_or("roundrect");+ let e = if hole > 0.0 {+ let mut e = el("pad", &[("name", n), ("x", f(x)), ("y", f(y)), ("drill", f(hole)), ("diameter", f(w.min(h)))]);+ if (w - h).abs() > 1e-6 { e.attrs.push(("shape".into(), "long".into())); if h > w { e.attrs.push(("rot".into(), "R90".into())); } }+ else if shape != "round" { e.attrs.push(("shape".into(), "square".into())); }+ e+ } else {+ let mut e = el("smd", &[("name", n), ("x", f(x)), ("y", f(y)), ("dx", f(w)), ("dy", f(h)), ("layer", "1".into())]);+ // the engine's cornerRatio (0.25 for roundrect) is EAGLE's roundness in percent+ let round = match (shape, p["cornerRatio"].as_f64()) { ("round" | "oval", _) => 100, (_, Some(c)) if c > 0.0 => (c * 100.0).round() as i64, ("roundrect", _) => 25, _ => 0 };+ if round > 0 { e.attrs.push(("roundness".into(), round.to_string())); }+ e+ };+ pkg.children.push(e);+ }+ let mut per_layer: std::collections::BTreeMap<&str, usize> = Default::default();+ for l in layout["lines"].as_array().into_iter().flatten() {+ let ly = layer(l["layer"].as_str().unwrap_or(""))?;+ *per_layer.entry(ly).or_default() += 1;+ pkg.children.push(el("wire", &[("x1", f(g(l, "x1"))), ("y1", f(g(l, "y1"))), ("x2", f(g(l, "x2"))), ("y2", f(g(l, "y2"))), ("width", f(g(l, "width").max(0.01))), ("layer", ly.into())]));+ }+ for a in layout["arcs"].as_array().into_iter().flatten() {+ let ly = layer(a["layer"].as_str().unwrap_or(""))?;+ *per_layer.entry(ly).or_default() += 1;+ let (cx, cy, r, w, a0, a1) = (g(a, "cx"), g(a, "cy"), g(a, "r"), g(a, "width").max(0.01), g(a, "a0"), g(a, "a1"));+ let sweep = a1 - a0;+ if sweep.abs() >= 359.999 { pkg.children.push(el("circle", &[("x", f(cx)), ("y", f(cy)), ("radius", f(r)), ("width", f(w)), ("layer", ly.into())])); continue; }+ let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+ let (p0, p1) = (pt(a0), pt(a1));+ pkg.children.push(el("wire", &[("x1", f(p0.0)), ("y1", f(p0.1)), ("x2", f(p1.0)), ("y2", f(p1.1)), ("width", f(w)), ("layer", ly.into()), ("curve", f(sweep))]));+ }+ for d in layout["discs"].as_array().into_iter().flatten() {+ let ly = layer(d["layer"].as_str().unwrap_or(""))?;+ // a filled dot: EAGLE draws a circle of width 0 filled+ pkg.children.push(el("circle", &[("x", f(g(d, "x"))), ("y", f(g(d, "y"))), ("radius", f(g(d, "r"))), ("width", "0".into()), ("layer", ly.into())]));+ }+ // >NAME above and >VALUE below whatever the package covers+ let (mut y0, mut y1, mut x0) = (f64::MAX, f64::MIN, f64::MAX);+ for p in pads { y0 = y0.min(g(p, "y") - g(p, "h") / 2.0); y1 = y1.max(g(p, "y") + g(p, "h") / 2.0); x0 = x0.min(g(p, "x") - g(p, "w") / 2.0); }+ for l in layout["lines"].as_array().into_iter().flatten() { for k in ["1", "2"] { y0 = y0.min(g(l, &format!("y{k}"))); y1 = y1.max(g(l, &format!("y{k}"))); x0 = x0.min(g(l, &format!("x{k}"))); } }+ let mut tn = el("text", &[("x", f(x0)), ("y", f(y1 + 0.3)), ("size", "1".into()), ("layer", "25".into())]); tn.text = ">NAME".into();+ let mut tv = el("text", &[("x", f(x0)), ("y", f(y0 - 1.3)), ("size", "1".into()), ("layer", "27".into())]); tv.text = ">VALUE".into();+ pkg.children.push(tn); pkg.children.push(tv);+ {+ let lib = lib_mut(&mut root)?;+ let sec = section(lib, "packages");+ if find_ci(&names(sec, "package"), name).is_some() { return Err(format!("footprint {name} already exists (rename_footprint, remove_footprint, or another name)")); }+ sec.children.push(pkg);+ }+ let lines = |k: &str| per_layer.get(k).copied().unwrap_or(0);+ let aliases: std::collections::BTreeMap<String, Vec<String>> = seen.into_iter().filter(|(_, v)| v.len() > 1).collect();+ Ok((root.to_xml_document(), json!({"footprint": name, "pads": pads.len(), "silkLines": lines("21"), "fabLines": lines("51"), "courtyardLines": lines("39"), "padAliases": aliases,+ "libraryState": if xml.is_some() { "opened" } else { "created" }})))+}+ fn direction_for(name: &str, hidden: bool) -> &'static str { let u = name.to_uppercase(); if hidden || ["GND", "VCC", "VDD", "VSS", "VEE", "V+", "V-", "AGND", "DGND", "PGND", "VIN", "VBAT", "3V3", "5V"].iter().any(|p| u == *p || u.starts_with(&format!("{p}@"))) { "pwr" } else { "pas" }@@ -462,3 +541,35 @@ pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) { && !e.starts_with("connects-resolve: no connects at all") && !e.starts_with("packages-nonempty")).collect(); (errs.is_empty(), errs) }++#[cfg(test)]+mod layout_tests {+ use super::*;++ #[test]+ fn a_laid_out_land_pattern_becomes_a_package_that_preflights() {+ let layout = json!({+ "pads": [{"name": "1", "x": -2.7, "y": 0.635, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0},+ {"name": "2", "x": 2.7, "y": 0.635, "w": 1.55, "h": 0.6, "shape": "rect", "hole": 0},+ {"name": "3", "x": 0, "y": 0, "w": 1.7, "h": 1.7, "shape": "round", "hole": 0.8}],+ "lines": [{"layer": "overlay", "x1": -1.0, "y1": 2.5, "x2": 1.0, "y2": 2.5, "width": 0.15},+ {"layer": "fab", "x1": -1.95, "y1": 2.45, "x2": 1.95, "y2": 2.45, "width": 0.1},+ {"layer": "courtyard", "x1": -3.8, "y1": 2.8, "x2": 3.8, "y2": 2.8, "width": 0.05}],+ "discs": [{"layer": "overlay", "x": -3.9, "y": 0.635, "r": 0.15}]});+ let (xml, s) = create_from_layout(None, "SOIC-TEST", "test", &layout).unwrap();+ assert_eq!((s["pads"].as_u64(), s["silkLines"].as_u64(), s["fabLines"].as_u64(), s["courtyardLines"].as_u64()), (Some(3), Some(1), Some(1), Some(1)));+ assert!(xml.contains(r#"<smd name="1" x="-2.7" y="0.635" dx="1.55" dy="0.6" layer="1" roundness="25" />"#), "{xml}");+ assert!(xml.contains(r#"<smd name="2" x="2.7" y="0.635" dx="1.55" dy="0.6" layer="1" />"#));+ assert!(xml.contains(r#"<pad name="3" x="0" y="0" drill="0.8" diameter="1.7" />"#));+ assert!(xml.contains(r#"<circle x="-3.9" y="0.635" radius="0.15" width="0" layer="21" />"#));+ assert!(xml.contains(r#"layer="39" />"#) && xml.contains(r#"layer="51" />"#));+ let p = std::env::temp_dir().join(format!("adom-layout-test-{}.lbr", std::process::id()));+ std::fs::write(&p, &xml).unwrap();+ let (ok, errs) = preflight_text(&p);+ let _ = std::fs::remove_file(&p);+ assert!(ok, "{errs:?}");+ // the same name twice is refused+ assert!(create_from_layout(Some(&xml), "soic-test", "", &layout).is_err());+ assert!(create_from_layout(None, "X", "", &json!({"pads": [], "lines": []})).is_err());+ }+}
rust/lbr/src/import.rsadded+237@@ -0,0 +1,237 @@+//! The standard's import_part on EAGLE .lbr text, offline: a footprint spec and a symbol spec (the+//! files Altium's bridge takes, so one spec serves both) become a package, a symbol and a deviceset+//! whose device joins every pin to its pad by number, with the part's parameters as attributes.+//! Importing again replaces the footprint and the component of the same names.+//!+//! Footprint spec (mm, Y up): META|name|desc PAD|des|x|y|w|h|shape|hole|layer|crpct+//! LINE|layer|x1|y1|x2|y2|w ARC|layer|cx|cy|r|w|a0|a1 DISC|layer|cx|cy|r+//! Symbol spec (mils, Y up): META|libref|desc|designator MODE|i|name RECT|mode|x1|y1|x2|y2+//! PIN|mode|number|name|x|y|orient|len|hide|etype LINE|mode|x1|y1|x2|y2 ARC|mode|cx|cy|r|a0|a1+//! A PIN's x,y is its body end and orient the way it runs out (left|right|up|down); EAGLE puts a+//! pin at its outer end. An EAGLE symbol has one look, so only one display mode is drawn.++use crate::authoring::{create_from_layout, el, f, find_ci, lib_mut, names, new_library, section};+use crate::Element;+use serde_json::{json, Value};++const MIL: f64 = 0.0254;++fn fields(line: &str) -> Vec<&str> { line.split('|').map(str::trim).collect() }+fn num(v: &[&str], k: usize) -> f64 { v.get(k).and_then(|t| t.parse().ok()).unwrap_or(0.0) }++/// A footprint spec as the layout create_from_layout writes: (name, description, layout).+pub fn parse_footprint_spec(text: &str) -> Result<(String, String, Value), String> {+ let (mut name, mut desc) = (String::new(), String::new());+ let (mut pads, mut lines, mut arcs, mut discs) = (vec![], vec![], vec![], vec![]);+ for (i, raw) in text.lines().enumerate() {+ let v = fields(raw);+ match v[0] {+ "META" => { name = v.get(1).unwrap_or(&"").to_string(); desc = v.get(2).unwrap_or(&"").to_string(); }+ "PAD" => {+ if v.len() < 9 { return Err(format!("footprint spec line {}: PAD|des|x|y|w|h|shape|hole|layer|crpct, got {raw}", i + 1)); }+ let crp = num(&v, 9);+ pads.push(json!({"name": v[1], "x": num(&v, 2), "y": num(&v, 3), "w": num(&v, 4), "h": num(&v, 5), "shape": v[6], "hole": num(&v, 7), "cornerRatio": crp / 100.0}));+ }+ "LINE" => lines.push(json!({"layer": v.get(1).unwrap_or(&""), "x1": num(&v, 2), "y1": num(&v, 3), "x2": num(&v, 4), "y2": num(&v, 5), "width": num(&v, 6)})),+ "ARC" => arcs.push(json!({"layer": v.get(1).unwrap_or(&""), "cx": num(&v, 2), "cy": num(&v, 3), "r": num(&v, 4), "width": num(&v, 5), "a0": num(&v, 6), "a1": num(&v, 7)})),+ "DISC" => discs.push(json!({"layer": v.get(1).unwrap_or(&""), "x": num(&v, 2), "y": num(&v, 3), "r": num(&v, 4)})),+ "" => {}+ o => return Err(format!("footprint spec line {}: unknown kind {o}", i + 1)),+ }+ }+ if name.is_empty() { return Err("footprint spec: no META|name line".into()); }+ if pads.is_empty() { return Err(format!("footprint spec {name}: no PAD lines")); }+ Ok((name, desc, json!({"pads": pads, "lines": lines, "arcs": arcs, "discs": discs, "repeatedPads": true})))+}++/// One symbol pin, in EAGLE's terms.+#[derive(Debug, Clone)]+pub struct SymPin { pub number: String, pub name: String, pub x: f64, pub y: f64, pub rot: &'static str, pub length: &'static str, pub direction: &'static str, pub hidden: bool }++/// The symbol spec's one display mode: (libref, description, designator, body graphics, pins, modes in the spec).+pub fn parse_symbol_spec(text: &str, mode: &str) -> Result<(String, String, String, Vec<Element>, Vec<SymPin>, usize), String> {+ let (mut libref, mut desc, mut des) = (String::new(), String::new(), "U".to_string());+ let mut graphics = Vec::new(); let mut pins = Vec::new(); let mut modes = 0usize;+ let wire = |x1: f64, y1: f64, x2: f64, y2: f64| el("wire", &[("x1", f(x1 * MIL)), ("y1", f(y1 * MIL)), ("x2", f(x2 * MIL)), ("y2", f(y2 * MIL)), ("width", "0.254".into()), ("layer", "94".into())]);+ for (i, raw) in text.lines().enumerate() {+ let v = fields(raw);+ let mine = v.get(1).map(|m| *m == mode).unwrap_or(false);+ match v[0] {+ "META" => { libref = v.get(1).unwrap_or(&"").to_string(); desc = v.get(2).unwrap_or(&"").to_string(); if let Some(d) = v.get(3).filter(|d| !d.is_empty()) { des = d.to_string(); } }+ "MODE" => modes += 1,+ "RECT" if mine => { let (a, b, c, d) = (num(&v, 2), num(&v, 3), num(&v, 4), num(&v, 5)); graphics.extend([wire(a, b, c, b), wire(c, b, c, d), wire(c, d, a, d), wire(a, d, a, b)]); }+ "LINE" if mine => graphics.push(wire(num(&v, 2), num(&v, 3), num(&v, 4), num(&v, 5))),+ "ARC" if mine => {+ let (cx, cy, r, a0, a1) = (num(&v, 2), num(&v, 3), num(&v, 4), num(&v, 5), num(&v, 6));+ if (a1 - a0).abs() >= 359.999 { graphics.push(el("circle", &[("x", f(cx * MIL)), ("y", f(cy * MIL)), ("radius", f(r * MIL)), ("width", "0.254".into()), ("layer", "94".into())])); }+ else {+ let pt = |d: f64| ((cx + r * d.to_radians().cos()) * MIL, (cy + r * d.to_radians().sin()) * MIL);+ let (p0, p1) = (pt(a0), pt(a1));+ graphics.push(el("wire", &[("x1", f(p0.0)), ("y1", f(p0.1)), ("x2", f(p1.0)), ("y2", f(p1.1)), ("width", "0.254".into()), ("layer", "94".into()), ("curve", f(a1 - a0))]));+ }+ }+ "PIN" if mine => {+ if v.len() < 8 { return Err(format!("symbol spec line {}: PIN|mode|number|name|x|y|orient|len, got {raw}", i + 1)); }+ let len_mil = num(&v, 7);+ let (bx, by) = (num(&v, 4), num(&v, 5));+ // outward from the body end to the connection point, and EAGLE's turn for a pin there+ let (dx, dy, rot) = match v[6].to_lowercase().as_str() { "left" | "180" => (-1.0, 0.0, "R0"), "right" | "0" => (1.0, 0.0, "R180"), "up" | "90" => (0.0, 1.0, "R270"), "down" | "270" => (0.0, -1.0, "R90"),+ o => return Err(format!("symbol spec line {}: orient {o} is left, right, up or down", i + 1)) };+ let length = if len_mil < 50.0 { "point" } else if len_mil < 150.0 { "short" } else if len_mil < 250.0 { "middle" } else { "long" };+ let el_len = match length { "point" => 0.0, "short" => 100.0, "middle" => 200.0, _ => 300.0 };+ let direction = match v.get(9).map(|t| t.to_lowercase()).unwrap_or_default().as_str() {+ "input" => "in", "output" => "out", "bidirectional" => "io", "tri_state" => "hiz", "power_in" => "pwr", "power_out" => "sup", "open_collector" | "open_emitter" => "oc", "no_connect" => "nc", _ => "pas" };+ pins.push(SymPin { number: v[2].to_string(), name: v[3].to_string(), x: (bx + dx * el_len) * MIL, y: (by + dy * el_len) * MIL, rot, length, direction, hidden: v.get(8).map(|h| !h.is_empty()).unwrap_or(false) });+ }+ "" | "RECT" | "LINE" | "ARC" | "PIN" => {}+ o => return Err(format!("symbol spec line {}: unknown kind {o}", i + 1)),+ }+ }+ if libref.is_empty() { return Err("symbol spec: no META|libref line".into()); }+ if pins.is_empty() { return Err(format!("symbol spec {libref}: no PIN lines in mode {mode}")); }+ Ok((libref, desc, des, graphics, pins, modes.max(1)))+}++fn remove_named(sec: &mut Element, tag: &str, name: &str) -> bool {+ let before = sec.children.len();+ sec.children.retain(|c| !(c.tag == tag && c.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)));+ before != sec.children.len()+}++/// The standard's import_part: footprint and/or symbol from their specs into one .lbr, the device+/// joining pins to pads, params as attributes. `footprint` names an existing package when no+/// footprint spec is given. Returns the new text and the steps.+pub fn import_part(xml: Option<&str>, fp_spec: Option<&str>, sym_spec: Option<&str>, footprint: Option<&str>, args: &Value) -> Result<(String, Value), String> {+ if fp_spec.is_none() && sym_spec.is_none() && !args["params"].is_object() { return Err("nothing to import: footprintSpec and/or symbolSpec (optionally params)".into()); }+ let mut text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));+ let mut steps = Vec::new();+ let mut fp_name = footprint.map(str::to_string);+ if let Some(spec) = fp_spec {+ let (name, desc, layout) = parse_footprint_spec(spec)?;+ // importing again replaces the footprint of that name (its devices keep pointing at it)+ let mut root = Element::parse(&text).map_err(|e| e.to_string())?;+ let replaced = remove_named(section(lib_mut(&mut root)?, "packages"), "package", &name);+ text = root.to_xml_document();+ let (t, s) = create_from_layout(Some(&text), &name, &desc, &layout)?;+ text = t;+ steps.push(json!({"build_footprint": name, "pads": s["pads"], "padAliases": s["padAliases"], "replaced": replaced}));+ fp_name = Some(name);+ }+ let mut sym_name: Option<String> = None;+ if let Some(spec) = sym_spec {+ let mode = args["mode"].as_u64().unwrap_or(0).to_string();+ let (libref, desc, des, graphics, pins, modes) = parse_symbol_spec(spec, &mode)?;+ let mut root = Element::parse(&text).map_err(|e| e.to_string())?;+ let lib = lib_mut(&mut root)?;+ // the package's pads, with repeated numbers' aliases (N, N@2 ...)+ let pads: Vec<String> = match &fp_name {+ Some(n) => {+ let sec = section(lib, "packages");+ let pk = sec.children.iter().find(|c| c.tag == "package" && c.attr("name").map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false)).ok_or(format!("footprint {n} is not in the library"))?;+ pk.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| c.attr("name").map(str::to_string)).collect()+ }+ None => vec![],+ };+ let replaced = remove_named(section(lib, "devicesets"), "deviceset", &libref);+ if replaced || find_ci(&names(section(lib, "symbols"), "symbol"), &libref).is_some() {+ let used_elsewhere = section(lib, "devicesets").children.iter().any(|d| d.find_child("gates").map(|g| g.children.iter().any(|x| x.attr("symbol").map(|s| s.eq_ignore_ascii_case(&libref)).unwrap_or(false))).unwrap_or(false));+ if used_elsewhere { return Err(format!("symbol {libref} is used by another component: import under another name")); }+ remove_named(section(lib, "symbols"), "symbol", &libref);+ }+ let mut sym = el("symbol", &[("name", libref.clone())]);+ sym.children.extend(graphics);+ let (mut x0, mut y0, mut y1) = (f64::MAX, f64::MAX, f64::MIN);+ for p in &pins { x0 = x0.min(p.x); y0 = y0.min(p.y); y1 = y1.max(p.y); }+ let mut tn = el("text", &[("x", f(x0)), ("y", f(y1 + 1.27)), ("size", "1.778".into()), ("layer", "95".into())]); tn.text = ">NAME".into();+ let mut tv = el("text", &[("x", f(x0)), ("y", f(y0 - 3.81)), ("size", "1.778".into()), ("layer", "96".into())]); tv.text = ">VALUE".into();+ sym.children.push(tn); sym.children.push(tv);+ let mut used: std::collections::HashMap<String, usize> = Default::default();+ let mut connects: Vec<(String, String)> = Vec::new();+ let mut no_pad: Vec<String> = Vec::new();+ for p in &pins {+ // EAGLE pin names are unique words: blanks take the number, repeats take @n+ let base: String = if p.name.is_empty() || p.name == "~" { p.number.clone() } else { p.name.replace(' ', "_") };+ let c = used.entry(base.to_uppercase()).or_insert(0); *c += 1;+ let pname = if *c > 1 { format!("{base}@{c}") } else { base };+ let mut pe = el("pin", &[("name", pname.clone()), ("x", f(p.x)), ("y", f(p.y)), ("length", p.length.into()), ("direction", p.direction.into())]);+ if p.rot != "R0" { pe.attrs.push(("rot".into(), p.rot.into())); }+ if p.hidden { pe.attrs.push(("visible".into(), "off".into())); }+ sym.children.push(pe);+ if fp_name.is_some() {+ let mine: Vec<&String> = pads.iter().filter(|q| *q == &p.number || q.starts_with(&format!("{}@", p.number))).collect();+ if mine.is_empty() { no_pad.push(format!("{} ({})", p.number, pname)); } else { connects.push((pname, mine.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "))); }+ }+ }+ if !no_pad.is_empty() { return Err(format!("pins with no pad of that number on {}: {}", fp_name.clone().unwrap_or_default(), no_pad.join(", "))); }+ section(lib, "symbols").children.push(sym);+ let mut ds = el("deviceset", &[("name", libref.clone()), ("prefix", des.clone())]);+ let d = args["description"].as_str().filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(desc);+ if !d.is_empty() { let mut de = Element::new("description"); de.text = d; ds.children.push(de); }+ let mut gates = Element::new("gates"); gates.children.push(el("gate", &[("name", "G$1".into()), ("symbol", libref.clone()), ("x", "0".into()), ("y", "0".into())])); ds.children.push(gates);+ let mut devs = Element::new("devices");+ let mut dev = el("device", &[("name", "".into())]);+ if let Some(n) = &fp_name {+ dev.attrs.push(("package".into(), n.clone()));+ let mut cs = Element::new("connects");+ for (pin, pad) in &connects { cs.children.push(el("connect", &[("gate", "G$1".into()), ("pin", pin.clone()), ("pad", pad.clone())])); }+ dev.children.push(cs);+ }+ let mut techs = Element::new("technologies"); techs.children.push(el("technology", &[("name", "".into())])); dev.children.push(techs);+ devs.children.push(dev); ds.children.push(devs);+ section(lib, "devicesets").children.push(ds);+ text = root.to_xml_document();+ steps.push(json!({"build_symbol": libref, "pins": pins.len(), "mode": mode, "modesInSpec": modes, "replaced": replaced}));+ steps.push(json!({"add_models": connects.len(), "footprint": fp_name}));+ sym_name = Some(libref);+ }+ let comment = args["comment"].as_str().filter(|s| !s.is_empty());+ if let (Some(sym), true) = (&sym_name, args["params"].is_object() || comment.is_some()) {+ let (t, s) = crate::authoring::set_params(&text, Some(sym), &args["params"], comment, None)?;+ text = t;+ steps.push(json!({"params": s["paramsSet"]}));+ }+ Ok((text, json!({"footprint": fp_name, "symbol": sym_name, "steps": steps})))+}++#[cfg(test)]+mod tests {+ use super::*;++ const FP: &str = "META|SOT23-X|three pins\nPAD|1|-1.1|0.95|1|0.6|roundrect|0|top|25\nPAD|2|-1.1|-0.95|1|0.6|rect|0|top|0\nPAD|3|1.1|0|1|0.6|rect|0|top|0\nPAD|3|1.1|0.5|0.4|0.4|round|0.3|multi|0\nLINE|overlay|-0.5|1.6|0.5|1.6|0.15\nARC|fab|0|0|0.3|0.1|0|360\nDISC|overlay|-1.9|0.95|0.15\n";+ const SYM: &str = "META|LDO-X|a regulator|U\nMODE|0|std\nRECT|0|0|-300|600|0\nPIN|0|1|VIN|0|-100|left|300||power_in\nPIN|0|2|GND|0|-200|left|300||power_in\nPIN|0|3|VOUT|600|-100|right|300||power_out\nMODE|1|alt\nPIN|1|1|VIN|0|0|left|300||\n";++ #[test]+ fn a_part_from_its_specs_joins_every_pin_to_its_pads() {+ let (xml, r) = import_part(None, Some(FP), Some(SYM), None, &json!({"params": {"MPN": "LDO-X-123"}, "comment": "3V3 LDO"})).unwrap();+ assert_eq!(r["footprint"], "SOT23-X"); assert_eq!(r["symbol"], "LDO-X");+ assert_eq!(r["steps"][0]["padAliases"]["3"], json!(["3", "3@2"]));+ // a repeated pad number: both pads on pin 3+ assert!(xml.contains(r#"<connect gate="G$1" pin="VOUT" pad="3 3@2" />"#), "{xml}");+ assert!(xml.contains(r#"<connect gate="G$1" pin="VIN" pad="1" />"#));+ // body end (0, -100 mil) running left 300 mil: EAGLE's pin sits at the outer end, turned R0+ assert!(xml.contains(r#"<pin name="VIN" x="-7.62" y="-2.54" length="long" direction="pwr" />"#), "{xml}");+ assert!(xml.contains(r#"<pin name="VOUT" x="22.86" y="-2.54" length="long" direction="sup" rot="R180" />"#));+ assert!(xml.contains(r#"<pad name="3@2""#) && xml.contains(r#"<circle x="0" y="0" radius="0.3" width="0.1" layer="51" />"#));+ assert!(xml.contains(r#"name="MPN" value="LDO-X-123""#), "{xml}");+ assert_eq!(r["steps"][1]["modesInSpec"], 2);+ let p = std::env::temp_dir().join(format!("adom-import-test-{}.lbr", std::process::id()));+ std::fs::write(&p, &xml).unwrap();+ let (ok, errs) = crate::authoring::preflight_text(&p);+ let _ = std::fs::remove_file(&p);+ assert!(ok, "{errs:?}");+ // importing again replaces, not stacks+ let (again, r2) = import_part(Some(&xml), Some(FP), Some(SYM), None, &json!({})).unwrap();+ assert_eq!(again.matches(r#"<package name="SOT23-X">"#).count(), 1);+ assert_eq!(again.matches(r#"<deviceset name="LDO-X""#).count(), 1);+ assert_eq!(again.matches(r#"<symbol name="LDO-X">"#).count(), 1);+ assert_eq!((r2["steps"][0]["replaced"].as_bool(), r2["steps"][1]["replaced"].as_bool()), (Some(true), Some(true)));+ }++ #[test]+ fn a_pin_with_no_pad_and_an_empty_call_are_refused() {+ let sym = SYM.replace("PIN|0|3|VOUT", "PIN|0|9|VOUT");+ assert!(import_part(None, Some(FP), Some(&sym), None, &json!({})).unwrap_err().contains("9 (VOUT)"));+ assert!(import_part(None, None, None, None, &json!({})).is_err());+ assert!(parse_footprint_spec("PAD|1|0|0|1|1|rect|0|top|0").is_err());+ }+}
rust/lbr/src/lbr_template_head.xml+2@@ -40,6 +40,8 @@ <layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/> <layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/> <layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>+<layer number="51" name="tDocu" color="7" fill="1" visible="yes" active="yes"/>+<layer number="52" name="bDocu" color="7" fill="1" visible="yes" active="yes"/> <layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/> <layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/> <layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>
rust/lbr/src/lib.rs+1@@ -7,6 +7,7 @@ //! Python bridge is the production oracle for the whole port. pub mod authoring;+pub mod import; use std::collections::BTreeMap; #[derive(Debug, thiserror::Error)]
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.17.2"+version = "0.18.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+2−1@@ -23,6 +23,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read | | `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments | | `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |+| `footprint_from_land_pattern` | args only | a footprint laid out from a datasheet's recommended land pattern (`family` chip, dual, sot, quad or bga, with pitch, span, pad size, `exposedPad`, `body`): pads numbered the standard way (pin 1 top-left, counterclockwise; BGA rows without I, O, Q, S, X, Z), silkscreen (`overlay`) kept `silkClearance` from copper with a pin-1 dot, a `fab` outline with a pin-1 chamfer and the `courtyard` grown by its margin, in mm, Y up, origin at the centre. A missing number is refused by name. Each bridge writes it in its own library format. From the Altium bridge's footprint.rs | | `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body | | `pin_pad_check` | board, sheetParts | each symbol's pins against its footprint's pads: pins with no pad (a net that can never reach copper), pads with no pin (mounting and exposed pads excepted), parts only on the sheets or only on the board. From the Altium bridge's schreview.rs | | `place_decoupling` | board, bodies? | positions for an IC's decoupling caps (`ic`, `caps?`, `gapMm` 0.3): each just outside the IC's box on the side of the power pin it serves, turned so its power pad faces the pin and level with it, sliding along when the spot is taken (by a part on the same side, or a through-hole pad from the other). Without `caps` it takes caps across ground and a supply of the IC, leaving out (`leftOut`) a signal net with one cap (a crystal's load) and a cap nearer another IC's pin on that supply. Each cap's pads keep clear of the stubs and vias plan_fanout would give the IC's other pins (`leaveFanout`, default true), so the fanout still fits after. A cap it cannot place stays where it is and keeps its spot. `icPowerNets` lists the IC's supplies. `moveArgs` is move_parts' arguments; `validation` is placement_check on the moved board. From the KiCad bridge's place_tools |@@ -112,7 +113,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.17.2"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.18.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
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.20.2-- Standard commit: 3c0ef8ff5e5bc11c68ba23f8038dc8d2643eea75-- Crate version: 0.17.2-- Copied: 2026-10-28, by fusion-lane+- Standard version: 0.21.0+- Standard commit: 6c5850f3303941197eaa1d2d147d33691e01bcaa+- Crate version: 0.18.0+- Copied: 2026-10-29, 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/features.rs+3−1@@ -194,7 +194,8 @@ pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> { pub fn width_style(rules: &[Value]) -> Option<f64> { if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false)) .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {- if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()) { return Some(v); }+ // its preferred width, else its minimum, as Altium's descriptor is read (Fusion sends EAGLE's msWidth as minMm)+ if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()).or_else(|| r["minMm"].as_f64()) { return Some(v); } } let num = |d: &str, key: &str| -> Option<f64> { let i = d.find(&format!("({key}="))? + key.len() + 2;@@ -431,6 +432,7 @@ mod tests { #[test] fn width_style_reads_the_standard_rule_and_altiums_descriptor() { assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));+ assert_eq!(width_style(&[json!({"kind": "width", "name": "minimum width (msWidth)", "minMm": 0.15})]), Some(0.15)); let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}), json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})]; assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");
rust/vendor/adom-eda-checks/src/landpattern.rsadded+276@@ -0,0 +1,276 @@+//! Footprints from a datasheet's land pattern: the AI gives the numbers the datasheet prints (pitch, pad size, row+//! span, body), the engine lays out a complete footprint: pads numbered the standard way, an exposed pad, the courtyard,+//! silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer (mm, Y up, origin at the+//! centre). From the Altium bridge's footprint.rs; each bridge writes the layout in its own library format.+use serde_json::{json, Value};++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }++#[derive(Debug, Default)]+pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }++/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...+pub fn bga_row(i: usize) -> String {+ const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";+ if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }+}++/// The pads of a land pattern. Errors name the missing number in the words the caller used.+pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));+ let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();+ let mut out = Vec::new();+ let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });+ match family.as_str() {+ "chip" | "two" | "2pad" => {+ let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);+ push("1".into(), -span / 2.0, 0.0, pl, pw);+ push("2".into(), span / 2.0, 0.0, pl, pw);+ }+ "dual" | "sot" => {+ let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);+ let (left, right) = if family == "sot" {+ (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)+ } else {+ let n = need("pins")? as usize;+ if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }+ (n / 2, n / 2)+ };+ // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)+ for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }+ for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+ }+ "quad" => {+ let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);+ let span_x = need("span")?;+ let span_y = num(a, "spanY").unwrap_or(span_x);+ let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {+ (Some(y), Some(x)) => (y as usize, x as usize),+ _ => {+ let n = need("pins")? as usize;+ if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }+ (n / 4, n / 4)+ }+ };+ let mut k = 1;+ // left top->bottom, bottom left->right, right bottom->top, top right->left+ for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }+ for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }+ }+ "bga" => {+ let (pitch, d) = (need("pitch")?, need("ballPad")?);+ let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);+ let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();+ for r in 0..rows {+ for c in 0..cols {+ let name = format!("{}{}", bga_row(r), c + 1);+ if missing.contains(&name) { continue; }+ push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);+ }+ }+ }+ f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),+ }+ // the exposed (thermal) pad+ if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {+ let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);+ let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());+ out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });+ }+ Ok(out)+}++/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.+fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {+ blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+ let mut out = Vec::new();+ let mut cur = a0;+ for &(b0, b1) in blocked.iter() {+ if b1 <= cur || b0 >= a1 { continue; }+ if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }+ cur = cur.max(b1);+ if cur >= a1 { break; }+ }+ if a1 - cur >= min { out.push((cur, a1)); }+ out+}++/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),+/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).+pub fn layout(a: &Value) -> Result<Fp, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let pads = pads(a)?;+ if pads.is_empty() { return Err("no pads".into()); }+ let body = a.get("body").cloned().unwrap_or(json!({}));+ let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));+ let silk_w = num(a, "silkWidth").unwrap_or(0.15);+ let clear = num(a, "silkClearance").unwrap_or(0.2);+ let margin = num(a, "courtyard").unwrap_or(0.25);+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+ let mut fp = Fp { pads: pads.clone(), ..Default::default() };+ if let (Some(w), Some(h)) = (bw, bh) {+ let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);+ x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);+ // silkscreen: each body edge minus the stretches that come within `clear` of copper+ let keep = clear + silk_w / 2.0;+ let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {+ let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {+ let (px0, py0, px1, py1) = ext(p);+ if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }+ }).collect();+ for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {+ if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }+ }+ };+ // the outline is drawn just outside the body so it is not hidden under it+ let o = silk_w / 2.0;+ edge(true, by1 + o, bx0 - o, bx1 + o);+ edge(true, by0 - o, bx0 - o, bx1 + o);+ edge(false, bx0 - o, by0 - o, by1 + o);+ edge(false, bx1 + o, by0 - o, by1 + o);+ // fab: the body with its pin-1 corner (top-left) cut+ let ch = (w.min(h) * 0.25).min(1.0);+ let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];+ for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }+ }+ // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized+ let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);+ if family != "chip" || polar {+ let p1 = &pads[0];+ let r = 0.15;+ let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };+ fp.discs.push(("overlay", r3(dx), r3(dy), r));+ x0 = x0.min(dx - r);+ y1 = y1.max(dy + r);+ }+ // courtyard: everything grown by the margin, on a 0.01 mm grid+ let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };+ let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));+ fp.courtyard = c;+ for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {+ fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+ }+ Ok(fp)+}++/// A plain description: "SOIC-8, 1.27 mm pitch, 8 pads".+fn describe(a: &Value, fp: &Fp) -> String {+ let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");+ let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();+ format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())+}++/// footprint_from_land_pattern: input {args: {name, family, ..., body?, exposedPad?, description?}}. The layout: pads,+/// lines and discs by layer (overlay, fab, courtyard), the courtyard box, a description and the pad table. A missing+/// number is refused in the caller's words.+pub fn footprint_from_land_pattern(input: &Value) -> Value {+ let args = &input["args"];+ let Some(name) = args["name"].as_str().filter(|n| !n.is_empty()) else { return crate::input::refuse("bad_args", "name is required") };+ let fp = match layout(args) {+ Ok(f) => f,+ Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e,+ "_hint": "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."}),+ };+ let desc = args["description"].as_str().map(String::from).unwrap_or_else(|| describe(args, &fp));+ let c = fp.courtyard;+ json!({"success": true, "name": name, "description": desc, "units": "mm", "frame": "Y up, origin at the centre, pin 1 top-left",+ "pads": fp.pads.iter().map(|p| json!({"name": p.name, "x": p.x, "y": p.y, "w": p.w, "h": p.h, "shape": p.shape, "cornerRatio": if p.shape == "roundrect" { 0.25 } else { 0.0 }, "hole": p.hole})).collect::<Vec<_>>(),+ "lines": fp.lines.iter().map(|(l, x1, y1, x2, y2, w)| json!({"layer": l, "x1": x1, "y1": y1, "x2": x2, "y2": y2, "width": w})).collect::<Vec<_>>(),+ "discs": fp.discs.iter().map(|(l, x, y, r)| json!({"layer": l, "x": x, "y": y, "r": r})).collect::<Vec<_>>(),+ "courtyardMm": [c.0, c.1, c.2, c.3], "body": args["body"].clone(),+ "padTable": fp.pads.iter().map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>(),+ "_hint": "A layout, not a library: write it in the tool's own footprint format (overlay is the silkscreen, fab the assembly outline, courtyard the keep-out), then check padTable against the datasheet. With body.height, generate_body makes the 3D body to attach."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn soic8_numbers_down_the_left_and_up_the_right() {+ let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));+ assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));+ assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));+ assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));+ assert_eq!((p[0].w, p[0].h), (1.55, 0.6));+ }++ #[test]+ fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {+ let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();+ assert_eq!(p.len(), 17);+ assert_eq!((p[0].x, p[0].y), (-1.5, 0.75)); // 1: left, top+ assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned+ assert_eq!((p[8].x, p[8].y), (1.5, -0.75)); // 9: right, bottom+ assert_eq!((p[12].x, p[12].y), (0.75, 1.5)); // 13: top, right end+ assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));+ }++ #[test]+ fn sot23_has_two_pins_left_and_one_right() {+ let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();+ assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+ }++ #[test]+ fn bga_rows_skip_confusable_letters() {+ assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));+ let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();+ assert_eq!(p.len(), 8);+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));+ }++ #[test]+ fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {+ let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});+ let fp = layout(&a).unwrap();+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {+ let _ = l;+ for p in &fp.pads {+ let (px0, py0, px1, py1) = ext(p);+ // distance from the segment (axis-aligned) to the pad box, minus half the stroke+ let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);+ let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);+ assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);+ }+ }+ // a SOIC's body sides sit under its pads: only the top and bottom edges survive+ assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);+ let c = fp.courtyard;+ assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));+ assert_eq!(fp.discs.len(), 1);+ assert!(fp.discs[0].1 < -2.7 - 0.775);++ }++ #[test]+ fn a_missing_number_is_named() {+ let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();+ assert!(e.contains("padLength"));+ }++ #[test]+ fn the_verb_answers_the_layout_in_the_standard_shape() {+ let r = footprint_from_land_pattern(&json!({"args": {"name": "SOIC-8", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}}}));+ assert_eq!(r["success"], json!(true), "{r}");+ assert_eq!(r["pads"][0], json!({"name": "1", "x": -2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "cornerRatio": 0.25, "hole": 0.0}));+ assert!(r["lines"].as_array().unwrap().iter().any(|l| l["layer"] == "courtyard"));+ assert_eq!(r["discs"][0]["layer"], "overlay");+ assert_eq!(r["padTable"].as_array().unwrap().len(), 8);+ assert!(r["description"].as_str().unwrap().starts_with("SOIC-8 dual, 1.27 mm pitch, 8 pads"));+ let e = footprint_from_land_pattern(&json!({"args": {"name": "X", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6}}));+ assert_eq!(e["errorCode"], "bad_args");+ assert!(e["error"].as_str().unwrap().contains("padLength"));+ assert_eq!(footprint_from_land_pattern(&json!({"args": {"family": "chip"}}))["errorCode"], "bad_args");+ }+}
rust/vendor/adom-eda-checks/src/lib.rs+3−1@@ -18,6 +18,7 @@ pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod landpattern; pub mod lengths; pub mod lengthtune; pub mod pairroute;@@ -36,7 +37,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "footprint_from_land_pattern", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"]; /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {@@ -57,6 +58,7 @@ fn answer(check: &str, input: &Value) -> Value { "current_capacity" => current::current_capacity(input), "estimate_cost" => cost::estimate_cost(input), "find_space" => space::find_space(input),+ "footprint_from_land_pattern" => landpattern::footprint_from_land_pattern(input), "fab_check" => fab::fab_check(input), "height_check" => bodies::height_check(input), "impedance" => impedance::impedance(input),
tests/run_verb_tests.py+11@@ -439,6 +439,17 @@ t("fusion_preflight_lbr", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {" t("fusion_library_parts", "negative", {"libraryPath": "C:/nope/missing.lbr"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file") LIBNEW = "C:/Users/olive/AppData/Local/Temp/fusion-lane/battery/new.lbr" t("fusion_create_footprint", "daily", {"libraryPath": LIBNEW, "name": "SOT-23", "pads": "1,-37,-40,24,28,rect,0;2,37,-40,24,28,rect,0;3,0,40,24,28,rect,0", "dryRun": True}, {"ok": True, "fields": ["data"]}, "dry run: a new library is planned and preflighted, nothing written")+BOARDLIB = "C:/Users/olive/Documents/fusion-lane-tests/boardgen.lbr" # R0603, SOT23, QFN-16-3X3 (fusion-lane's)+BOARDDIR = "C:/Users/olive/AppData/Local/Temp/fusion-lane/battery"+t("fusion_footprint_from_land_pattern", "daily", {"family": "dual", "name": "SOIC-8", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}, "dryRun": True}, {"ok": True, "fields": ["padTable", "courtyardMm", "engine"]}, "the engine lays a SOIC-8 out; nothing written")+t("fusion_footprint_from_land_pattern#missing", "negative", {"family": "dual", "name": "SOIC-8", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6, "dryRun": True}, {"pred": "must_refuse"}, "a missing land-pattern number is refused by name")+IMPORT_FP = 'META|SOT-23-5-LANE|SOT-23-5, 0.95 mm pitch\nPAD|1|-1.1|0.95|1.06|0.65|roundrect|0|top|25\nPAD|2|-1.1|0|1.06|0.65|roundrect|0|top|25\nPAD|3|-1.1|-0.95|1.06|0.65|roundrect|0|top|25\nPAD|4|1.1|-0.95|1.06|0.65|roundrect|0|top|25\nPAD|5|1.1|0.95|1.06|0.65|roundrect|0|top|25\nLINE|overlay|-0.3|1.6|0.3|1.6|0.15\nLINE|overlay|-0.3|-1.6|0.3|-1.6|0.15\nLINE|fab|-0.8|1.45|0.8|1.45|0.1\nLINE|fab|0.8|1.45|0.8|-1.45|0.1\nLINE|fab|0.8|-1.45|-0.8|-1.45|0.1\nLINE|fab|-0.8|-1.45|-0.8|1.45|0.1\nLINE|courtyard|-1.9|1.75|1.9|1.75|0.05\nLINE|courtyard|1.9|1.75|1.9|-1.75|0.05\nLINE|courtyard|1.9|-1.75|-1.9|-1.75|0.05\nLINE|courtyard|-1.9|-1.75|-1.9|1.75|0.05\nDISC|overlay|-1.95|1.5|0.15\n'+IMPORT_SYM = 'META|LDO-LANE|300 mA LDO (fusion-lane test)|U\nMODE|0|std\nRECT|0|0|-400|800|0\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|3|EN|0|-200|left|300||input\nPIN|0|2|GND|400|-400|down|300||power_in\nPIN|0|5|OUT|800|-100|right|300||power_out\nPIN|0|4|NC|800|-300|right|300||no_connect\n'+t("fusion_import_part", "daily", {"libraryPath": BOARDDIR + "/import.lbr", "footprintSpec": IMPORT_FP, "symbolSpec": IMPORT_SYM, "params": {"MPN": "LANE-LDO-TEST"}, "dryRun": True}, {"ok": True, "fields": ["footprint", "symbol", "steps"]}, "Altium-format specs into one .lbr: footprint, symbol, connects, params (dry run, nothing written)")+t("fusion_import_part#nothing", "negative", {"libraryPath": BOARDDIR + "/import.lbr"}, {"pred": "must_refuse"}, "nothing to import is not success")+t("fusion_build_board", "daily", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/build.brd", "parts": [{"ref": "R1", "footprint": "R0603", "x": 5, "y": 5}, {"ref": "Q1", "footprint": "SOT23", "x": 12, "y": 5}], "nets": {"GND": ["R1.2", "Q1.2"]}, "outline": {"rect": [0, 0, 18, 10], "cornerRadius": 1}, "open": False}, {"ok": True, "fields": ["pcbPath", "unknownPins"]}, "written offline (open:false keeps the battery from opening a design each run)")+t("fusion_build_board#missing", "negative", {"libraryPath": BOARDLIB, "pcbPath": BOARDDIR + "/bad.brd", "parts": [{"ref": "U1", "footprint": "NO_SUCH_FP", "x": 0, "y": 0}], "open": False}, {"pred": "must_refuse"}, "a footprint missing from the library refuses the whole build")+t("fusion_test_board", "daily", {"libraryPath": BOARDLIB, "footprintName": "R0603;SOT23", "pcbPath": BOARDDIR + "/test.brd", "open": False}, {"ok": True, "fields": ["pcb", "placed", "saved"]}, "test.brd is made on the first run and appended to after (designators continue); written offline") t("fusion_create_symbol", "daily", {"libraryPath": LIBNEW, "name": "LM358", "pins": "1,OUT,right,100;2,IN-,left,100;3,IN+,left,200;8,V+,left,300,0;4,V-,left,400,0", "dryRun": True}, {"ok": True, "fields": ["data"]}, "dry run") t("fusion_copy_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)") t("fusion_rename_footprint", "negative", {"libraryPath": "C:/nope/missing.lbr", "from": "A", "to": "B"}, {"errorCode": "library_unreadable"}, "expect a refusal naming the missing file (live-tested on new files 2026-10-28; golden: authoring_builds_a_part_fusion_will_open)")