← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.7\ No newline at end of file+1.13.8\ No newline at end of file
CHANGELOG.md+24
@@ -3,6 +3,30 @@ 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.8 (2026-10-29)++Schematic placing and wiring, and engine 0.11.0 (standard 0.14.0). 310 verbs.++- New, each read back and undone on a mismatch, no dialog:+  - `fusion_sch_place_part`: a deviceset from a library the design holds (by name) at x, y (mil), with its+    designator (the next free one for R? or none) and value; Fusion adds it to the board too;+  - `fusion_sch_connect`: a stub from each pin's outer end named for the net, with a label (port: an XREF label;+    power: a label, as Fusion's power symbols are library parts); a pin on another net is refused, never merged;+  - `fusion_sch_swap_part`: a part's device replaced in place with REPLACE; the reply lists the pins that lost+    their net.+  - Two Fusion limits found live (2706) and refused clearly instead of failing silently: a variant named with+    parentheses (Fusion's passives: CHIP-0402(1005-METRIC)) cannot be added from a text command, and a local .lbr+    is accepted by USE but not added from (add the library to the design first). `fusion_schematic_run`'s ADD has+    the same limits.+- `fusion_sch_delete_wire` deletes a labelled stub: its label goes with it (only labels of the deleted wires' nets),+  and the reply lists `labelsRemoved`. Before, the read back undid it.+- `fusion_export_ipc2581` is written by the shared engine (IPC-2581C, valid against the schema): the same copper as+  Fusion's own writer on BQ25792 (920 pads, 577 lines, 176 holes, 43 pour contours, 50 nets); parts with no pads+  (logos) are left out.+- Engine 0.10.4: `fusion_plan_route` smooths grid staircases into straight and 45-degree segments (BQ25792's N$15: 8+  corners, 13.9 mm, its via where the designer put it, routed with no split and fewer DRC entries than the+  original), echoes `avoidPours` and `pourCost`, and exempts a pour holding the route's own end pad.+ ## 1.13.7 (2026-10-28)  Engine 0.10.3 (standard 0.13.3) and `fusion_diff_pairs`. 307 verbs.
bridge.json+8−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.7",+  "version": "1.13.8",   "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.",   "homepage": "https://wiki.adom.inc/adom/fusion-bridge",   "author": "Adom Inc.",@@ -260,6 +260,7 @@     "fusion_save_to_cloud",     "fusion_sch_bus",     "fusion_sch_bus_entry",+    "fusion_sch_connect",     "fusion_sch_connect_pins",     "fusion_sch_connect_to_bus",     "fusion_sch_create",@@ -273,9 +274,11 @@     "fusion_sch_move_region",     "fusion_sch_page",     "fusion_sch_pins",+    "fusion_sch_place_part",     "fusion_sch_reset_fields",     "fusion_sch_sheet_pins",     "fusion_sch_split_wire",+    "fusion_sch_swap_part",     "fusion_sch_text",     "fusion_sch_trace_point",     "fusion_sch_update_from_library",@@ -503,7 +506,10 @@       "estimate_cost": 150,       "read_board": 120,       "add_rule": 120,-      "diff_pairs": 60+      "diff_pairs": 60,+      "sch_place_part": 120,+      "sch_connect": 120,+      "sch_swap_part": 120     },     "prefixes": [       {
contracts/describe.json+105−4
@@ -1,6 +1,6 @@ {   "contract": "fusion-bridge verb catalog",-  "frozen": "2026-10-28",+  "frozen": "2026-10-29",   "note": "The Python bridge and the Rust bridge must both serve this catalog byte-for-byte in meaning: same names, same args, same result shapes, same timeouts. Regenerate ONLY from describe.py and diff in CI.",   "source": "describe.py",   "verbs": [@@ -2134,18 +2134,20 @@       "longRunning": false,       "name": "fusion_export_ipc2581",       "output": {-        "components": "int",+        "arcs": "int",+        "components": "int (parts with pads)",+        "counts": "{...}",+        "engine": "str",         "holes": "int",         "nets": "int",         "pads": "int",         "path": "str",         "pourIslands": "int",-        "poursUnfilled": "int",         "traces": "int",         "vias": "int"       },       "statusVerb": null,-      "summary": "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581): layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",+      "summary": "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581), written by the shared engine's writer (adom-eda-checks) from Fusion's board model, stack-up and pour fills: layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",       "timeoutSeconds": 180     },     {@@ -18968,6 +18970,40 @@       "summary": "Add 45-degree bus entries: the short diagonal from a member wire's end onto a bus. In Fusion an entry is a net wire named for its bus member (drawn from the bus side, so no member menu opens); member defaults to the net of the wire ending at at. Refused with entry_off_bus when the entry misses the bus, not_a_member when the net is not in the bus. Read back, or undone.",       "timeoutSeconds": 150     },+    {+      "example": {+        "connections": [+          {+            "designator": "U2",+            "kind": "power",+            "net": "GND",+            "pin": "1"+          },+          {+            "designator": "U2",+            "net": "VIN",+            "pin": "VIN"+          }+        ]+      },+      "input": {+        "connections": "[{designator, pin (pad number or name), net, kind?: label|power|port|wire, stubMil?}] (required)",+        "dryRun": "bool",+        "expectDocument": "the design",+        "sheet": "int, default the one shown"+      },+      "longRunning": false,+      "name": "fusion_sch_connect",+      "output": {+        "connected": "int",+        "failed": "[{designator, pin, error, code}]",+        "results": "[{designator, pin, net, kind, result, endMil}]",+        "saved": "false"+      },+      "statusVerb": null,+      "summary": "Connect pins of placed parts (the standard's sch_connect): per connection a stub from the pin's outer end (stubMil, default 200) named for the net and a label at its end (kind label; port: an XREF label; power: a label too, as Fusion's power symbols are library parts and nets join by name); kind wire is the named stub alone. A pin already on another net is refused, never merged; noerc is refused (Fusion has no No-ERC marker). Read back; a mismatch undoes every command.",+      "timeoutSeconds": 120+    },     {       "example": {         "pairs": [@@ -19329,6 +19365,42 @@       "summary": "Every pin of the named parts (or all) on a schematic sheet: its connection point (rotation and mirror applied), symbol pin name, the pad number(s) it maps to, electrical type, the side it leaves the body, its net, and the wires, labels and pins on its wire segment. Read only.",       "timeoutSeconds": 60     },+    {+      "example": {+        "comment": "10k",+        "designator": "R?",+        "library": "C:/parts/Lib.lbr",+        "symbol": "R-US",+        "x": 3000,+        "y": 4000+      },+      "input": {+        "comment": "the value",+        "designator": "e.g. R12 or R? (default the deviceset's prefix, next free)",+        "dryRun": "bool",+        "expectDocument": "the design",+        "footprint": "a package name of the deviceset's variants",+        "library": ".lbr path on the Fusion machine (required)",+        "rotation": "0|90|180|270",+        "sheet": "int, default the one shown",+        "symbol": "the deviceset (required)",+        "x": "mil, default 1000",+        "y": "mil, default 1000"+      },+      "longRunning": false,+      "name": "fusion_sch_place_part",+      "output": {+        "atMil": "[x, y]",+        "designator": "str",+        "footprints": "[package]",+        "libref": "dev@lib",+        "pins": "int",+        "saved": "false"+      },+      "statusVerb": null,+      "summary": "Place a part on the schematic (the standard's sch_place_part): a deviceset from a .lbr on the Fusion machine at x, y (mil) and a rotation, with its designator (R? or none: the next free number) and value (comment). footprint picks the variant whose package has that name. The device is proven in the .lbr and the library USEd first, and ADD's questions are answered, so no picker or dialog opens; read back as the one part added, or undone. Fusion adds it to the board too.",+      "timeoutSeconds": 120+    },     {       "example": {         "references": [@@ -19407,6 +19479,35 @@       "summary": "Split a schematic wire at a point into two wires with a junction dot at the cut (Fusion's way: a junction on a wire splits it), so a branch can end on a wire end. junction:false is refused.",       "timeoutSeconds": 120     },+    {+      "example": {+        "designator": "C10",+        "footprint": "CAPC1005X60",+        "library": "C:/parts/Lib.lbr",+        "symbol": "C"+      },+      "input": {+        "designator": "required",+        "dryRun": "bool",+        "expectDocument": "the design",+        "footprint": "package of the variant",+        "keepComment": "bool, default false",+        "library": ".lbr path (required)",+        "symbol": "the new deviceset (required)"+      },+      "longRunning": false,+      "name": "fusion_sch_swap_part",+      "output": {+        "lostConnections": "[{pin, net}]",+        "newPinsNotOnOld": "[pad]",+        "now": "{libReference, footprints}",+        "oldPinsNotOnNew": "[pad]",+        "pinsJoined": "[] (Fusion does not add wires)"+      },+      "statusVerb": null,+      "summary": "Replace a placed part's device (the standard's sch_swap_part) with EAGLE's REPLACE: same name, place and turn, a deviceset from a .lbr on the Fusion machine (footprint picks the variant). Fusion keeps the connections of pins that land where the old ones were; the reply compares every pin's net by pad number and lists lostConnections to wire with fusion_sch_connect. keepComment puts the old value back. Multi-gate devices are refused. Read back, or undone.",+      "timeoutSeconds": 120+    },     {       "example": {         "texts": [
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.13.7"+version = "1.13.8" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.7"+version = "1.13.8" edition = "2021"  [dependencies]
describe.py+14−2
@@ -418,6 +418,18 @@ _T = [      {"expectDocument": "the design", "designators": "['R1','R2'] or 'R1,R2' (required)", "params": "{MPN: ...} or 'Key=Value;...' (required)", "dryRun": "bool"},      {"updated": "[ref]", "notFound": "[ref]"},      120, None, False, {'designators': ['R1'], 'params': {'MPN': '0603WAF1002T5E'}, 'dryRun': True}),+    ('fusion_sch_place_part', "Place a part on the schematic (the standard's sch_place_part): a deviceset from a .lbr on the Fusion machine at x, y (mil) and a rotation, with its designator (R? or none: the next free number) and value (comment). footprint picks the variant whose package has that name. The device is proven in the .lbr and the library USEd first, and ADD's questions are answered, so no picker or dialog opens; read back as the one part added, or undone. Fusion adds it to the board too.",+     {"expectDocument": "the design", "library": ".lbr path on the Fusion machine (required)", "symbol": "the deviceset (required)", "designator": "e.g. R12 or R? (default the deviceset's prefix, next free)", "comment": "the value", "footprint": "a package name of the deviceset's variants", "x": "mil, default 1000", "y": "mil, default 1000", "rotation": "0|90|180|270", "sheet": "int, default the one shown", "dryRun": "bool"},+     {"designator": "str", "libref": "dev@lib", "atMil": "[x, y]", "pins": "int", "footprints": "[package]", "saved": "false"},+     120, None, False, {'library': 'C:/parts/Lib.lbr', 'symbol': 'R-US', 'designator': 'R?', 'comment': '10k', 'x': 3000, 'y': 4000}),+    ('fusion_sch_connect', "Connect pins of placed parts (the standard's sch_connect): per connection a stub from the pin's outer end (stubMil, default 200) named for the net and a label at its end (kind label; port: an XREF label; power: a label too, as Fusion's power symbols are library parts and nets join by name); kind wire is the named stub alone. A pin already on another net is refused, never merged; noerc is refused (Fusion has no No-ERC marker). Read back; a mismatch undoes every command.",+     {"expectDocument": "the design", "connections": "[{designator, pin (pad number or name), net, kind?: label|power|port|wire, stubMil?}] (required)", "sheet": "int, default the one shown", "dryRun": "bool"},+     {"connected": "int", "results": "[{designator, pin, net, kind, result, endMil}]", "failed": "[{designator, pin, error, code}]", "saved": "false"},+     120, None, False, {'connections': [{'designator': 'U2', 'pin': '1', 'net': 'GND', 'kind': 'power'}, {'designator': 'U2', 'pin': 'VIN', 'net': 'VIN'}]}),+    ('fusion_sch_swap_part', "Replace a placed part's device (the standard's sch_swap_part) with EAGLE's REPLACE: same name, place and turn, a deviceset from a .lbr on the Fusion machine (footprint picks the variant). Fusion keeps the connections of pins that land where the old ones were; the reply compares every pin's net by pad number and lists lostConnections to wire with fusion_sch_connect. keepComment puts the old value back. Multi-gate devices are refused. Read back, or undone.",+     {"expectDocument": "the design", "designator": "required", "library": ".lbr path (required)", "symbol": "the new deviceset (required)", "footprint": "package of the variant", "keepComment": "bool, default false", "dryRun": "bool"},+     {"now": "{libReference, footprints}", "oldPinsNotOnNew": "[pad]", "newPinsNotOnOld": "[pad]", "lostConnections": "[{pin, net}]", "pinsJoined": "[] (Fusion does not add wires)"},+     120, None, False, {'designator': 'C10', 'library': 'C:/parts/Lib.lbr', 'symbol': 'C', 'footprint': 'CAPC1005X60'}),     ('fusion_sch_edit', "Change placed parts on a sheet (the standard's sch_edit): move (x, y mil), moveBy (dx, dy mil), rotate (degrees), designator (rename, the board follows), comment (value), delete. Each is read back; a mismatch undoes the edit.",      {"expectDocument": "the design", "sheet": "default the one shown", "edits": "[{designator, action, x,y | dx,dy | degrees | to | text}] (required)", "dryRun": "bool"},      {"applied": "int", "results": "[{designator, check, result}]", "failed": "[..]"},@@ -450,9 +462,9 @@ _T = [      {"libraryPath": "a .lbr on the Fusion machine (with name)", "name": "the package's name", "libId": "'Library:Package' from the board's embedded libraries", "expectDocument": "the board's name, for libId"},      {"name": "str", "pads": "[{number, type, shape, at, sizeMm, drillMm, layers, rotation}]", "layers": "[str]", "courtyardMm": "[x1, y1, x2, y2]", "graphics": "{layer: count}", "attributes": "{mount}", "models": "[{name, urn, type}]"},      60, None, False, {}),-    ('fusion_export_ipc2581', "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581): layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",+    ('fusion_export_ipc2581', "The board as IPC-2581 revision C XML (mm), valid against the IPC-2581C schema (the standard's export_ipc2581), written by the shared engine's writer (adom-eda-checks) from Fusion's board model, stack-up and pour fills: layers and stack-up, outline, packages with their pins, components and BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island with its holes, plus the drills. Mode ASSEMBLY: solder mask, paste and silkscreen are not in it (a fab order uses fusion_export_odb).",      {"expectDocument": "required: the board's name", "outputDir": "folder on the Fusion machine (default %TEMP%/adom-ipc2581)"},-     {"path": "str", "components": "int", "nets": "int", "pads": "int", "traces": "int", "vias": "int", "holes": "int", "pourIslands": "int", "poursUnfilled": "int"},+     {"path": "str", "components": "int (parts with pads)", "nets": "int", "pads": "int", "traces": "int", "arcs": "int", "vias": "int", "holes": "int", "pourIslands": "int", "counts": "{...}", "engine": "str"},      180, None, False, {}),     ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",      {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},
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.7"+DASH_VERSION="1.13.8" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.7",+  "version": "1.13.8",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+5−11
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.7",+  "version": "1.13.8",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -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]"   }-}\ No newline at end of file+}
rust/Cargo.lock+6−6
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-eda-checks"-version = "0.10.3"+version = "0.11.0" dependencies = [  "roxmltree",  "serde_json",@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.7"+version = "1.13.8" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.7"+version = "1.13.8" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.7"+version = "1.13.8" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.7"+version = "1.13.8" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.7"+version = "1.13.8" 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.7"+version = "1.13.8" edition = "2021" license = "MIT" 
rust/bridge/src/sch.rs+1−1
@@ -2,7 +2,7 @@ //! thread through the script transport, as the routing verbs do. use serde_json::{json, Value}; const SOURCE: &str = include_str!("sch_script.py");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "sch_items" | "sch_pins" | "sch_trace_point" | "sch_page" | "sch_hierarchy" | "sch_text" | "sch_junction" | "sch_delete_wire" | "sch_connect_pins" | "sch_split_wire" | "sch_bus" | "sch_edit_label" | "sch_move_region" | "sch_fix_connectivity" | "sch_bus_entry" | "sch_connect_to_bus" | "render_schematic" | "sch_reset_fields" | "sch_create" | "sch_sheet_pins" | "sch_update_from_library" | "cross_probe" | "cross_check" | "schematic_data" | "part_parameters" | "doc_params" | "sheet_params" | "sch_edit" | "annotate" | "sch_connect" | "sch_place_part" | "sch_swap_part") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     if cmd == "render_schematic" { return render_schematic(args, pass); }     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/sch_script.py+208−4
@@ -858,7 +858,12 @@ def sch_delete_wire(app, args):     before = {wid(w) for _, s_, b in segments(e.sheet) if not b for w in s_.findall('wire')}     after = {wid(w) for _, s_, b in segments(e.sheet2) if not b for w in s_.findall('wire')}     gone = {wid(w) for _, w in targets}-    if after != before - gone or not e.same_except(wires=False, junctions=False):+    # a label goes with its net segment when the segment's last wire goes (a stub and its label):+    # only labels of the deleted wires' nets may go, and none may appear or move+    nets_hit = {name for name, _ in targets}+    lb, la = label_rows(e.sheet), label_rows(e.sheet2)+    labels_ok = set(la) <= set(lb) and all(r[0] in nets_hit for r in set(lb) - set(la))+    if after != before - gone or not labels_ok or not e.same_except(wires=False, junctions=False, labels=False):         e.undo('The wires read back were not the old ones minus the asked ones')     # only the junctions at the deleted wires' own ends can have lost their reason to be there     pp = pin_points(e.after, e.sheet2)@@ -870,9 +875,10 @@ def sch_delete_wire(app, args):         e.sheet = e.sheet2         e.run()         import collections-        if collections.Counter(junction_points(e.sheet2)) != collections.Counter(junction_points(e.sheet)) - collections.Counter(orphans) or not e.same_except(junctions=False):+        if collections.Counter(junction_points(e.sheet2)) != collections.Counter(junction_points(e.sheet)) - collections.Counter(orphans) or not e.same_except(junctions=False, labels=False):             e.undo('Removing the junctions the delete left took more than those (%s)' % e.what_changed(junctions=False))-    return e.reply(deleted=sorted(gone), junctionsRemoved=[list(q) for q in orphans], notFound=missing)+    return e.reply(deleted=sorted(gone), junctionsRemoved=[list(q) for q in orphans], notFound=missing,+                   labelsRemoved=[list(r) for r in sorted(set(lb) - set(la))])   def route_points(a, b, first, via):@@ -943,6 +949,204 @@ def sch_connect_pins(app, args):     return e.reply(wired=len(routes), routes=routes, junctionsAdded=[list(q) for q in sorted(set(dots))], failed=failed)  +def _library_for(e, lib):+    """A library to ADD from: the name of one the design already holds (its schematic's copy is the+    truth), or a .lbr path on this machine. Fusion 2706 loads a local .lbr through USE without an+    error but ADD then finds nothing (live 2026-10-29), so a path is tried and refused honestly."""+    from pathlib import Path as _P+    held = {l_.get('name'): l_ for l_ in e.root.iter('library')}+    if lib in held:+        return held[lib], lib, None+    if lib and _P(lib).is_file():+        return ET.parse(lib).getroot(), _P(lib).stem, lib+    sfail('library_missing', 'library is a library this design already holds (%s) or a .lbr on the Fusion machine: %r'+          % (', '.join(sorted(held))[:300], lib))+++def _not_loaded(path):+    return ('Fusion did not load %s: a local .lbr is taken by USE without an error but ADD then finds nothing. '+            'Add the library to the design first (Fusion\'s Library Manager, or fusion_install_library_bundle for a hub library), then pass its name as library' % path)+++def _addable(full, ds, devices):+    """Fusion's ADD and REPLACE take a device by deviceset + variant name from a text command, but+    silently do nothing when the variant name holds parentheses (Fusion's own passives:+    CHIP-0402(1005-METRIC); live 2026-10-29). Refuse those up front instead of failing quietly."""+    if '(' in full or ')' in full:+        sfail('not_supported', "Fusion's ADD/REPLACE text command cannot take the variant %r of %s (parentheses in the name); "+              "variants it can take: %s. Place that one from Fusion's library panel, or use a library whose variants have plain names"+              % (full[len(ds.get('name')):], ds.get('name'), ', '.join(repr(d.get('name')) for d in devices if '(' not in (d.get('name') or '') and ')' not in (d.get('name') or '')) or 'none'))+++OUTWARD = {'left': (-1, 0), 'right': (1, 0), 'top': (0, 1), 'bottom': (0, -1)}+++def sch_connect(app, args):+    """The standard's sch_connect: per connection a stub from the pin's outer end (stubMil, default+    200), named for the net, and a label at its end (kind label; port: an XREF label; power: a label+    too, as Fusion's power symbols are library parts and nets join by name). kind wire is the named+    stub alone. A pin already on another net is refused, never merged; noerc is refused (EAGLE has+    no No-ERC marker: approve the ERC message instead). Read back; a mismatch undoes every command."""+    e = Edit(app, args)+    conns = args.get('connections') or []+    if not conns:+        sfail('bad_args', 'connections is [{designator, pin, net, kind?: label|power|port|wire, stubMil?}]')+    plans, failed = [], []+    for c in conns:+        spec = '%s.%s' % (c.get('designator'), c.get('pin'))+        kind = str(c.get('kind') or 'label').lower()+        net = str(c.get('net') or '')+        try:+            if kind == 'noerc':+                sfail('not_supported', 'Fusion has no No-ERC marker: leave the pin open and approve its ERC message (fusion_run_erc lists it)')+            if kind not in ('label', 'power', 'port', 'wire'):+                sfail('bad_args', 'kind is label, power, port or wire')+            if not net:+                sfail('bad_args', 'net is required')+            pin = find_pin(e.root, e.sheet, spec)+            have = next((q['net'] for q in pins_with_connections(e.root, e.sheet, [pin['ref']], connections=False)+                         if q['pin'] == pin['pin'] and q['gate'] == pin['gate']), None)+            if have and have != net:+                sfail('pin_on_other_net', '%s is already on %s: disconnect it first (fusion_sch_delete_wire) or connect it to %s' % (spec, have, have))+        except SchError as x:+            failed.append({'designator': c.get('designator'), 'pin': c.get('pin'), 'net': net, 'kind': kind, 'error': str(x), 'code': x.code})+            continue+        L = float(c.get('stubMil') or 200) * 0.0254+        dx, dy = OUTWARD.get(pin['side'], (1, 0))+        a = pin['at']; b = (_r4(a[0] + dx * L), _r4(a[1] + dy * L)); mid = (_r4(a[0] + dx * L / 2), _r4(a[1] + dy * L / 2))+        e.cmds.append('SET CONFIRM YES; NET %s %s %s; SET CONFIRM OFF;' % (quote(net), mm(a), mm(b)))+        if kind != 'wire':+            e.cmds.append('LABEL %s%s %s;' % ('XREF ' if kind == 'port' else '', mm(mid), mm(b)))+        plans.append({'designator': c.get('designator'), 'pin': c.get('pin'), 'ref': pin['ref'], 'pinName': pin['pin'], 'gate': pin['gate'], 'net': net, 'kind': kind,+                      'endMm': list(b), 'endMil': [round(b[0] / 0.0254, 1), round(b[1] / 0.0254, 1)],+                      'note': 'a label: Fusion power symbols are library parts, and the net joins every other copy of its name' if kind == 'power' else None})+    if dry(args):+        return plan_reply(e, results=plans, failed=failed)+    if not e.cmds:+        sfail('nothing_to_do', 'No connection could be made: %s' % failed)+    e.run()+    now = {(q['ref'], q['gate'], q['pin']): q['net'] for q in pins_with_connections(e.after, e.sheet2, connections=False)}+    bad = [p_ for p_ in plans if now.get((p_['ref'], p_['gate'], p_['pinName'])) != p_['net']]+    was = {(q['ref'], q['gate'], q['pin']): q['net'] for q in pins_with_connections(e.root, e.sheet, connections=False)}+    mine = {(p_['ref'], p_['gate'], p_['pinName']) for p_ in plans}+    nets = {p_['net'] for p_ in plans}+    strays = sorted('%s.%s' % (k[0], k[2]) for k, n in was.items() if k not in mine and n and n not in nets and now.get(k) != n)+    if bad or strays or not e.same_except(wires=False, junctions=False, labels=False):+        e.undo('After the stubs, %s' % ('; '.join('%s.%s is on %s, not %s' % (p_['designator'], p_['pin'], now.get((p_['ref'], p_['gate'], p_['pinName'])), p_['net']) for p_ in bad)+                                         or ('pins of other nets moved: %s' % ', '.join(strays[:8]) if strays else e.what_changed(wires=False, junctions=False, labels=False))))+    for p_ in plans:+        p_['result'] = 'ok'+        for k in ('ref', 'pinName', 'gate'): p_.pop(k, None)+    return e.reply(connected=len(plans), results=plans, failed=failed,+                   _hint='Nets with the same name connect across the sheets; check with fusion_run_erc. A pin that already had a wire got a second stub only when it was on the same net.')+++def sch_place_part(app, args):+    """The standard's sch_place_part: a deviceset from a .lbr on this machine onto the sheet (x, y+    in mil, rotation), with its designator and value. The device is proven in the .lbr first and+    the library USEd, ADD and VALUE pre-answer their questions, so no picker or dialog opens; the+    part is read back on the sheet or the edit is undone. footprint picks the device variant whose+    package has that name. Fusion keeps the board in step, so the part also lands on the board."""+    from pathlib import Path as _P+    e = Edit(app, args)+    lroot, libname, lpath = _library_for(e, args.get('library') or args.get('libraryPath'))+    sym = str(args.get('symbol') or '')+    ds = next((d for d in lroot.iter('deviceset') if d.get('name', '').upper() == sym.upper()), None)+    if ds is None:+        sfail('unknown_device', 'No deviceset %s in %s (devicesets: %s)' % (sym, lib, ', '.join(sorted(d.get('name') for d in lroot.iter('deviceset')))[:300]))+    devices = ds.findall('devices/device')+    fp = args.get('footprint')+    dev = next((d for d in devices if fp and d.get('package', '').upper() == str(fp).upper()), None) if fp else (devices[0] if devices else None)+    if fp and dev is None:+        sfail('unknown_footprint', '%s has no variant with package %s (packages: %s)' % (sym, fp, ', '.join(d.get('package') or '-' for d in devices)))+    full = ds.get('name') + (dev.get('name') if dev is not None else '')+    _addable(full, ds, devices)+    parts = {p_.get('name') for p_ in e.root.iter('part')}+    ref = args.get('designator')+    if not ref or str(ref).endswith('?'):+        prefix = (str(ref)[:-1] if ref else '') or ds.get('prefix') or 'U'+        n = 1+        while '%s%d' % (prefix, n) in parts: n += 1+        ref = '%s%d' % (prefix, n)+    if ref in parts:+        sfail('designator_taken', '%s is already on the schematic' % ref)+    if not re.fullmatch(r'[A-Za-z0-9_$+-]+', ref):+        sfail('bad_args', 'designator %r has characters EAGLE does not take' % ref)+    rot = int(args.get('rotation') or 0)+    if rot not in (0, 90, 180, 270):+        sfail('bad_args', 'rotation is 0, 90, 180 or 270')+    x, y = float(args.get('x', 1000)) * 0.0254, float(args.get('y', 1000)) * 0.0254+    e.cmds.append("SET CONFIRM YES; ADD '%s@%s' '%s' R%d %s; SET CONFIRM OFF;" % (full, libname, ref, rot, mm((x, y))))+    if args.get('comment'):+        e.cmds.append('SET CONFIRM YES; VALUE %s %s; SET CONFIRM OFF;' % (quote(ref), quote(str(args['comment'])[:40])))+    pins = sum(len(lroot.findall(".//symbols/symbol[@name='%s']/pin" % g.get('symbol'))) for g in ds.findall('gates/gate'))+    out = {'designator': ref, 'libref': '%s@%s' % (full, libname), 'atMil': [round(x / 0.0254, 1), round(y / 0.0254, 1)], 'pins': pins,+           'footprints': [d.get('package') for d in devices if d.get('package')]}+    if dry(args):+        return plan_reply(e, **out)+    if lpath: app.executeTextCommand("Electron.run USE '%s';" % _P(lpath).as_posix())+    e.run()+    placed = {p_.get('name') for p_ in e.after.iter('part')}+    if lpath and placed == parts:+        sfail('library_not_loaded', _not_loaded(lpath))+    if ref not in placed or not (placed - parts) == {ref} or not e.same_except(parts=False):+        e.undo('%s did not read back as the one part added' % ref)+    return e.reply(**out, _hint='Then wire it: fusion_sch_connect {connections:[{designator, pin, net}]}. Fusion adds it to the board as well (beside the outline): place it with fusion_move_parts.')+++def sch_swap_part(app, args):+    """The standard's sch_swap_part with EAGLE's REPLACE: the part keeps its name, place and turn and+    takes another device from a .lbr on this machine (footprint picks the variant). Fusion keeps the+    connections of pins that land where the old ones were; the reply compares every pin's net by+    pad number before and after, and lists the pins whose net was lost (wire them with+    fusion_sch_connect). keepComment puts the old value back. Read back; a mismatch undoes it."""+    from pathlib import Path as _P+    e = Edit(app, args)+    lroot, libname, lpath = _library_for(e, args.get('library') or args.get('libraryPath'))+    sym = str(args.get('symbol') or '')+    ds = next((d for d in lroot.iter('deviceset') if d.get('name', '').upper() == sym.upper()), None)+    if ds is None:+        sfail('unknown_device', 'No deviceset %s in %s' % (sym, lib))+    devices = ds.findall('devices/device'); fp = args.get('footprint')+    dev = next((d for d in devices if fp and d.get('package', '').upper() == str(fp).upper()), None) if fp else (devices[0] if devices else None)+    if fp and dev is None:+        sfail('unknown_footprint', '%s has no variant with package %s' % (sym, fp))+    if len(ds.findall('gates/gate')) > 1:+        sfail('not_supported', 'a multi-gate device: replace it gate by gate in Fusion, or place the new part and delete the old')+    full = ds.get('name') + (dev.get('name') if dev is not None else '')+    _addable(full, ds, devices)+    ref = str(args.get('designator') or '')+    part = next((p_ for p_ in e.root.iter('part') if p_.get('name') == ref), None)+    if part is None:+        sfail('designator_not_found', 'No part %s on the schematic' % ref)+    old_value = part.get('value')+    def by_pad(root, sheet):+        out = {}+        for q in pins_with_connections(root, sheet, [ref], connections=False):+            for pad in (q['number'] or q['pin']).split():+                out[pad] = q['net']+        return out+    before = by_pad(e.root, e.sheet)+    e.cmds.append("SET CONFIRM YES; REPLACE '%s@%s' %s; SET CONFIRM OFF;" % (full, libname, quote(ref)))+    if args.get('keepComment') and old_value:+        e.cmds.append('SET CONFIRM YES; VALUE %s %s; SET CONFIRM OFF;' % (quote(ref), quote(old_value[:40])))+    if dry(args):+        return plan_reply(e, was={'deviceset': part.get('deviceset'), 'device': part.get('device'), 'library': part.get('library')})+    if lpath: app.executeTextCommand("Electron.run USE '%s';" % _P(lpath).as_posix())+    e.run()+    now_part = next((p_ for p_ in e.after.iter('part') if p_.get('name') == ref), None)+    if lpath and now_part is not None and now_part.get('library') == part.get('library') and now_part.get('deviceset') == part.get('deviceset') and now_part.get('device') == part.get('device'):+        sfail('library_not_loaded', _not_loaded(lpath))+    after = by_pad(e.after, e.sheet2)+    if now_part is None or now_part.get('deviceset', '').upper() != ds.get('name').upper() or not e.same_except(parts=False, wires=False, junctions=False, labels=False):+        e.undo('%s did not read back as %s' % (ref, full))+    lost = sorted(p_ for p_, n in before.items() if n and after.get(p_) != n)+    return e.reply(now={'libReference': '%s@%s' % (full, libname), 'footprints': [dev.get('package')] if dev is not None and dev.get('package') else []},+                   oldPinsNotOnNew=sorted(set(before) - set(after)), newPinsNotOnOld=sorted(set(after) - set(before)),+                   lostConnections=[{'pin': p_, 'net': before[p_]} for p_ in lost], pinsJoined=[],+                   _hint='Pins listed in lostConnections lost their net: wire them with fusion_sch_connect. Fusion updates the board footprint with the part.')++ def sch_split_wire(app, args):     if args.get('junction') is False:         sfail('unsupported', 'Fusion splits a wire by placing a junction on it (SPLIT at one point does nothing); call with junction true, or delete the junction afterwards with sch_junction {remove}')@@ -1782,7 +1986,7 @@ def annotate(app, args):  EDITS = {'doc_params': doc_params, 'sheet_params': sheet_params, 'sch_edit': sch_edit, 'annotate': annotate, 'sch_text': sch_text, 'sch_junction': sch_junction, 'sch_delete_wire': sch_delete_wire, 'sch_connect_pins': sch_connect_pins,          'sch_split_wire': sch_split_wire, 'sch_bus': sch_bus, 'sch_edit_label': sch_edit_label, 'sch_move_region': sch_move_region,-         'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create}+         'sch_fix_connectivity': sch_fix_connectivity, 'sch_bus_entry': sch_bus_entry, 'sch_connect_to_bus': sch_connect_to_bus, 'sch_reset_fields': sch_reset_fields, 'sch_create': sch_create, 'sch_connect': sch_connect, 'sch_place_part': sch_place_part, 'sch_swap_part': sch_swap_part}   def drawing_extent(root, sheet):
rust/bridge/src/writers.rs+18−1
@@ -4,7 +4,7 @@ //! documentation) stay on this bridge's own writer (routing_script board_dxf), so nothing is lost. use serde_json::{json, Value}; -pub fn owns(cmd: &str) -> bool { matches!(cmd, "export_gencad" | "board_dxf") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "export_gencad" | "board_dxf" | "export_ipc2581") }  fn safe(name: &str) -> String { name.chars().map(|c| if c.is_ascii_alphanumeric() || "-_.".contains(c) { c } else { '_' }).collect() } @@ -20,6 +20,23 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val     if board["success"].as_bool() == Some(false) { return board; }     let doc = board["documentName"].as_str().unwrap_or("board").to_string();     match cmd {+        "export_ipc2581" => {+            // the stack-up for the layer thicknesses and Fusion's poured copper island by island+            let stack = crate::routing::call("layer_stack", args, pass);+            let pours = crate::routing::call("pour_state", args, pass);+            let mut a = args.clone();+            if a["title"].is_null() { a["title"] = json!(doc); }+            let r = adom_eda_checks::run("export_ipc2581", &json!({"board": board, "stack": stack, "pours": pours, "args": a}));+            let Some(text) = r["text"].as_str() else { return r };+            let dir = out_dir(args, "adom-ipc2581");+            let _ = std::fs::create_dir_all(&dir);+            let path = format!("{dir}/{}.xml", safe(&doc));+            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": false, "documentName": doc, "path": path, "format": r["format"], "mode": r["mode"], "units": r["units"],+                                 "counts": r["counts"], "writtenFrom": r["writtenFrom"], "engine": r["engine"], "_hint": r["_hint"]});+            if let Some(c) = r["counts"].as_object() { for (k, v) in c { out[k] = v.clone(); } }+            out+        }         "export_gencad" => {             let r = adom_eda_checks::run("export_gencad", &json!({"board": board, "args": args}));             let Some(text) = r["text"].as_str() else { return r };
rust/vendor/adom-eda-checks/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.10.3"+version = "0.11.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+3−2
@@ -22,7 +22,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `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 | | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools |-| `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path |+| `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`). The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) | | `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments | | `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | @@ -43,6 +43,7 @@ regions, with `writtenFrom`, and the bridge writes the file or places the region |---|---|---| | `export_gencad` | board | GenCAD 1.4 text, in inches: outline, pads and padstacks, shapes, components (a bottom part `MIRRORX FLIP`, as KiCad writes it), devices, signals, routes | | `export_dxf` | board, pours? | one DXF (R12) per layer in `args.layers`: Edge.Cuts as lines and arcs or one closed polyline (`contours`), copper as tracks, arcs, pads, vias and pours. With `pours` a copper layer shows the copper actually poured; without, `poursAsDrawn` names the layers that show the pour as drawn |+| `export_ipc2581` | board, stack?, pours? | IPC-2581 revision C (mm) for assembly and test, valid against the IPC-2581C schema: layers and stack-up, outline (arcs as curves), one package per footprint with its pins in the footprint's frame, components (a bottom part mirrored) and BOM, nets with their pins, and on each copper layer the pads, traces, arcs, vias and, with `pours`, the poured copper island by island with its holes, plus the drills (a hole with no ring is non-plated). `args.date` sets the history date (default now). No mask, paste or silkscreen. From the Fusion bridge's routing_script.py | | `generate_body` | args only | a part's 3D body as STEP AP214 text from its package family and JEDEC letters (`family`, `dimensionsMm` with `pins` the total pin count and `D2`/`E2` an exposed pad, `marking?`), plus its box in the bodies companion's form; families chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal, header (#20) | | `import_svg` | svg (text) | `regions` in board mm (y up) for a layer: each shape with its holes as one contour reaching the holes through zero-width slits | @@ -95,7 +96,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.10.3"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.11.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests 
rust/vendor/adom-eda-checks/VENDOR.md+3−3
@@ -3,9 +3,9 @@ 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.13.3-- Standard commit: d991b05865a55a89da697159a8f626006281adbb-- Crate version: 0.10.3+- Standard version: 0.14.0+- Standard commit: 2421e66b3bf499df02e9771db51dd56103b5c2a8+- Crate version: 0.11.0 - Copied: 2026-10-28, by fusion-lane  To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/route.rs+74−10
@@ -162,6 +162,15 @@ pub fn plan_route(input: &Value) -> Value {             let ring: Vec<P> = p["points"].as_array()?.iter().map(|q| (f(&q[0]), f(&q[1]))).collect();             (ring.len() >= 3).then(|| (p["net"].as_str().unwrap_or("").to_string(), p["layer"].as_str().unwrap_or("").to_string(), Shape::Poly(ring)))         }).collect();+    // a pour that holds the route's own end pad cannot be kept out of (fusion-lane #21: SW1.2 sits inside VBAT's F.Cu+    // pour, so avoidPours: ["VBAT"] found no path): it is exempt, and the reply names it+    let held: Vec<usize> = (0..pours.len()).filter(|&i| {+        let (_, l, s) = &pours[i];+        let Shape::Poly(ring) = s else { return false };+        ends.iter().any(|p| pad_layers(p, &copper).contains(l) && inside((f(&p["x"]), f(&p["y"])), ring))+    }).collect();+    let blocks = |i: usize| avoids(&pours[i].0) && !held.contains(&i);+    let exempt: Vec<Value> = held.iter().filter(|&&i| avoids(&pours[i].0)).map(|&i| json!({"net": pours[i].0, "layer": pours[i].1, "why": "it holds an end pad of the route"})).collect();     // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's     // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper,     // so neither blocks a route: repour after placing it (the reply says so)@@ -230,7 +239,7 @@ pub fn plan_route(input: &Value) -> Value {             edge_ok(a, a, r) && copper.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)         } else {             edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])-                && !pours.iter().any(|(n, l, s)| avoids(n) && *l == layers[u.2] && s.dist(a, b) < width / 2.0)+                && !(0..pours.len()).any(|i| blocks(i) && pours[i].1 == layers[u.2] && pours[i].2.dist(a, b) < width / 2.0)         };         cache.insert(k, ok);         ok@@ -276,14 +285,39 @@ pub fn plan_route(input: &Value) -> Value {     let mut path = vec![goal_node];     while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }     path.reverse();+    // smooth the grid path: from each point, the farthest later point on the same layer that a straight or 45-degree+    // segment reaches with the same clearance, inside the outline and into no pour the grid path kept out of. Staircases+    // become diagonals (fusion-lane #21: Fusion's DRC flags every vertex of a staircase). smooth: false keeps the grid path.+    let at = |u: (usize, usize, usize)| (xs[u.0], ys[u.1]);+    let pours_hit = |a: P, b: P, l: usize| -> Vec<usize> { (0..pours.len()).filter(|&i| pours[i].1 == layers[l] && pours[i].2.dist(a, b) < width / 2.0).collect() };+    let octilinear = |a: P, b: P| { let (dx, dy) = ((b.0 - a.0).abs(), (b.1 - a.1).abs()); dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9 };+    let smooth = args["smooth"].as_bool().unwrap_or(true);+    let mut pts = vec![path[0]];+    let mut i = 0;+    while i + 1 < path.len() {+        if path[i + 1].2 != path[i].2 || !smooth { pts.push(path[i + 1]); i += 1; continue; }+        let (mut best, mut j) = (i + 1, i + 2);+        let mut grid_pours: Vec<usize> = pours_hit(at(path[i]), at(path[i + 1]), path[i].2);+        while j < path.len() && path[j].2 == path[i].2 {+            for p in pours_hit(at(path[j - 1]), at(path[j]), path[i].2) { if !grid_pours.contains(&p) { grid_pours.push(p); } }+            let (a, b) = (at(path[i]), at(path[j]));+            if octilinear(a, b) && edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[path[i].2])+                && pours_hit(a, b, path[i].2).iter().all(|p| grid_pours.contains(p) && !blocks(*p)) { best = j; }+            j += 1;+        }+        pts.push(path[best]);+        i = best;+    }     // keep the corners and the layer changes-    let mut keep = vec![path[0]];-    for w in path.windows(3) {+    let mut keep = vec![pts[0]];+    for w in pts.windows(3) {         let (a, b, c) = (w[0], w[1], w[2]);-        if a.2 == b.2 && b.2 == c.2 && ((a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1)) { continue; }+        let (pa, pb, pc) = (at(a), at(b), at(c));+        let straight = ((pb.0 - pa.0) * (pc.1 - pb.1) - (pb.1 - pa.1) * (pc.0 - pb.0)).abs() < 1e-9 && (pb.0 - pa.0) * (pc.0 - pb.0) + (pb.1 - pa.1) * (pc.1 - pb.1) >= 0.0;+        if a.2 == b.2 && b.2 == c.2 && straight { continue; }         keep.push(b);     }-    if path.len() > 1 { keep.push(*path.last().unwrap()); }+    if pts.len() > 1 { keep.push(*pts.last().unwrap()); }     let mut way = vec![json!({"pad": key(&ends[0])})];     let mut vias = Vec::new();     let mut length = 0.0;@@ -304,7 +338,8 @@ pub fn plan_route(input: &Value) -> Value {     way.push(json!({"pad": key(&ends[1])}));     json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias,         "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},-        "planner": {"algorithm": "bounded orthogonal A*", "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+        "planner": {"algorithm": if smooth { "bounded orthogonal A*, smoothed to straight and 45-degree segments" } else { "bounded orthogonal A*" }, "widthFrom": width_from, "viaFrom": via_from, "gridMm": grid, "layers": layers, "viasCheckedOn": copper, "clearanceMm": clearance, "edgeClearanceMm": edge,+            "avoidPours": args["avoidPours"].clone(), "avoidExempt": exempt, "pourCost": pour_cost, "smooth": smooth, "expandedNodes": seen.len()},         "crossesPours": crosses, "avoidPours": args["avoidPours"].clone(), "pourCost": pour_cost, "revision": revision,         "_hint": if crosses.is_empty() { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer." }                  else { "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). The route runs through the pours in crossesPours: after the repour, check that none of those nets split in two (routing_state's splitNets), or plan again keeping out of them: avoidPours: true (or a list of the nets whose pours to keep out of), or pourCost: 2 to prefer going round them where a way exists. A layer change is two waypoints at the same point, where route_net places a via." }})@@ -419,15 +454,44 @@ mod tests {         // by net: avoiding another net's pours only leaves this one crossable         assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["VBAT"]}}))["vias"], json!(0));         assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["gnd"]}}))["vias"], json!(2));-        // a pour over the whole board: a hard block finds no path, a cost still routes (across it)+        // a wall of pours across both layers: a hard block finds no path, a cost still routes (across it)+        let mut wall = board(json!([]));+        wall["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[8.0, 0.0], [12.0, 0.0], [12.0, 10.0], [8.0, 10.0]]}]);+        assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));+        assert_eq!(plan_route(&json!({"board": wall, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+        // a pour that holds the route's own end pad cannot be kept out of: it is exempt and named (fusion-lane #21)         let mut all = board(json!([]));-        all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}, {"net": "GND", "layer": "B.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);-        assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": true}}))["found"], json!(false));-        assert_eq!(plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 2.0}}))["found"], json!(true));+        all["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]]}]);+        let held = plan_route(&json!({"board": all, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "avoidPours": ["GND"]}}));+        assert_eq!(held["found"], json!(true), "{held}");+        assert_eq!(held["planner"]["avoidExempt"][0]["net"], "GND");+        assert_eq!(held["planner"]["pourCost"], json!(0.0));         // and with the cost a short way round a narrow pour beats crossing it         let mut strip = board(json!([]));         strip["polygons"] = json!([{"net": "GND", "layer": "F.Cu", "points": [[9.0, 2.0], [11.0, 2.0], [11.0, 10.0], [9.0, 10.0]]}]);         let soft = plan_route(&json!({"board": strip, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "pourCost": 10.0, "allowVias": false}}));         assert_eq!(soft["crossesPours"], json!([]), "{soft}");     }+    #[test]+    fn a_staircase_becomes_one_45_degree_segment() {+        // U1.1 at (2, 5) to a pad at (8, 9): the grid path is a staircase of corners; smoothed, the plan is straight and+        // 45-degree runs only, the diagonal clear of everything+        let mut b = board(json!([]));+        b["pads"][1]["x"] = json!(8.0); b["pads"][1]["y"] = json!(9.0);+        let r = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        let way = r["routeNet"]["paths"][0].as_array().unwrap().clone();+        let mut pts = vec![(2.0, 5.0)];+        pts.extend(way[1..way.len() - 1].iter().map(|w| (f(&w["x"]), f(&w["y"]))));+        pts.push((8.0, 9.0));+        assert!(pts.len() <= 3, "at most one corner: {r}");+        for w in pts.windows(2) {+            let (dx, dy) = ((w[1].0 - w[0].0).abs(), (w[1].1 - w[0].1).abs());+            assert!(dx < 1e-9 || dy < 1e-9 || (dx - dy).abs() < 1e-9, "{w:?}");+        }+        assert!(pts.windows(2).any(|w| (w[1].0 - w[0].0).abs() > 1e-9 && (w[1].1 - w[0].1).abs() > 1e-9), "one diagonal: {r}");+        // shorter than the 10 mm staircase: 4 * sqrt(2) + 2+        assert!((f(&r["lengthMm"]) - (4.0 * 2f64.sqrt() + 2.0)).abs() < 1e-3, "{r}");+        let grid = plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "smooth": false}}));+        assert!((f(&grid["lengthMm"]) - 10.0).abs() < 1e-9);+    } }
rust/vendor/adom-eda-checks/src/writers/ipc2581.rsadded+398
@@ -0,0 +1,398 @@+//! IPC-2581 revision C (mm) for assembly and test, from the board model: the layers and stack-up, the outline, one+//! package per footprint (pins in the footprint's own frame), the components and BOM, the nets with their pins, and on+//! each copper layer the pads (with their pin), traces, arcs, vias and, with pour_state, the poured copper island by+//! island, plus the drills. From the Fusion bridge's routing_script.py `ipc2581_xml`, which validates against the+//! IPC-2581C schema; the element order follows it.+use super::dxf::f;+use serde_json::{json, Value};++/// A number as IPC-2581 wants it: up to 4 decimals, no trailing zeros.+fn n(v: f64) -> String {+    let s = format!("{:.4}", if v.abs() < 5e-5 { 0.0 } else { v });+    let s = s.trim_end_matches('0').trim_end_matches('.');+    if s.is_empty() || s == "-" { "0".into() } else { s.to_string() }+}++/// Fab formats are read by ASCII tools: the usual unit signs by name (10kΩ -> 10kohm), '?' for the rest.+fn ascii(t: &str) -> String {+    t.chars().map(|c| match c {+        c if (c as u32) < 128 => c.to_string(),+        'Ω' | 'Ω' => "ohm".into(), 'µ' | 'μ' => "u".into(), '±' => "+/-".into(), '°' => "deg".into(), '–' | '—' => "-".into(),+        _ => "?".into(),+    }).collect()+}++/// An attribute value: ASCII, XML-escaped.+fn a(t: &str) -> String { ascii(t).replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }++/// The pad primitives used, by id, in the order first used.+#[derive(Default)]+struct Dict { ids: Vec<(String, String)> }++impl Dict {+    /// A pad's primitive: a circle, an oval (a stadium) or a centred rectangle.+    fn pad(&mut self, shape: &str, w: f64, h: f64) -> String {+        let round = matches!(shape, "round" | "circle");+        let (id, el) = if round && (w - h).abs() < 1e-6 {+            (format!("CIRCLE_{}", n(w)), format!("<Circle diameter=\"{}\"/>", n(w)))+        } else if round {+            (format!("OVAL_{}x{}", n(w), n(h)), format!("<Oval width=\"{}\" height=\"{}\"/>", n(w), n(h)))+        } else {+            (format!("RECT_{}x{}", n(w), n(h)), format!("<RectCenter width=\"{}\" height=\"{}\"/>", n(w), n(h)))+        };+        let id = id.replace('.', "_").replace('-', "m");+        if !self.ids.iter().any(|(i, _)| *i == id) { self.ids.push((id.clone(), el)); }+        id+    }+    fn xml(&self) -> String {+        let mut o = String::from("    <DictionaryStandard units=\"MILLIMETER\">\n");+        for (id, el) in &self.ids { o += &format!("      <EntryStandard id=\"{id}\">{el}</EntryStandard>\n"); }+        o + "    </DictionaryStandard>\n"+    }+}++/// A closed outline from the board model's outline edges (lines and arcs), or its point list.+fn profile_polygon(st: &Value) -> String {+    let edges = st["outlineEdges"].as_array().cloned().unwrap_or_default();+    let pt = |v: &Value| (f(&v[0]), f(&v[1]));+    let mut o = String::from("        <Polygon>\n");+    if !edges.is_empty() {+        let first = pt(&edges[0]["start"]);+        o += &format!("          <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(first.0), n(first.1));+        for (i, e) in edges.iter().enumerate() {+            // an edge ends where the next begins (the last closes the ring)+            let end = if e["end"].is_array() { pt(&e["end"]) } else { pt(&edges[(i + 1) % edges.len()]["start"]) };+            if e["kind"] == "arc" {+                let cw = super::dxf::bulge(e) < 0.0;+                o += &format!("          <PolyStepCurve x=\"{}\" y=\"{}\" centerX=\"{}\" centerY=\"{}\" clockwise=\"{cw}\"/>\n", n(end.0), n(end.1), n(f(&e["center"][0])), n(f(&e["center"][1])));+            } else {+                o += &format!("          <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(end.0), n(end.1));+            }+        }+    } else {+        let ring: Vec<(f64, f64)> = st["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+        if let Some(first) = ring.first() {+            o += &format!("          <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(first.0), n(first.1));+            for p in ring.iter().skip(1).chain(std::iter::once(first)) { o += &format!("          <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(p.0), n(p.1)); }+        }+    }+    o + "        </Polygon>\n"+}++/// A ring of [x, y] as an IPC-2581 Polygon or Cutout (closed).+fn ring_xml(tag: &str, ring: &[(f64, f64)], ind: &str) -> String {+    let mut o = format!("{ind}<{tag}>\n{ind}  <PolyBegin x=\"{}\" y=\"{}\"/>\n", n(ring[0].0), n(ring[0].1));+    for p in ring.iter().skip(1).chain(std::iter::once(&ring[0])) { o += &format!("{ind}  <PolyStepSegment x=\"{}\" y=\"{}\"/>\n", n(p.0), n(p.1)); }+    o + &format!("{ind}</{tag}>\n")+}++/// The board as IPC-2581C text and what it holds.+pub fn ipc2581_text(st: &Value, input: &Value, title: &str, date: &str) -> (String, Value) {+    let arr = |v: &Value, k: &str| v[k].as_array().cloned().unwrap_or_default();+    let name = { let s: String = ascii(title).chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect(); if s.is_empty() { "board".into() } else { s } };+    let copper = crate::placetools::copper_layers(st, input);+    let lname = |i: usize| if i == 0 { "TOP".to_string() } else if i + 1 == copper.len() { "BOTTOM".to_string() } else { format!("INNER{i}") };+    let idx = |l: &str| copper.iter().position(|c| c == l);+    let mut dict = Dict::default();+    let comps = arr(st, "components");+    let pads = arr(st, "pads");+    let vias = arr(st, "vias");+    let stack = crate::input::stack(input);++    // the BOM: one item per pattern (and value, when the model carries one)+    let mut groups: Vec<((String, String), Vec<&Value>)> = Vec::new();+    for c in &comps {+        if !pads.iter().any(|p| p["ref"] == c["ref"]) { continue; }+        let k = (c["pattern"].as_str().unwrap_or("").to_string(), c["value"].as_str().unwrap_or("").to_string());+        match groups.iter_mut().find(|g| g.0 == k) { Some(g) => g.1.push(c), None => groups.push((k, vec![c])) }+    }+    groups.sort_by(|x, y| x.0.cmp(&y.0));+    let part_id = |p: &str, v: &str| { let s = a(&format!("{p}_{v}")); let s = s.trim_matches('_').to_string(); if s.is_empty() { "PART".to_string() } else { s } };+    let mut bom = format!("  <Bom name=\"{name}_BOM\">\n    <BomHeader assembly=\"{name}\" revision=\"1\">\n      <StepRef name=\"{name}\"/>\n    </BomHeader>\n");+    for ((pat, val), cs) in &groups {+        bom += &format!("    <BomItem OEMDesignNumberRef=\"{}\" quantity=\"{}\" category=\"ELECTRICAL\" description=\"{}\">\n", part_id(pat, val), cs.len(), a(val));+        for c in cs {+            bom += &format!("      <RefDes name=\"{}\" packageRef=\"{}\" populate=\"true\" layerRef=\"{}\"/>\n", a(c["ref"].as_str().unwrap_or("")), a(pat), if c["side"] == "bottom" { "BOTTOM" } else { "TOP" });+        }+        bom += "      <Characteristics category=\"ELECTRICAL\">\n";+        if !val.is_empty() { bom += &format!("        <Textual definitionSource=\"board model\" textualCharacteristicName=\"Value\" textualCharacteristicValue=\"{}\"/>\n", a(val)); }+        bom += "      </Characteristics>\n    </BomItem>\n";+    }+    bom += "  </Bom>\n";++    // layers and the stack-up+    let mut cad = String::from("    <CadData>\n");+    for (i, _) in copper.iter().enumerate() {+        let side = if i == 0 { "TOP" } else if i + 1 == copper.len() { "BOTTOM" } else { "INTERNAL" };+        cad += &format!("      <Layer name=\"{}\" layerFunction=\"SIGNAL\" side=\"{side}\" polarity=\"POSITIVE\"/>\n", lname(i));+    }+    cad += "      <Layer name=\"DRILL_TOP_BOTTOM\" layerFunction=\"DRILL\" side=\"ALL\" polarity=\"POSITIVE\"/>\n";+    let total = stack.as_ref().and_then(|s| s["totalUm"].as_f64()).filter(|t| *t > 0.0).unwrap_or(1600.0) / 1000.0;+    let cu: Vec<f64> = stack.as_ref().map(|s| arr(s, "layers").iter().map(|l| f(&l["copperUm"])).filter(|c| *c > 0.0).collect()).unwrap_or_default();+    cad += &format!("      <Stackup name=\"PRIMARY\" overallThickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\" whereMeasured=\"METAL\" stackupStatus=\"PROPOSED\">\n        <StackupGroup name=\"PRIMARY\" thickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\">\n", n(total), n(total));+    for i in 0..copper.len() {+        cad += &format!("          <StackupLayer layerOrGroupRef=\"{}\" thickness=\"{}\" tolPlus=\"0\" tolMinus=\"0\" sequence=\"{}\"/>\n", lname(i), n(cu.get(i).copied().unwrap_or(35.0) / 1000.0), i + 1);+    }+    cad += "        </StackupGroup>\n      </Stackup>\n";+    let mut step = format!("      <Step name=\"{name}\">\n");++    // via padstacks, one per size and drill+    let mut vstacks: Vec<((String, String), String)> = Vec::new();+    for v in &vias {+        let key = (n(f(&v["size"])), n(f(&v["drill"])));+        if vstacks.iter().any(|x| x.0 == key) { continue; }+        let vn = format!("VIA_{}_{}", key.0, key.1).replace('.', "_");+        let prim = dict.pad("round", f(&v["size"]), f(&v["size"]));+        step += &format!("        <PadStackDef name=\"{vn}\">\n          <PadstackHoleDef name=\"H{vn}\" diameter=\"{}\" platingStatus=\"VIA\" plusTol=\"0\" minusTol=\"0\" x=\"0\" y=\"0\"/>\n", key.1);+        for i in 0..copper.len() {+            step += &format!("          <PadstackPadDef layerRef=\"{}\" padUse=\"REGULAR\">\n            <Location x=\"0\" y=\"0\"/>\n            <StandardPrimitiveRef id=\"{prim}\"/>\n          </PadstackPadDef>\n", lname(i));+        }+        step += "        </PadStackDef>\n";+        vstacks.push((key, vn));+    }+    step += "        <Datum x=\"0\" y=\"0\"/>\n        <Profile>\n";+    step += &profile_polygon(st).replace("        <Polygon>", "          <Polygon>").replace("\n          <Poly", "\n            <Poly").replace("        </Polygon>", "          </Polygon>");+    step += "        </Profile>\n";++    // packages: one per pattern, pins in the footprint's own frame (mirrored back for a bottom part)+    let mut made: Vec<(String, bool)> = Vec::new();+    let mut pkg_xml = String::new();+    for c in &comps {+        let rf = c["ref"].as_str().unwrap_or("");+        let own: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(rf)).collect();+        let pat = { let p = c["pattern"].as_str().unwrap_or(""); if p.is_empty() { format!("PKG_{rf}") } else { p.to_string() } };+        if own.is_empty() || made.iter().any(|m| m.0 == pat) { continue; }+        let (cx, cy, th) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]).to_radians());+        let bottom = c["side"] == "bottom";+        let th_part = own.iter().any(|p| f(&p["drill"]) > 0.0);+        let mut pins = String::new();+        let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+        for p in &own {+            let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+            let (mut lx, ly) = (dx * th.cos() + dy * th.sin(), -dx * th.sin() + dy * th.cos());+            if bottom { lx = -lx; }+            let mut rot = (f(&p["rotation"]) - f(&c["rotation"])).rem_euclid(360.0);+            if bottom { rot = (360.0 - rot).rem_euclid(360.0); }+            let (w, h) = (f(&p["sizeX"]), f(&p["sizeY"]));+            let half = w.max(h).max(f(&p["drill"])) / 2.0;+            bb = (bb.0.min(lx - half), bb.1.min(ly - half), bb.2.max(lx + half), bb.3.max(ly + half));+            let prim = dict.pad(p["shape"].as_str().unwrap_or("rect"), w, h);+            pins += &format!("          <Pin number=\"{}\" type=\"{}\">\n", a(p["name"].as_str().unwrap_or("")), if f(&p["drill"]) > 0.0 { "THRU" } else { "SURFACE" });+            if rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6 { pins += &format!("            <Xform rotation=\"{}\"/>\n", n(rot)); }+            pins += &format!("            <Location x=\"{}\" y=\"{}\"/>\n            <StandardPrimitiveRef id=\"{prim}\"/>\n          </Pin>\n", n(lx), n(ly));+        }+        pkg_xml += &format!("        <Package name=\"{}\" type=\"OTHER\" pinOneOrientation=\"OTHER\">\n          <Outline>\n{}            <LineDesc lineEnd=\"ROUND\" lineWidth=\"0.1\"/>\n          </Outline>\n{pins}        </Package>\n",+            a(&pat), ring_xml("Polygon", &[(bb.0, bb.1), (bb.2, bb.1), (bb.2, bb.3), (bb.0, bb.3)], "            "));+        made.push((pat, th_part));+    }+    step += &pkg_xml;+    for c in &comps {+        let rf = c["ref"].as_str().unwrap_or("");+        let pat = { let p = c["pattern"].as_str().unwrap_or(""); if p.is_empty() { format!("PKG_{rf}") } else { p.to_string() } };+        let Some((_, th_part)) = made.iter().find(|m| m.0 == pat) else { continue };+        if !pads.iter().any(|p| p["ref"].as_str() == Some(rf)) { continue; }+        let bottom = c["side"] == "bottom";+        step += &format!("        <Component refDes=\"{}\" packageRef=\"{}\" part=\"{}\" layerRef=\"{}\" mountType=\"{}\">\n", a(rf), a(&pat),+            part_id(c["pattern"].as_str().unwrap_or(""), c["value"].as_str().unwrap_or("")), if bottom { "BOTTOM" } else { "TOP" }, if *th_part { "THMT" } else { "SMT" });+        let rot = f(&c["rotation"]).rem_euclid(360.0);+        let has_rot = rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6;+        if has_rot || bottom {+            step += &format!("          <Xform{}{}/>\n", if has_rot { format!(" rotation=\"{}\"", n(rot)) } else { String::new() }, if bottom { " mirror=\"true\"" } else { "" });+        }+        step += &format!("          <Location x=\"{}\" y=\"{}\"/>\n        </Component>\n", n(f(&c["x"])), n(f(&c["y"])));+    }+    // nets with their pins+    let mut nets: Vec<(String, Vec<&Value>)> = Vec::new();+    for p in &pads {+        let (Some(net), Some(r)) = (p["net"].as_str().filter(|s| !s.is_empty()), p["ref"].as_str().filter(|s| !s.is_empty())) else { continue };+        let _ = r;+        match nets.iter_mut().find(|x| x.0 == net) { Some(x) => x.1.push(p), None => nets.push((net.to_string(), vec![p])) }+    }+    nets.sort_by(|x, y| x.0.cmp(&y.0));+    for (net, ps) in &nets {+        step += &format!("        <LogicalNet name=\"{}\">\n", a(net));+        for p in ps { step += &format!("          <PinRef componentRef=\"{}\" pin=\"{}\"/>\n", a(p["ref"].as_str().unwrap_or("")), a(p["name"].as_str().unwrap_or(""))); }+        step += "        </LogicalNet>\n";+    }++    // copper, layer by layer: sets of features by net and use+    let zones = crate::input::pours(input);+    let (mut n_pads, mut n_traces, mut n_arcs, mut n_vias, mut n_islands) = (0, 0, 0, 0, 0);+    for (li, layer) in copper.iter().enumerate() {+        let mut sets: Vec<((String, &str), String)> = Vec::new();+        let mut put = |net: &str, kind: &'static str, body: String| {+            match sets.iter_mut().find(|s| s.0 .0 == net && s.0 .1 == kind) { Some(s) => s.1 += &body, None => sets.push(((net.to_string(), kind), body)) }+        };+        for p in &pads {+            let on = p["layer"].as_str() == Some(layer.as_str()) || p["layer"] == "*.Cu" || f(&p["drill"]) > 0.0;+            if !on { continue; }+            let rot = f(&p["rotation"]).rem_euclid(360.0);+            let prim = dict.pad(p["shape"].as_str().unwrap_or("rect"), f(&p["sizeX"]), f(&p["sizeY"]));+            let mut b = String::from("            <Pad>\n");+            if rot.abs() > 1e-6 && (rot - 360.0).abs() > 1e-6 { b += &format!("              <Xform rotation=\"{}\"/>\n", n(rot)); }+            b += &format!("              <Location x=\"{}\" y=\"{}\"/>\n              <StandardPrimitiveRef id=\"{prim}\"/>\n", n(f(&p["x"])), n(f(&p["y"])));+            if let Some(r) = p["ref"].as_str().filter(|s| !s.is_empty()) { b += &format!("              <PinRef componentRef=\"{}\" pin=\"{}\"/>\n", a(r), a(p["name"].as_str().unwrap_or(""))); }+            b += "            </Pad>\n";+            put(p["net"].as_str().unwrap_or(""), "pad", b);+            n_pads += 1;+        }+        for v in &vias {+            let ls: Vec<usize> = v["layers"].as_array().map(|x| x.iter().filter_map(|l| l.as_str().and_then(idx)).collect()).unwrap_or_default();+            let (lo, hi) = if ls.len() == 2 { (ls[0].min(ls[1]), ls[0].max(ls[1])) } else { (0, copper.len() - 1) };+            if li < lo || li > hi { continue; }+            let key = (n(f(&v["size"])), n(f(&v["drill"])));+            let vn = &vstacks.iter().find(|x| x.0 == key).unwrap().1;+            let prim = dict.pad("round", f(&v["size"]), f(&v["size"]));+            put(v["net"].as_str().unwrap_or(""), "via", format!("            <Pad padstackDefRef=\"{vn}\">\n              <Location x=\"{}\" y=\"{}\"/>\n              <StandardPrimitiveRef id=\"{prim}\"/>\n            </Pad>\n", n(f(&v["position"]["x"])), n(f(&v["position"]["y"]))));+            n_vias += 1;+        }+        for t in arr(st, "tracks").iter().filter(|t| t["layer"].as_str() == Some(layer.as_str())) {+            put(t["net"].as_str().unwrap_or(""), "trace", format!("            <Features>\n              <Line startX=\"{}\" startY=\"{}\" endX=\"{}\" endY=\"{}\">\n                <LineDesc lineEnd=\"ROUND\" lineWidth=\"{}\"/>\n              </Line>\n            </Features>\n",+                n(f(&t["start"]["x"])), n(f(&t["start"]["y"])), n(f(&t["end"]["x"])), n(f(&t["end"]["y"])), n(f(&t["width"]))));+            n_traces += 1;+        }+        for r in arr(st, "arcs").iter().filter(|r| r["layer"].as_str() == Some(layer.as_str())) {+            let (cx, cy, rad) = (f(&r["center"]["x"]), f(&r["center"]["y"]), f(&r["radius"]));+            let (s0, s1) = (f(&r["startAngle"]).to_radians(), f(&r["endAngle"]).to_radians());+            put(r["net"].as_str().unwrap_or(""), "trace", format!("            <Features>\n              <Arc startX=\"{}\" startY=\"{}\" endX=\"{}\" endY=\"{}\" centerX=\"{}\" centerY=\"{}\" clockwise=\"false\">\n                <LineDesc lineEnd=\"ROUND\" lineWidth=\"{}\"/>\n              </Arc>\n            </Features>\n",+                n(cx + rad * s0.cos()), n(cy + rad * s0.sin()), n(cx + rad * s1.cos()), n(cy + rad * s1.sin()), n(cx), n(cy), n(f(&r["width"]))));+            n_arcs += 1;+        }+        for z in zones.iter().filter(|z| z["layer"].as_str() == Some(layer.as_str())) {+            for fill in arr(z, "fills") {+                let ring = |v: &Value| -> Vec<(f64, f64)> { v.as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect() };+                let outer = ring(&fill["outline"]);+                if outer.len() < 3 { continue; }+                let mut b = String::from("            <Features>\n              <Contour>\n");+                b += &ring_xml("Polygon", &outer, "                ");+                for h in arr(&fill, "holes") { let h = ring(&h); if h.len() >= 3 { b += &ring_xml("Cutout", &h, "                "); } }+                b += "              </Contour>\n            </Features>\n";+                put(z["net"].as_str().unwrap_or(""), "pour", b);+                n_islands += 1;+            }+        }+        step += &format!("        <LayerFeature layerRef=\"{}\">\n", lname(li));+        for ((net, kind), body) in &sets {+            let mut attrs = if net.is_empty() { String::new() } else { format!(" net=\"{}\"", a(net)) };+            if *kind == "pad" { attrs += " padUsage=\"TERMINATION\""; } else if *kind == "via" { attrs += " padUsage=\"VIA\""; }+            step += &format!("          <Set{attrs}>\n{body}          </Set>\n");+        }+        step += "        </LayerFeature>\n";+    }+    // the drills: plated pad holes (a hole with no copper ring round it is non-plated), vias+    let mut holes: Vec<(String, f64, f64, f64, &str)> = pads.iter().filter(|p| f(&p["drill"]) > 0.0)+        .map(|p| (p["net"].as_str().unwrap_or("").to_string(), f(&p["x"]), f(&p["y"]), f(&p["drill"]), if f(&p["sizeX"]).min(f(&p["sizeY"])) <= f(&p["drill"]) + 1e-6 { "NONPLATED" } else { "PLATED" })).collect();+    holes.extend(vias.iter().map(|v| (v["net"].as_str().unwrap_or("").to_string(), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["drill"]), "VIA")));+    step += "        <LayerFeature layerRef=\"DRILL_TOP_BOTTOM\">\n";+    let mut hnets: Vec<&String> = holes.iter().map(|h| &h.0).collect();+    hnets.sort();+    hnets.dedup();+    let mut k = 0;+    for net in hnets {+        step += &format!("          <Set{}>\n", if net.is_empty() { String::new() } else { format!(" net=\"{}\"", a(net)) });+        for h in holes.iter().filter(|h| &h.0 == net) {+            k += 1;+            step += &format!("            <Hole name=\"H{k}\" diameter=\"{}\" platingStatus=\"{}\" plusTol=\"0\" minusTol=\"0\" x=\"{}\" y=\"{}\"/>\n", n(h.3), h.4, n(h.1), n(h.2));+        }+        step += "          </Set>\n";+    }+    step += "        </LayerFeature>\n      </Step>\n";+    cad += &step;+    cad += "    </CadData>\n";++    let soft = format!("adom-eda-checks {}", env!("CARGO_PKG_VERSION"));+    let mut o = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<IPC-2581 revision=\"C\" xmlns=\"http://webstds.ipc.org/2581\">\n  <Content roleRef=\"Owner\">\n    <FunctionMode mode=\"ASSEMBLY\"/>\n");+    o += &format!("    <StepRef name=\"{name}\"/>\n");+    for i in 0..copper.len() { o += &format!("    <LayerRef name=\"{}\"/>\n", lname(i)); }+    o += "    <LayerRef name=\"DRILL_TOP_BOTTOM\"/>\n";+    o += &format!("    <BomRef name=\"{name}_BOM\"/>\n");+    o += &dict.xml();+    o += "  </Content>\n  <LogisticHeader>\n    <Role id=\"Owner\" roleFunction=\"SENDER\"/>\n    <Enterprise id=\"Adom\" code=\"NONE\"/>\n    <Person name=\"adom-eda-checks\" enterpriseRef=\"Adom\" roleRef=\"Owner\"/>\n  </LogisticHeader>\n";+    o += &format!("  <HistoryRecord number=\"1\" origination=\"{date}\" software=\"{soft}\" lastChange=\"{date}\">\n    <FileRevision fileRevisionId=\"1\" comment=\"from the board model\">\n      <SoftwarePackage name=\"adom-eda-checks\" revision=\"{}\" vendor=\"Adom\">\n        <Certification certificationStatus=\"SELFTEST\"/>\n      </SoftwarePackage>\n    </FileRevision>\n  </HistoryRecord>\n", env!("CARGO_PKG_VERSION"));+    o += &bom;+    o += "  <Ecad name=\"";+    o += &name;+    o += "\">\n    <CadHeader units=\"MILLIMETER\"/>\n";+    o += &cad;+    o += "  </Ecad>\n</IPC-2581>\n";+    let counts = json!({"layers": copper.len() + 1, "packages": made.len(), "components": comps.iter().filter(|c| pads.iter().any(|p| p["ref"] == c["ref"])).count(), "nets": nets.len(),+        "pads": n_pads, "traces": n_traces, "arcs": n_arcs, "vias": n_vias, "pourIslands": n_islands, "holes": k});+    (o, counts)+}++/// The current UTC time as an xsd:dateTime, for the history record when the caller gives no `date`.+fn now() -> String {+    let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);+    let (days, rem) = (s.div_euclid(86400), s.rem_euclid(86400));+    // civil from days (Howard Hinnant)+    let z = days + 719468;+    let era = z.div_euclid(146097);+    let doe = z - era * 146097;+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+    let mp = (5 * doy + 2) / 153;+    let d = doy - (153 * mp + 2) / 5 + 1;+    let m = if mp < 10 { mp + 3 } else { mp - 9 };+    let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };+    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)+}++/// writers::ipc2581: `{board, stack?, pours?, args: {title?, date?}}` -> the IPC-2581C text and what it holds.+pub fn export_ipc2581(input: &Value) -> Value {+    let board = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = input.get("args").cloned().unwrap_or(json!({}));+    let title = args["title"].as_str().unwrap_or("board");+    let date = args["date"].as_str().map(String::from).unwrap_or_else(now);+    let (text, counts) = ipc2581_text(&board, input, title, &date);+    let poured = !crate::input::pours(input).is_empty();+    json!({"success": true, "text": text, "counts": counts, "format": "IPC-2581 revision C", "mode": "ASSEMBLY", "units": "mm", "writtenFrom": "board model",+        "_hint": if poured { "IPC-2581C written from the board model (valid against the IPC-2581C schema): layers and stack-up, outline, packages and components, BOM, nets, and on each copper layer the pads, traces, arcs, vias and the poured copper island by island, plus the drills. Solder mask, paste and silkscreen are not in it: a fab order goes through the tool's Gerbers or ODB++." }+                 else { "IPC-2581C written from the board model, without pours: pass pour_state as pours to add the poured copper island by island. Solder mask, paste and silkscreen are not in it: a fab order goes through the tool's Gerbers or ODB++." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "arc", "start": [30.0, 20.0], "end": [0.0, 20.0], "center": [15.0, 20.0], "radius": 15.0, "angle1": 0.0, "angle2": 180.0}, {"kind": "line", "start": [0.0, 20.0]}],+            "components": [{"ref": "U1", "x": 10.0, "y": 10.0, "rotation": 90.0, "side": "top", "pattern": "SOT23", "value": "LDO 3.3V"}, {"ref": "R1", "x": 20.0, "y": 5.0, "rotation": 0.0, "side": "bottom", "pattern": "0603", "value": "10kΩ"},+                           {"ref": "LOGO", "x": 2.0, "y": 2.0, "rotation": 0.0, "side": "top", "pattern": "LOGO"}],+            "pads": [{"ref": "U1", "name": "1", "x": 9.05, "y": 8.9, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "VIN"},+                     {"ref": "U1", "name": "2", "x": 10.95, "y": 8.9, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "GND"},+                     {"ref": "U1", "name": "3", "x": 10.0, "y": 11.1, "sizeX": 0.6, "sizeY": 0.7, "rotation": 90.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "VOUT"},+                     {"ref": "R1", "name": "1", "x": 19.2, "y": 5.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "VOUT"},+                     {"ref": "R1", "name": "2", "x": 20.8, "y": 5.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "GND"},+                     {"ref": "", "name": "", "x": 3.0, "y": 17.0, "sizeX": 3.2, "sizeY": 3.2, "rotation": 0.0, "shape": "round", "layer": "*.Cu", "drill": 3.2, "net": ""}],+            "tracks": [{"layer": "F.Cu", "width": 0.25, "net": "VOUT", "start": {"x": 10.0, "y": 11.1}, "end": {"x": 15.0, "y": 11.1}}],+            "arcs": [{"layer": "B.Cu", "width": 0.25, "net": "VOUT", "center": {"x": 17.0, "y": 8.0}, "radius": 2.0, "startAngle": 0.0, "endAngle": 90.0}],+            "vias": [{"position": {"x": 15.0, "y": 11.1}, "size": 0.6, "drill": 0.3, "layers": ["F.Cu", "B.Cu"], "net": "VOUT"}]})+    }++    #[test]+    fn the_board_comes_out_as_ipc_2581c() {+        let pours = json!({"zones": [{"net": "GND", "layer": "B.Cu", "fills": [{"outline": [[1.0, 1.0], [29.0, 1.0], [29.0, 9.0], [1.0, 9.0]], "holes": [[[18.5, 4.0], [21.5, 4.0], [21.5, 6.0], [18.5, 6.0]]]}]}]});+        let r = export_ipc2581(&json!({"board": board(), "pours": pours, "args": {"title": "Test Board", "date": "2026-10-29T00:00:00Z"}}));+        let t = r["text"].as_str().unwrap();+        assert!(t.starts_with("<?xml") && t.contains("<IPC-2581 revision=\"C\""));+        assert_eq!(r["counts"], json!({"layers": 3, "packages": 2, "components": 2, "nets": 3, "pads": 7, "traces": 1, "arcs": 1, "vias": 2, "pourIslands": 1, "holes": 2}), "{}", r["counts"]);+        // U1's pin 1 back in the footprint's frame: (9.05, 8.9) less (10, 10), turned back 90 degrees -> (-1.1, 0.95)+        assert!(t.contains("<Pin number=\"1\" type=\"SURFACE\">\n            <Location x=\"-1.1\" y=\"0.95\"/>"), "{t}");+        // the bottom resistor is mirrored, the mounting hole has no ring so it is non-plated, the ohm sign is spelled out+        assert!(t.contains("refDes=\"R1\" packageRef=\"0603\" part=\"0603_10kohm\" layerRef=\"BOTTOM\" mountType=\"SMT\">\n          <Xform mirror=\"true\"/>"));+        assert!(t.contains("platingStatus=\"NONPLATED\"") && t.contains("platingStatus=\"VIA\""));+        assert!(t.contains("<PolyStepCurve x=\"0\" y=\"20\" centerX=\"15\" centerY=\"20\" clockwise=\"false\"/>"), "{t}");+        assert!(t.contains("<Cutout>") && t.contains("<Oval") == false && t.contains("<Circle diameter=\"3.2\"/>"));+        assert!(!t.contains("LOGO"), "a component with no pads is no part");+        assert_eq!(n(0.10000001), "0.1");+        assert_eq!(n(-0.00001), "0");+        assert_eq!(ascii("10kΩ ±5%"), "10kohm +/-5%");+    }++    #[test]+    fn the_history_date_is_an_xsd_date_time() {+        let d = now();+        assert_eq!(d.len(), 20);+        assert!(d.starts_with("20") && d.ends_with('Z') && &d[10..11] == "T", "{d}");+    }+}
rust/vendor/adom-eda-checks/src/writers/mod.rs+5−3
@@ -1,5 +1,5 @@-//! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD and DXF from the-//! board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). Like the checks, no I/O and no tool: each returns the file text+//! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD, DXF and IPC-2581C+//! from the board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). Like the checks, no I/O and no tool: each returns the file text //! or the regions as JSON, says it was written from the board model (`writtenFrom`), and the bridge writes the files or //! places the regions. A bridge whose tool has a native exporter keeps it. use serde_json::Value;@@ -7,10 +7,11 @@ use serde_json::Value; pub mod body3d; pub mod dxf; pub mod gencad;+pub mod ipc2581; pub mod svgart;  /// The verbs these writers answer, by standard name.-pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "generate_body", "import_svg"];+pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "export_ipc2581", "generate_body", "import_svg"];  pub use dxf::dxf_layers; pub use gencad::gencad;@@ -21,6 +22,7 @@ pub fn run(verb: &str, input: &Value) -> Option<Value> {     Some(match verb {         "export_dxf" => dxf_layers(input),         "export_gencad" => gencad(input),+        "export_ipc2581" => ipc2581::export_ipc2581(input),         "generate_body" => body3d::generate_body(input),         "import_svg" => svg_to_regions(input),         _ => return None,
tests/run_verb_tests.py+3−1
@@ -256,6 +256,8 @@ t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": t("fusion_estimate_cost", "daily", dict(B, quantities=[5, 30], assembly=True), {"ok": True, "fields": ["prices", "source", "engine"]}) t("fusion_read_board", "daily", dict(B, sections="components,nets,rules,outline", limit=5), {"ok": True, "fields": ["data", "counts"]}) t("fusion_diff_pairs", "daily", dict(B), {"ok": True, "fields": ["pairs"]}, "BQ25792 pairs D_P/D_N by name")+for v in ("fusion_sch_place_part", "fusion_sch_connect", "fusion_sch_swap_part"):+    t(v, "manual", note="edits the schematic (and the board with it): tested live on lane-BQ25792-copy with a part placed and deleted again") t("fusion_add_rule", "manual", note="edits the board's net classes: tested live on lane-BQ25792-copy and put back") t("fusion_height_check", "daily", dict(B, maxTopMm=8, minGapMm=0.1), {"ok": True, "fields": ["heights", "noBody", "engine"]}, "reads fusion_bodies (switches to the 3D PCB and back)") t("fusion_placement_check", "daily", dict(B, marginMm=0.1), {"ok": True, "fields": ["parts", "courtyardOverlaps", "engine"]})@@ -499,7 +501,7 @@ _SECTION_BY_VERB = {     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",     "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_export_library_part": "cad-3d-bom", "fusion_edit_footprint": "cad-3d-bom", "fusion_create_footprint": "cad-3d-bom", "fusion_create_symbol": "cad-3d-bom", "fusion_read_symbol": "cad-3d-bom", "fusion_find_symbol": "cad-3d-bom", "fusion_copy_footprint": "cad-3d-bom", "fusion_rename_footprint": "cad-3d-bom", "fusion_remove_footprint": "cad-3d-bom", "fusion_xcopy_footprint": "cad-3d-bom", "fusion_clone_symbol": "cad-3d-bom", "fusion_rename_symbol": "cad-3d-bom", "fusion_remove_symbol": "cad-3d-bom", "fusion_set_params": "cad-3d-bom", "fusion_read_footprint": "cad-3d-bom", "fusion_cross_probe": "routing", "fusion_project_info": "routing", "fusion_schematic_data": "routing", "fusion_part_parameters": "routing", "fusion_cross_check": "routing", "fusion_doc_params": "routing", "fusion_sheet_params": "routing", "fusion_sch_edit": "routing", "fusion_annotate": "routing", "fusion_footprint_primitives": "routing", "fusion_thermal_vias": "routing", "fusion_via_fence": "routing", "fusion_board_features": "routing", "fusion_export_pdf": "routing", "fusion_project_create": "routing", "fusion_project_rename": "routing", "fusion_project_snapshot": "routing", "fusion_import_svg": "routing", "fusion_copy_routing": "routing", "fusion_set_layer_count": "routing", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_copper_balance": "routing", "fusion_return_path": "routing", "fusion_impedance": "routing", "fusion_current_capacity": "routing", "fusion_fab_check": "routing", "fusion_estimate_cost": "routing", "fusion_read_board": "routing", "fusion_diff_pairs": "routing", "fusion_sch_place_part": "routing", "fusion_sch_connect": "routing", "fusion_sch_swap_part": "routing", "fusion_add_rule": "routing", "fusion_height_check": "routing", "fusion_placement_check": "routing", "fusion_check_clearance": "routing", "fusion_place_decoupling": "routing", "fusion_plan_fanout": "routing", "fusion_export_odb": "exports", "fusion_export_ipc356": "exports", "fusion_export_gencad": "exports", "fusion_export_ipc2581": "exports", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",