← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.12.4\ No newline at end of file+1.12.5\ No newline at end of file
CHANGELOG.md+15
@@ -3,6 +3,21 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.12.5 (2026-10-27, insiders)++The checks tier through the EDA Bridge Standard's shared engine. 256 verbs.++- `adom-eda-checks` 0.1.1 is vendored from the standard (0.4.2, commit 680ab69; `rust/vendor/adom-eda-checks/VENDOR.md`+  says how to update it, never by editing the copy) and linked into the exe.+- New: `fusion_copper_balance`, `fusion_return_path`, `fusion_impedance`, `fusion_current_capacity`, `fusion_fab_check`.+  Their input comes from one export of the live board (the board model, stack-up, Fusion's pour fills, the rules+  with copper clearances in the standard's `{kind: clearance, clearanceMm}`, the silkscreen), and the engine's+  follow-up verbs come back as this bridge's (`fusion_apply_pours`, `fusion_add_rule`).+  `fab_check {vendor}` fetches the record for the board's copper layer count from adom/pcb-design-rules.+- On BQ25792: JLCPCB's 4-layer record finds R3.1's pad 0.405 mm past the board edge (minimum 0.2 mm, routed edge),+  and 5 A on VBAT needs 2.8 mm of outer copper where the narrowest is 0.81 mm.+- Daily suite: 117 pass on DESKTOP-BAR6S0F; the 5 failures are the APS verbs without the APS sign-in.+ ## 1.12.4 (2026-10-27, insiders)  Standard parity, round three: the verbs credited by name now answer the standard's shape, and eight more. 251 verbs.
README.md+7−2
@@ -527,7 +527,7 @@ The AI does the whole setup - the user never gets handed a download link: ## Full verb catalog  All verbs return a rich `_hint`. `fusion_describe '{}'` is the machine-readable source of truth; this table is generated from it.-_251 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.+_256 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`.  **Lifecycle and readiness** @@ -745,11 +745,15 @@ _251 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_change_pours` - Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. - `fusion_component_data` - Placement data for one part or every part on the live board, in the background: footprint, value (comment), library, side and copper layer, position and... - `fusion_component_pins` - Every pad of one part with its net, position, layer, size, shape and drill (mm), the same rows fusion_routing_state gives, for one part.+- `fusion_copper_balance` - Copper per layer and how evenly mirrored layers match (bow and twist through reflow), from the board's tracks, pads and Fusion's pour fills: the shared checks... - `fusion_copper_layers` - The board's copper layers in stack order (F.Cu, In1.Cu.., B.Cu) with Fusion's layer id, signal or plane, whether displayed, and primitives on each.+- `fusion_current_capacity` - Per net and layer, the narrowest copper against the width a current needs (IPC-2221), and the vias a layer change needs: the shared checks engine on the live... - `fusion_edit_parts` - Edit placed parts (the standard's edit_parts): value, designator (newRef), any field (attribute; null removes it) and the lock, each read back. - `fusion_execute_text_command` - Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included).+- `fusion_fab_check` - The board's smallest features (track, clearance, drill, annular ring, edge clearance, silk...) compared with a fab's limits: vendor names a record in... - `fusion_generate_body` - The EDA Bridge Standard's generate_body, served by fusion_generate_package (Fusion's own package generator): a part's 3D body from its family and datasheet... - `fusion_get_preferences` - Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot...+- `fusion_impedance` - The track width (and pair gap) for a target impedance on each copper layer, from the board's stack-up: the shared checks engine. - `fusion_launch` - The EDA Bridge Standard's name for fusion_start: the same verb, same arguments and reply (marked aliasOf). - `fusion_layer_stack` - The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with... - `fusion_layers` - The board's layer set: id, name (copper by the standard's names, the rest by Fusion's own: SilkscreenTop, BoardOutline...), kind (copper, silkscreen, mask...@@ -780,6 +784,7 @@ _251 verbs._ Regenerate with `python3 tools/gen-readme-verbs.py`. - `fusion_remove_parts` - Remove placed parts by designator (the standard's remove_parts). - `fusion_remove_pours` - Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. - `fusion_repour` - Refill every copper pour on the live board (Fusion's ratsnest pass, with hidden fills shown again) and read the poured copper back: filled area per layer and...+- `fusion_return_path` - Signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (apply as fusion_apply_pours): the shared... - `fusion_ripup_nets` - Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way. - `fusion_rules_by_kind` - The ENABLED design rules grouped by kind, each group in priority order. - `fusion_run_command` - The EDA Bridge Standard's name for fusion_execute_text_command: the same verb, same arguments and reply (marked aliasOf).@@ -840,7 +845,7 @@ adom-wiki pkg install adom/fusion-bridge   # the skills pack for your AI  Auto-updates via `updateManifestUrl` (ab polls on launch + every 4h). Every release lands on the insiders manifest first, and the public manifest moves only after the pre-production gate, so the-two tiers can serve different versions. 251 verbs -+two tiers can serve different versions. 256 verbs - `fusion_describe '{}'` returns the full machine-readable catalog. More demos:  <video controls width="100%" src="https://wiki.adom.inc/api/v1/pages/fusion-bridge/files/library2-3d-montage.mp4"></video>
bridge.json+12−2
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.12.4",+  "version": "1.12.5",   "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.",@@ -72,9 +72,11 @@     "fusion_close_window",     "fusion_component_data",     "fusion_component_pins",+    "fusion_copper_balance",     "fusion_copper_layers",     "fusion_create_cloud_folder",     "fusion_create_library",+    "fusion_current_capacity",     "fusion_delete_cloud_file",     "fusion_demo",     "fusion_demo_progress",@@ -114,6 +116,7 @@     "fusion_export_step",     "fusion_export_stl",     "fusion_export_usdz",+    "fusion_fab_check",     "fusion_fetch_optimized_glb",     "fusion_generate_body",     "fusion_generate_package",@@ -121,6 +124,7 @@     "fusion_get_design_info",     "fusion_get_parameters",     "fusion_get_preferences",+    "fusion_impedance",     "fusion_import_electronics",     "fusion_import_step",     "fusion_inspect_bodies",@@ -197,6 +201,7 @@     "fusion_reset_workspace",     "fusion_resolve_seat_dialog",     "fusion_restart_for_addin",+    "fusion_return_path",     "fusion_ripup_nets",     "fusion_route_net",     "fusion_routing_drc",@@ -412,7 +417,12 @@       "edit_parts": 150,       "align_parts": 150,       "remove_graphics": 150,-      "remove_parts": 150+      "remove_parts": 150,+      "copper_balance": 150,+      "return_path": 150,+      "impedance": 150,+      "current_capacity": 150,+      "fab_check": 150     },     "prefixes": [       {
contracts/describe.json+102
@@ -1015,6 +1015,23 @@       "summary": "Every pad of one part with its net, position, layer, size, shape and drill (mm), the same rows fusion_routing_state gives, for one part.",       "timeoutSeconds": 60     },+    {+      "example": {},+      "input": {+        "expectDocument": "required: the board's name",+        "resolutionMm": "raster step, default 0.25"+      },+      "longRunning": false,+      "name": "fusion_copper_balance",+      "output": {+        "engine": "str",+        "layers": "[{layer, percent, ...}]",+        "pairs": "[{layers, percent, differencePoints, ok}]"+      },+      "statusVerb": null,+      "summary": "Copper per layer and how evenly mirrored layers match (bow and twist through reflow), from the board's tracks, pads and Fusion's pour fills: the shared checks engine (adom-eda-checks) on the live board.",+      "timeoutSeconds": 120+    },     {       "example": {         "expectDocument": "fusion-routing"@@ -1081,6 +1098,27 @@       "summary": "ONE-CALL library creation for web-control (one call = one consent). EVERY LIBRARY IS MULTI-PART - a single-part library is simply parts:[one entry], and there is no separate single-part verb to choose. Pass parts:[{package, lbrPath, modelPath}] for any number of parts; the older {libraryPath, stepPath} shape still works and is normalised into a one-element parts list, so both run the identical code path and the ETA scales with the part count either way. TWO entry shapes: A) {libraryPath, stepPath} - a ready .lbr plus the 3D; B) {kicadSymbolPath, kicadFootprintPath, stepPath, pinMap?} - KiCad sources; needs adom-lbr, which is Linux-container-only by platform rule, so on Windows/Mac boxes shape B refuses IMMEDIATELY (adom_lbr_unavailable, before any staging or Fusion launch - fusion_readiness reports kicadIngest up front). Convert at AUTHORING time in a container and use shape A. pinMap = explicit {pinName: padName} for when pin numbers do not match pad names (BGA-style). Chains convert -> bind 3D -> prove (four evidence frames + verdict) in the BACKGROUND and returns immediately; poll fusion_prove_result (stage narrates; top-level `verdict` proven/not_proven is the score - `stage:done` only means the chain finished). EVERY PART IS PROVED, not just the first: a 10-component library streams 40 evidence frames, FOUR per component (symbol, footprint, 3d, ganged) in assembly order, each tagged with `part` so a front end can group them as they arrive, and `provedParts[]` reports per-part pass/fail. The library is proven only if EVERY part is - one good part does not vouch for nine unchecked ones. On the FINAL fetch pass {inlineFrames:true} to get each evidence frame as a base64 thumbnailDataUrl a browser can render (plain polls return box paths only). The chain closes its library document on success AND failure - batches need no between-part sweeping. stepPath is required for a PROVEN library - no real 3D body without it.",       "timeoutSeconds": 30     },+    {+      "example": {+        "nets": {+          "VBUS": 3+        }+      },+      "input": {+        "expectDocument": "required: the board's name",+        "nets": "{net: amps} (required)",+        "tempRiseC": "default 10"+      },+      "longRunning": false,+      "name": "fusion_current_capacity",+      "output": {+        "engine": "str",+        "nets": "[{net, amps, layers: [{layer, narrowestMm, neededMm, ok}], ok}]"+      },+      "statusVerb": null,+      "summary": "Per net and layer, the narrowest copper against the width a current needs (IPC-2221), and the vias a layer change needs: the shared checks engine on the live board.",+      "timeoutSeconds": 120+    },     {       "example": {         "fileName": "verbtest-scratch",@@ -1854,6 +1892,29 @@       "summary": "Export to USDZ (AR / Hydrogen 3D).",       "timeoutSeconds": 300     },+    {+      "example": {+        "vendor": "jlcpcb"+      },+      "input": {+        "expectDocument": "required: the board's name",+        "fab": "a record instead",+        "limits": "{minTrack, minClearance, ...} mm",+        "process": "optional record process",+        "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)"+      },+      "longRunning": false,+      "name": "fusion_fab_check",+      "output": {+        "engine": "str",+        "fab": "the record",+        "measured": "{...}",+        "outsideLimits": "[{limit, boardMm, limitMm, at, source}]"+      },+      "statusVerb": null,+      "summary": "The board's smallest features (track, clearance, drill, annular ring, edge clearance, silk...) compared with a fab's limits: vendor names a record in adom/pcb-design-rules (fetched, the one for the board's copper layer count), or pass fab or limits. Each finding names the fab's page and the date it was read. The shared checks engine on the live board.",+      "timeoutSeconds": 120+    },     {       "example": {         "jobId": "abc123",@@ -2002,6 +2063,28 @@       "summary": "Read current Fusion appearance/navigation preferences (theme, activeTheme, invertScrollZoom, orbitScheme, modelingOrientation, gestureNav, cameraPivot, lengthUnit) as friendly values - the same keys fusion_set_preference accepts.",       "timeoutSeconds": 20     },+    {+      "example": {+        "z0": 50+      },+      "input": {+        "apply": "{scope}",+        "diff": "ohms, differential",+        "expectDocument": "required: the board's name",+        "gapMm": "pair gap",+        "z0": "ohms, single-ended"+      },+      "longRunning": false,+      "name": "fusion_impedance",+      "output": {+        "apply": "plan",+        "engine": "str",+        "layers": "[{layer, model, widthMm, ...}]"+      },+      "statusVerb": null,+      "summary": "The track width (and pair gap) for a target impedance on each copper layer, from the board's stack-up: the shared checks engine. apply:{scope} answers fusion_add_rule arguments (a plan; nothing changes).",+      "timeoutSeconds": 120+    },     {       "example": {         "filePath": "C:/Temp/TJA1050.fbrd"@@ -3516,6 +3599,25 @@       "summary": "ASYNC: returns immediately (started:true) and restarts Fusion in the background, SAVING the user's open documents first; poll fusion_readiness until ready with the new addinVersion (a restart takes 60-120s, longer than the relay budget; pass wait:true for the old blocking form). Finish add-in setup by restarting Fusion. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document.",       "timeoutSeconds": 300     },+    {+      "example": {},+      "input": {+        "expectDocument": "required: the board's name",+        "referenceNets": "[net] (default the nets that own pours)",+        "resolutionMm": "number"+      },+      "longRunning": false,+      "name": "fusion_return_path",+      "output": {+        "apply": "{verb: fusion_apply_pours, args}",+        "engine": "str",+        "findings": "[...]",+        "ok": "bool"+      },+      "statusVerb": null,+      "summary": "Signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (apply as fusion_apply_pours): the shared checks engine on the live board, its stack-up and Fusion's pour fills.",+      "timeoutSeconds": 120+    },     {       "example": {},       "input": {
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.12.4"+version = "1.12.5" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.12.4"+version = "1.12.5" edition = "2021"  [dependencies]
describe.py+20
@@ -231,6 +231,26 @@ _T = [      {"refs": "[ref] (required)", "allowLocked": "bool, default false", "expectDocument": "required: the board's name", "expectedRevision": "optional", "dryRun": "bool"},      {"removed": "[{ref, nets}]", "failed": "[{ref, error}]", "revision": "str"},      150, None, False, {"refs": ["R7", "R8"], "dryRun": True}),+    ('fusion_copper_balance', "Copper per layer and how evenly mirrored layers match (bow and twist through reflow), from the board's tracks, pads and Fusion's pour fills: the shared checks engine (adom-eda-checks) on the live board.",+     {"expectDocument": "required: the board's name", "resolutionMm": "raster step, default 0.25"},+     {"layers": "[{layer, percent, ...}]", "pairs": "[{layers, percent, differencePoints, ok}]", "engine": "str"},+     120, None, False, {}),+    ('fusion_return_path', "Signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (apply as fusion_apply_pours): the shared checks engine on the live board, its stack-up and Fusion's pour fills.",+     {"expectDocument": "required: the board's name", "referenceNets": "[net] (default the nets that own pours)", "resolutionMm": "number"},+     {"ok": "bool", "findings": "[...]", "apply": "{verb: fusion_apply_pours, args}", "engine": "str"},+     120, None, False, {}),+    ('fusion_impedance', "The track width (and pair gap) for a target impedance on each copper layer, from the board's stack-up: the shared checks engine. apply:{scope} answers fusion_add_rule arguments (a plan; nothing changes).",+     {"expectDocument": "required: the board's name", "z0": "ohms, single-ended", "diff": "ohms, differential", "gapMm": "pair gap", "apply": "{scope}"},+     {"layers": "[{layer, model, widthMm, ...}]", "apply": "plan", "engine": "str"},+     120, None, False, {'z0': 50}),+    ('fusion_current_capacity', 'Per net and layer, the narrowest copper against the width a current needs (IPC-2221), and the vias a layer change needs: the shared checks engine on the live board.',+     {"expectDocument": "required: the board's name", "nets": "{net: amps} (required)", "tempRiseC": "default 10"},+     {"nets": "[{net, amps, layers: [{layer, narrowestMm, neededMm, ok}], ok}]", "engine": "str"},+     120, None, False, {'nets': {'VBUS': 3}}),+    ('fusion_fab_check', "The board's smallest features (track, clearance, drill, annular ring, edge clearance, silk...) compared with a fab's limits: vendor names a record in adom/pcb-design-rules (fetched, the one for the board's copper layer count), or pass fab or limits. Each finding names the fab's page and the date it was read. The shared checks engine on the live board.",+     {"expectDocument": "required: the board's name", "vendor": "jlcpcb|pcbway|oshpark ... (adom/pcb-design-rules)", "process": "optional record process", "fab": "a record instead", "limits": "{minTrack, minClearance, ...} mm"},+     {"measured": "{...}", "outsideLimits": "[{limit, boardMm, limitMm, at, source}]", "fab": "the record", "engine": "str"},+     120, None, False, {'vendor': 'jlcpcb'}),     ("fusion_apply_silkscreen", "Land a silkscreen plan on the live board: designators (names) and values (comments) moved to their own location, sized, turned or hidden; free silkscreen texts and lines added (bottom ones mirrored); earlier free items removed by the raw handle fusion_silk_state gives. Parts not yet smashed are smashed first. Each pick is made with only the target's layer displayed, so a pad or wire is never taken instead, and the board's layer display is restored. Read back; if copper, parts or the outline moved, every command is undone. The edit side of a designator plan (silk_designators).",      {"expectDocument":"required exact board name","expectedRevision":"optional: refuse with stale_board if the board moved on","names":"[{ref, x, y, size?, rotation?, show?}] the designator text's own location (lower left before rotation), mm","comments":"[same] for the value text","texts":"[{text, x, y, size?, rotation?, side?: top|bottom}]","lines":"[{x1, y1, x2, y2, width? (0.15), side?}]","remove":"{texts:[raw], lines:[raw]} from fusion_silk_state"},      {"applied":"int","failed":"int","results":"[ok | err:...]","smashed":"[ref]","after":"silk_state","revision":"str"},120,"fusion_routing_state",False,{"expectDocument":"fusion-routing","names":[{"ref":"J1","x":10,"y":20,"size":1.5,"rotation":90}]}),
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.12.4"+DASH_VERSION="1.12.5" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.12.4",+  "version": "1.12.5",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.12.4",+  "version": "1.12.5",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -143,4 +143,4 @@     "name": "John Lauer",     "email": "[email protected]"   }-}\ No newline at end of file+}
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.12.4"+version = "1.12.5" edition = "2021" license = "MIT" 
rust/bridge/Cargo.toml+1
@@ -21,6 +21,7 @@ rusqlite = { version = "0.32", features = ["bundled"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } fusion-core = { path = "../core" } fusion-lbr = { path = "../lbr" }+adom-eda-checks = { path = "../vendor/adom-eda-checks" } serde_json.workspace = true tiny_http.workspace = true ureq.workspace = true
rust/bridge/src/checks.rsadded+47
@@ -0,0 +1,47 @@+//! The EDA Bridge Standard's checks tier through the shared engine (adom-eda-checks, vendored in+//! rust/vendor): the board model and its companions come from one export of the live board+//! (routing's check_inputs), the engine computes, and the reply names this bridge's verbs.+use serde_json::{json, Value};++pub fn owns(cmd: &str) -> bool { adom_eda_checks::CHECKS.contains(&cmd) }++/// A pcb-design-rules record for a fab (adom/pcb-design-rules rules/records/<vendor>.json): the+/// record for the board's copper layer count, or the asked process.+fn fab_record(vendor: &str, layers: u64, process: Option<&str>) -> Result<Value, String> {+    let slug: String = vendor.to_lowercase().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect();+    let url = format!("https://wiki.adom.inc/api/v1/pages/adom/pcb-design-rules/files/rules/records/{slug}.json");+    let (code, body) = fusion_core::aps::http_bytes(&url);+    if code != 200 { return Err(format!("no fab record for {vendor} (HTTP {code} from adom/pcb-design-rules)")); }+    let file: Value = serde_json::from_slice(&body).map_err(|e| format!("{url}: {e}"))?;+    let recs = file["records"].as_array().cloned().unwrap_or_default();+    let pick = recs.iter().find(|r| r["layerCount"].as_u64() == Some(layers) && process.map_or(true, |p| r["process"].as_str() == Some(p)))+        .or_else(|| recs.iter().find(|r| process.map_or(true, |p| r["process"].as_str() == Some(p))));+    match pick {+        Some(r) => { let mut r = r.clone(); r["vendor"] = file["vendor"].clone(); Ok(r) }+        None => Err(format!("{vendor} has no record for a {layers}-layer board{}", process.map(|p| format!(" ({p})")).unwrap_or_default())),+    }+}++pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {+    let inputs = crate::routing::call("check_inputs", args, pass);+    if inputs["success"].as_bool() != Some(true) { return inputs; }+    let mut bundle = json!({"board": inputs["board"], "stack": inputs["stack"], "pours": inputs["pours"], "rules": inputs["rules"], "silk": inputs["silk"], "args": args});+    if cmd == "fab_check" {+        if args["fab"].is_object() { bundle["fab"] = args["fab"].clone(); }+        else if let Some(v) = args["vendor"].as_str() {+            let layers = inputs["stack"]["copperLayers"].as_u64().unwrap_or(2);+            match fab_record(v, layers, args["process"].as_str()) {+                Ok(r) => bundle["fab"] = r,+                Err(e) => return json!({"success": false, "errorCode": "no_fab_record", "error": e, "_hint": "Name a vendor adom/pcb-design-rules has (jlcpcb, pcbway, oshpark ...), or pass the record as fab, or limits:{...} in mm."}),+            }+        }+    }+    let mut r = adom_eda_checks::run(cmd, &bundle);+    // the engine names verbs without a prefix: this bridge answers them as fusion_<verb>+    for key in ["apply", "plan", "pourPlan"] {+        if let Some(v) = r[key]["verb"].as_str().map(str::to_string) { r[key]["verb"] = json!(format!("fusion_{v}")); }+    }+    for k in ["boardId", "documentName", "revision"] { if !inputs[k].is_null() { r[k] = inputs[k].clone(); } }+    r["engine"] = json!("adom-eda-checks 0.1.1 (EDA Bridge Standard 0.4.2, vendored)");+    r+}
rust/bridge/src/main.rs+3−2
@@ -42,6 +42,7 @@ mod electronics; mod routing; mod sch; mod docs;+mod checks; mod sheet; mod bodies; mod display;@@ -444,13 +445,13 @@ fn main() {                     // a schematic file read in the exe: no Fusion needed                     "fusion_read_sheet_file" | "read_sheet_file" => { let mut r = sheet::read_sheet_file(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },                     "fusion_netlist" | "netlist" if parsed["args"]["schPath"].is_string() => { let mut r = sheet::netlist_from_sch(&parsed["args"]); r["bridgeVersion"] = json!(VERSION); r["shell"] = json!("rust"); r },-                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {+                    v if routing::owns(v.strip_prefix("fusion_").unwrap_or(v)) || sch::owns(v.strip_prefix("fusion_").unwrap_or(v)) || docs::owns(v.strip_prefix("fusion_").unwrap_or(v)) || checks::owns(v.strip_prefix("fusion_").unwrap_or(v)) => {                         let (sc, ca) = (sidecar.clone(), caller.clone());                         let side = |verb: &str, a: Value, t: u64| proxy_with_timeout(&sc, &json!({"command":verb,"args":a}).to_string(), &ca, t);                         let dlg = |r: Value| r;                         let ctx = addin::Ctx { host: &**h, fusion_exe:h.fusion_executable(), short_path:|p|host().short_path(p), state_dir:h.bridge_state_dir(), caller:caller.clone(), pids:h.fusion_processes().iter().map(|p|p.pid).collect(), sidecar:&side, dialogs:&dlg };                         let cmd=v.strip_prefix("fusion_").unwrap_or(v);-                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };+                        let mut r=match cmd { "bodies" => bodies::bodies(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "export_bom" => routing::export_bom(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_document" => savedoc::save_document(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), "save_to_cloud" => savedoc::save_to_cloud(&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if sch::owns(c) => sch::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if docs::owns(c) => docs::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), c if checks::owns(c) => checks::call(c,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)), _ => routing::call(cmd,&parsed["args"],&|v,a|addin::pass_through(v,&a,&ctx)) };                         if cmd=="netlist" { r=sheet::shape_board_netlist(r,&parsed["args"]); }                         r["bridgeVersion"]=json!(VERSION); r["shell"]=json!("rust"); r                     },
rust/bridge/src/routing.rs+1−1
@@ -3,7 +3,7 @@ use serde_json::{json, Value}; const SOURCE: &str = concat!(include_str!("routing_script.py"), "\n", include_str!("routing_planner.py"), "\n", include_str!("board_model.py"),     "\nFUSION_DEFAULT_RULES = r'''", include_str!("fusion_default_designrules.xml"), "'''\n");-pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts") }+pub fn owns(cmd: &str) -> bool { matches!(cmd, "routing_state" | "route_net" | "routing_drc" | "plan_route" | "move_parts" | "place_vias" | "apply_pours" | "pour_state" | "board_source" | "ripup_nets" | "schematic_run" | "board_bom" | "change_pours" | "schematic_plan" | "board_seed" | "remove_pours" | "layer_stack" | "netlist" | "bodies" | "component_data" | "component_pins" | "list_nets" | "net_connections" | "list_designators" | "layers" | "copper_layers" | "layers_in_use" | "read_rules" | "rules_by_kind" | "silk_state" | "net_classes" | "run_drc" | "repour" | "zoom_to" | "export_bom" | "save_document" | "save_to_cloud" | "apply_silkscreen" | "set_layer_visibility" | "select_net" | "set_track_width" | "remove_items" | "set_outline" | "board_shape" | "board_outline" | "run_erc" | "autoroute" | "update_board_from_eagle" | "board_dxf" | "edit_parts" | "align_parts" | "remove_graphics" | "remove_parts" | "check_inputs") } pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let script = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",         serde_json::to_string(SOURCE).unwrap(),
rust/bridge/src/routing_script.py+25
@@ -2073,6 +2073,30 @@ def remove_parts(app, root, board, meta, args):             '_hint': 'Removed from the design (schematic and board). Tracks and vias that met their pads stay: fusion_routing_state shows what is left dangling.'}  +def _mm_value(v):+    m = re.fullmatch(r'\s*([0-9.]+)\s*(mm|mil|in)?\s*', str(v or ''))+    if not m:+        return None+    x = float(m[1])+    return x * {'mil': 0.0254, 'in': 25.4}.get(m[2], 1.0)+++def check_inputs(app, root, board, meta, args):+    """Everything the shared checks engine reads, from one export: the board model, the stack-up,+    Fusion's pour fills, the rules (copper clearances also in the standard's {kind: clearance,+    clearanceMm}, same-net spacing left out: it does not separate two nets) and the silkscreen."""+    rules = rd_read_rules(root, board, {})+    std = []+    for r_ in rules.get('rules', []):+        if r_.get('kind') == 'Copper Clearance' and r_.get('enabled', True) and 'same' not in (r_.get('name') or '').lower():+            v = _mm_value(r_.get('value'))+            if v is not None:+                std.append({'kind': 'clearance', 'clearanceMm': round(v, 4), 'name': r_.get('name'), 'enabled': True})+    return {'success': True, 'mutated': False, **meta, 'board': state(board, meta), 'stack': layer_stack(board),+            'pours': {'zones': zones(board)}, 'rules': {'rules': std + [dict(x, kind='fusion:' + str(x.get('kind'))) for x in rules.get('rules', [])]},+            'silk': rd_silk_state(root, board, {})}++ def handle(app,args,action):     attempted=False; commands=[]     try:@@ -2103,6 +2127,7 @@ def handle(app,args,action):         if action=='autoroute': return autoroute(app,root,board,meta,args)         if action=='update_board_from_eagle': return update_board_from_eagle(app,root,board,meta,args)         if action=='board_dxf': return board_dxf(app,root,board,meta,args)+        if action=='check_inputs': return check_inputs(app,root,board,meta,args)         if action=='edit_parts': return edit_parts(app,root,board,meta,args)         if action=='align_parts': return align_parts(app,root,board,meta,args)         if action=='remove_graphics': return remove_graphics(app,root,board,meta,args)
rust/vendor/adom-eda-checks/Cargo.tomladded+22
@@ -0,0 +1,22 @@+[package]+name = "adom-eda-checks"+version = "0.1.1"+edition = "2021"+description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool."+license = "MIT"++[lib]+path = "src/lib.rs"++[[bin]]+name = "adom-eda-checks"+path = "src/main.rs"++[dependencies]+serde_json = "1"++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
rust/vendor/adom-eda-checks/README.mdadded+78
@@ -0,0 +1,78 @@+# adom-eda-checks: the shared checks engine++The checks tier of the EDA Bridge Standard, computed from the board model and its companions as plain JSON. It makes+no bridge calls, runs no tool and does no I/O. A bridge links the crate (the Altium, KiCad and Fusion bridges are all+Rust) or runs the `adom-eda-checks` command, then answers the check under its own prefix (`kicad_return_path`).++The code moved over from the Altium bridge's `bridge-rs/src/`, which was the reference implementation. On Altium's own+boards the engine gives the same answers the Altium bridge 0.2.76 gives.++## Checks++| Check | Reads | What it answers |+|---|---|---|+| `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) |+| `return_path` | board, stack, pours | signal tracks running over a gap in the neighbouring layer's reference copper, with a pour plan that closes each gap (`apply_pours`) |+| `fab_check` | board, stack?, rules?, silk?, fab? | the board's smallest features, compared with a fab's limits from adom/pcb-design-rules; each finding names the fab's page and the date it was read |+| `impedance` | stack | the track width (and pair gap) for a target impedance on each copper layer; `apply` answers `add_rule` arguments |+| `current_capacity` | board, stack? | per net and layer, the narrowest copper against the width the current needs (IPC-2221), and the vias a layer change needs |++The other checks-tier verbs (assembly, 3D heights, placement, schematic review, pair routing, length tuning,+designators, via fences, panels, the board report) come over from the Altium bridge a module at a time.++## Input++Every check takes one JSON object:++```json+{"board": {...}, "stack": {...}, "pours": {...}, "rules": [...], "silk": {...}, "fab": {...}, "args": {...}}+```++| Key | What it is |+|---|---|+| `board` | the board model, `<prefix>_routing_state` (`schema/board.schema.json`) |+| `stack` | the stack-up companion, `<prefix>_layer_stack` |+| `pours` | poured copper, `<prefix>_pour_state` (its `zones`) |+| `rules` | the board's rules, `<prefix>_read_rules`: a clearance rule as `{kind: "clearance", clearanceMm}` |+| `silk` | `<prefix>_silk_state` |+| `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |+| `args` | the check's own arguments, as the standard verb documents them |++Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.+A missing part is never guessed. A limit with nothing to measure it against is listed in `notChecked`. Copper weight+assumed for lack of a stack is named in `assumed`.++## Use++As a crate, vendored at a tagged standard version (the wiki has no git protocol, so Cargo can't fetch it):++```toml+[dependencies]+adom-eda-checks = { path = "vendor/adom-eda-checks" }+```++```rust+let reply = adom_eda_checks::run("return_path", &serde_json::json!({"board": board, "stack": stack, "pours": pours, "args": {}}));+```++As a command:++```bash+adom-eda-checks list+adom-eda-checks return_path --board board.json --stack stack.json --pours pours.json --args '{"resolutionMm": 0.1}'+adom-eda-checks fab_check bundle.json        # or - for stdin+```++Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix+(`apply_pours`, `add_rule`): a bridge adds its own.++## Tests++`cargo test` runs the unit tests. To compare with a bridge's own answers, point `ADOM_EDA_FIXTURES` at a folder of+saved replies laid out as `<bridge>/<board>.<model>.json` and `<bridge>/expected/<board>.<check>.json`:++```bash+ADOM_EDA_FIXTURES=/path/to/fixtures cargo test --test fixtures+```++Fixtures live on each bridge's own page, not here.
rust/vendor/adom-eda-checks/VENDOR.mdadded+13
@@ -0,0 +1,13 @@+# Vendored: adom-eda-checks++The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki+has no git protocol, so Cargo cannot fetch it).++- Standard version: 0.4.2+- Standard commit: 680ab693fc0d4b1ec948b85ae4a3c28c51a6a961+- Crate version: 0.1.1+- Copied: 2026-10-27, by fusion-lane++To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,+`engine/Cargo.toml` and `engine/README.md` here, record the new version and commit above, and run the bridge's tests.+Never edit the copy: fixes go to the standard (an issue titled `fusion-lane: ...`).
rust/vendor/adom-eda-checks/src/coppermap.rsadded+446
@@ -0,0 +1,446 @@+//! The copper of each layer as a raster (0.1 mm by default) inside the board outline, from the live board: tracks,+//! arcs, pads, vias and the copper Altium actually poured (its regions minus their holes); an internal plane counts as+//! copper everywhere. From it: how much of each layer is copper and how evenly the stack is balanced (bow and twist),+//! and where signal tracks run over a gap in the copper of the layer next to them (the return current's path).+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// A layer's copper as cells of `res` mm from (x0, y0).+#[derive(Clone)]+pub struct Raster { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize, pub cells: Vec<bool> }++impl Raster {+    pub fn new(x0: f64, y0: f64, x1: f64, y1: f64, res: f64) -> Raster {+        let w = (((x1 - x0) / res).ceil() as usize).max(1);+        let h = (((y1 - y0) / res).ceil() as usize).max(1);+        Raster { x0, y0, res, w, h, cells: vec![false; w * h] }+    }+    fn centre(&self, i: usize, j: usize) -> (f64, f64) { (self.x0 + (i as f64 + 0.5) * self.res, self.y0 + (j as f64 + 0.5) * self.res) }+    /// cells whose centre is in [x0, x1] x [y0, y1], clipped+    fn span(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> (usize, usize, usize, usize) {+        let c = |v: f64, o: f64, n: usize| (((v - o) / self.res - 0.5).max(0.0) as usize).min(n);+        let (i0, j0) = (c(x0, self.x0, self.w), c(y0, self.y0, self.h));+        let (i1, j1) = ((((x1 - self.x0) / self.res + 0.5).max(0.0) as usize).min(self.w), (((y1 - self.y0) / self.res + 0.5).max(0.0) as usize).min(self.h));+        (i0, j0, i1, j1)+    }+    pub fn at(&self, x: f64, y: f64) -> bool {+        let (i, j) = ((x - self.x0) / self.res, (y - self.y0) / self.res);+        if i < 0.0 || j < 0.0 || i >= self.w as f64 || j >= self.h as f64 { return false; }+        self.cells[j as usize * self.w + i as usize]+    }+    /// a segment of width w with round ends (a track, an arc's piece, an oval pad)+    pub fn capsule(&mut self, ax: f64, ay: f64, bx: f64, by: f64, w: f64) {+        let r = w / 2.0;+        let (i0, j0, i1, j1) = self.span(ax.min(bx) - r, ay.min(by) - r, ax.max(bx) + r, ay.max(by) + r);+        let (dx, dy) = (bx - ax, by - ay);+        let l2 = dx * dx + dy * dy;+        for j in j0..j1 { for i in i0..i1 {+            let (x, y) = self.centre(i, j);+            let t = if l2 > 0.0 { (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+            let (px, py) = (ax + t * dx - x, ay + t * dy - y);+            if px * px + py * py <= r * r { self.cells[j * self.w + i] = true; }+        } }+    }+    /// a rectangle sx by sy centred at (cx, cy), turned `rot` degrees+    pub fn rect(&mut self, cx: f64, cy: f64, sx: f64, sy: f64, rot: f64) {+        let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+        let e = (sx * sx + sy * sy).sqrt() / 2.0;+        let (i0, j0, i1, j1) = self.span(cx - e, cy - e, cx + e, cy + e);+        for j in j0..j1 { for i in i0..i1 {+            let (x, y) = self.centre(i, j);+            let (u, v) = ((x - cx) * c + (y - cy) * s, -(x - cx) * s + (y - cy) * c);+            if u.abs() <= sx / 2.0 && v.abs() <= sy / 2.0 { self.cells[j * self.w + i] = true; }+        } }+    }+    /// polygons filled by the even-odd rule (an outline with its holes), set to `on`+    pub fn polys(&mut self, rings: &[Vec<(f64, f64)>], on: bool) {+        let ys: Vec<f64> = rings.iter().flatten().map(|p| p.1).collect();+        if ys.is_empty() { return; }+        let (lo, hi) = (ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max));+        let (_, j0, _, j1) = self.span(self.x0, lo, self.x0, hi);+        for j in j0..j1 {+            let y = self.y0 + (j as f64 + 0.5) * self.res;+            let mut xs: Vec<f64> = Vec::new();+            for ring in rings {+                let n = ring.len();+                for k in 0..n {+                    let (a, b) = (ring[k], ring[(k + 1) % n]);+                    if (a.1 <= y) != (b.1 <= y) { xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); }+                }+            }+            xs.sort_by(|a, b| a.partial_cmp(b).unwrap());+            for p in xs.chunks(2) {+                if p.len() < 2 { continue; }+                let (i0, _, i1, _) = self.span(p[0], y, p[1], y);+                for i in i0..i1 { self.cells[j * self.w + i] = on; }+            }+        }+    }+    pub fn and(&mut self, mask: &Raster) { for (c, m) in self.cells.iter_mut().zip(&mask.cells) { *c = *c && *m; } }+    pub fn count(&self) -> usize { self.cells.iter().filter(|c| **c).count() }+}++/// The stack's copper layers in order as (board model name, plane?, plane net). The name is the stack entry's `layer`+/// when the bridge gives it, else its place in the copper stack (F.Cu, In<n>.Cu, B.Cu): a plane is named like any other+/// inner layer (standard 0.3.1), never by a tool's id.+pub fn stack_layers(stack: &Value) -> Vec<(String, bool, String)> {+    let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+    let n = ls.len();+    ls.iter().enumerate().map(|(i, l)| {+        let plane = l["kind"] == "plane";+        let name = l["layer"].as_str().map(str::to_string).unwrap_or_else(|| if i == 0 { "F.Cu".to_string() } else if i + 1 == n { "B.Cu".to_string() } else { format!("In{i}.Cu") });+        (name, plane, if plane { l["net"].as_str().unwrap_or("").to_string() } else { String::new() })+    }).collect()+}++/// The outline as a ring in mm (arc edges sampled).+pub(crate) fn outline_ring(st: &Value) -> Vec<(f64, f64)> {+    let mut ring = Vec::new();+    for e in st["outlineEdges"].as_array().cloned().unwrap_or_default() {+        if e["kind"] == "arc" {+            let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+            let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));+            if a2 <= a1 { a2 += 360.0; }+            // every 2 degrees: a chord cuts inside the arc by r(1 - cos 1deg), 3 um on a 20 mm radius (16 points per+            // arc put a hatched pour's ends 0.04 mm outside the Bluetooth Sentinel example's edge)+            let k = (((a2 - a1) / 2.0).ceil() as usize).max(4);+            for q in 0..=k { let t = (a1 + (a2 - a1) * q as f64 / k as f64).to_radians(); ring.push((cx + r * t.cos(), cy + r * t.sin())); }+        } else { ring.push((f(&e["start"][0]), f(&e["start"][1]))); }+    }+    if ring.is_empty() { ring = st["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect(); }+    ring+}++/// Each layer's copper raster inside the outline. `refs` None: all copper. Some(nets): only the copper a return current+/// can use: pours, planes, those nets' own copper, and net-less copper (Altium lists a hatched pour's tracks with no+/// net: 8777 of the Bluetooth Sentinel example's 9378 tracks). Pure: routing_state, pour_state's zones, the stack.+pub fn rasters(st: &Value, zones: &[Value], layers: &[(String, bool, String)], res: f64, refs: Option<&[String]>) -> (Raster, BTreeMap<String, Raster>) {+    let ring = outline_ring(st);+    let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let mut board = Raster::new(x0, y0, x1, y1, res);+    board.polys(&[ring], true);+    let mut out = BTreeMap::new();+    for (name, plane, _) in layers {+        let mut r = Raster::new(x0, y0, x1, y1, res);+        if *plane { r.cells = board.cells.clone(); out.insert(name.clone(), r); continue; }+        {+            let keep = |n: &Value| match refs { None => true, Some(rs) => { let n = n.as_str().unwrap_or(""); n.is_empty() || rs.iter().any(|r| r.eq_ignore_ascii_case(n)) } };+            let on_layer = |l: &Value| l.as_str().map(|s| s == name || s == "*.Cu").unwrap_or(false);+            let on = |p: &Value| on_layer(&p["layer"]) && keep(&p["net"]);+            for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| on(t)) {+                r.capsule(f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), f(&t["width"]));+            }+            for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| on(a)) {+                let (cx, cy, rad) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+                let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));+                if e <= s { e += 360.0; }+                let k = ((e - s) / 10.0).ceil().max(1.0) as usize;+                for q in 0..k {+                    let (t0, t1) = ((s + (e - s) * q as f64 / k as f64).to_radians(), (s + (e - s) * (q + 1) as f64 / k as f64).to_radians());+                    r.capsule(cx + rad * t0.cos(), cy + rad * t0.sin(), cx + rad * t1.cos(), cy + rad * t1.sin(), f(&a["width"]));+                }+            }+            for p in st["pads"].as_array().cloned().unwrap_or_default().iter().filter(|p| on(p)) {+                let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+                // a custom pad's real copper when the bridge sends it (Fusion's copperPoints); its bounding rectangle otherwise+                let outline: Vec<(f64, f64)> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+                if outline.len() >= 3 { r.polys(&[outline], true); continue; }+                match p["shape"].as_str() {+                    Some("round") | Some("circle") if (sx - sy).abs() > 1e-6 => {+                        let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+                        let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy));+                        let (ux, uy) = if sx > sy { (c * l, s * l) } else { (-s * l, c * l) };+                        r.capsule(f(&p["x"]) - ux, f(&p["y"]) - uy, f(&p["x"]) + ux, f(&p["y"]) + uy, w);+                    }+                    Some("round") | Some("circle") => r.capsule(f(&p["x"]), f(&p["y"]), f(&p["x"]), f(&p["y"]), sx),+                    _ => r.rect(f(&p["x"]), f(&p["y"]), sx, sy, f(&p["rotation"])),+                }+            }+            for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| keep(&v["net"])) {+                r.capsule(f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]));+            }+        }+        for z in zones.iter().filter(|z| z["layer"].as_str() == Some(name)) {+            for fill in z["fills"].as_array().cloned().unwrap_or_default() {+                let pts = |v: &Value| v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect::<Vec<_>>();+                let mut rings = vec![pts(&fill["outline"])];+                rings.extend(fill["holes"].as_array().cloned().unwrap_or_default().iter().map(pts));+                r.polys(&rings, true);+            }+        }+        r.and(&board);+        out.insert(name.clone(), r);+    }+    (board, out)+}++/// Per layer: copper area and share of the board; per mirrored pair (1 with n, 2 with n-1...): the difference.+pub fn balance(board: &Raster, maps: &BTreeMap<String, Raster>, layers: &[(String, bool, String)]) -> (Vec<Value>, Vec<Value>) {+    let cell = board.res * board.res;+    let total = board.count() as f64;+    let pct = |name: &str| maps.get(name).map(|r| 100.0 * r.count() as f64 / total.max(1.0)).unwrap_or(0.0);+    let rows: Vec<Value> = layers.iter().map(|(n, plane, net)| {+        let mut o = json!({"layer": n, "copperMm2": r1(maps.get(n).map(|r| r.count()).unwrap_or(0) as f64 * cell), "percent": r1(pct(n))});+        if *plane { o["plane"] = json!(net); }+        o+    }).collect();+    let k = layers.len();+    let pairs = (0..k / 2).map(|i| {+        let (a, b) = (&layers[i].0, &layers[k - 1 - i].0);+        let d = (pct(a) - pct(b)).abs();+        json!({"layers": [a, b], "percent": [r1(pct(a)), r1(pct(b))], "differencePoints": r1(d), "ok": d <= 20.0})+    }).collect();+    (rows, pairs)+}++/// Per signal net: track length on each layer, and the length that runs over no reference copper on either+/// neighbouring layer, with where the first gap starts. `skip` nets are the references themselves. Pure.+pub fn return_paths(st: &Value, refs: &BTreeMap<String, Raster>, layers: &[(String, bool, String)], skip: &[String], step: f64) -> Vec<Value> {+    let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();+    let mut acc: BTreeMap<(String, String), (f64, f64, Option<(f64, f64)>, Vec<(f64, f64)>)> = BTreeMap::new();+    for t in st["tracks"].as_array().cloned().unwrap_or_default() {+        let (net, layer) = (t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or(""));+        if net.is_empty() || skip.iter().any(|s| s.eq_ignore_ascii_case(net)) { continue; }+        let Some(&i) = idx.get(layer) else { continue };+        let nb: Vec<&Raster> = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter_map(|l| refs.get(&l.0)).collect();+        let (ax, ay, bx, by) = (f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]));+        let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();+        let n = (len / step).ceil().max(1.0) as usize;+        let e = acc.entry((net.to_string(), layer.to_string())).or_insert((0.0, 0.0, None, Vec::new()));+        e.0 += len;+        for k in 0..n {+            let s = (k as f64 + 0.5) / n as f64;+            let (x, y) = (ax + s * (bx - ax), ay + s * (by - ay));+            if !nb.iter().any(|r| r.at(x, y)) {+                e.1 += len / n as f64;+                if e.2.is_none() { e.2 = Some((r3(x), r3(y))); }+                e.3.push((x, y));+            }+        }+    }+    let mut out: Vec<Value> = acc.into_iter().filter(|(_, v)| v.1 > 0.0).map(|((net, layer), (len, gap, at, pts))| json!({+        "net": net, "layer": layer, "lengthMm": r3(len), "unreferencedMm": r3(gap), "percent": r1(100.0 * gap / len.max(1e-9)), "firstGapAt": at.map(|p| json!([p.0, p.1])),+        "gapRuns": gap_runs(&pts, 1.0).iter().map(|r| json!([r3(r.0), r3(r.1), r3(r.2), r3(r.3)])).collect::<Vec<_>>()})).collect();+    out.sort_by(|a, b| b["unreferencedMm"].as_f64().partial_cmp(&a["unreferencedMm"].as_f64()).unwrap());+    out+}++/// The gap sample points grouped into runs: boxes (x0, y0, x1, y1) of points within `join` mm of each other. Pure.+pub fn gap_runs(pts: &[(f64, f64)], join: f64) -> Vec<(f64, f64, f64, f64)> {+    let mut runs: Vec<(f64, f64, f64, f64)> = Vec::new();+    for &(x, y) in pts {+        match runs.iter_mut().find(|r| x >= r.0 - join && x <= r.2 + join && y >= r.1 - join && y <= r.3 + join) {+            Some(r) => { r.0 = r.0.min(x); r.1 = r.1.min(y); r.2 = r.2.max(x); r.3 = r.3.max(y); }+            None => runs.push((x, y, x, y)),+        }+    }+    // runs that grew into each other merge+    let mut merged = true;+    while merged {+        merged = false;+        'outer: for i in 0..runs.len() {+            for j in i + 1..runs.len() {+                let (a, b) = (runs[i], runs[j]);+                if a.0 <= b.2 + join && b.0 <= a.2 + join && a.1 <= b.3 + join && b.1 <= a.3 + join {+                    runs[i] = (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3));+                    runs.remove(j);+                    merged = true;+                    break 'outer;+                }+            }+        }+    }+    runs+}++/// A plan that closes each gap: a pour patch `margin` mm round every gap run, on the neighbouring layer that already+/// carries the most reference copper, on that layer's reference net; as apply_pours arguments. Pure.+pub fn fix_plan(rows: &[Value], layers: &[(String, bool, String)], refs: &BTreeMap<String, Raster>, zones: &[Value], skip: &[String], board: (f64, f64, f64, f64), margin: f64) -> Vec<Value> {+    let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();+    let mut pours = Vec::new();+    for row in rows {+        let Some(&i) = row["layer"].as_str().and_then(|l| idx.get(l)) else { continue };+        // a plane needs no patch (it is copper everywhere but its splits); pick the signal neighbour with most copper+        let best = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter(|l| !l.1)+            .max_by_key(|l| refs.get(&l.0).map(|r| r.count()).unwrap_or(0));+        let Some(nb) = best else { continue };+        let net = zones.iter().find(|z| z["layer"].as_str() == Some(nb.0.as_str())).and_then(|z| z["net"].as_str()).map(String::from)+            .or_else(|| skip.first().cloned());+        let Some(net) = net else { continue };+        for (k, r) in row["gapRuns"].as_array().cloned().unwrap_or_default().iter().enumerate() {+            let (x0, y0) = ((f(&r[0]) - margin).max(board.0 + 0.3), (f(&r[1]) - margin).max(board.1 + 0.3));+            let (x1, y1) = ((f(&r[2]) + margin).min(board.2 - 0.3), (f(&r[3]) + margin).min(board.3 - 0.3));+            if x1 <= x0 || y1 <= y0 { continue; }+            // does the patch overlap the reference copper already on that layer? (one that does not floats)+            let joins = refs.get(&nb.0).map(|ras| {+                let mut hit = false;+                let mut y = y0;+                while y <= y1 && !hit { let mut x = x0; while x <= x1 && !hit { hit = ras.at(x, y); x += ras.res; } y += ras.res; }+                hit+            }).unwrap_or(false);+            // removeDead off: a patch that only overlaps the pour it extends touches no pad or track of its net, and+            // Altium's dead-copper removal poured it as nothing (0 mm2, measured 2026-10-26)+            pours.push(json!({"net": net, "layer": nb.0, "name": format!("Return {} {}", row["net"].as_str().unwrap_or(""), k + 1), "removeDead": false,+                "points": [[r3(x0), r3(y0)], [r3(x1), r3(y0)], [r3(x1), r3(y1)], [r3(x0), r3(y1)]], "closes": {"net": row["net"], "layer": row["layer"]}, "joinsExistingCopper": joins}));+        }+    }+    pours+}++/// The board, its poured copper and its stack from the input bundle, or the refusal to answer.+fn gather(input: &Value) -> Result<(Value, Vec<Value>, Vec<(String, bool, String)>), Value> {+    let st = crate::input::board(input)?;+    if st["outline"].as_array().map(|a| a.len() < 3).unwrap_or(true) { return Err(crate::input::refuse("no_outline", "the board has no outline")); }+    let zones = crate::input::pours(input);+    let layers = crate::input::stack(input).map(|s| stack_layers(&s)).unwrap_or_default();+    if layers.is_empty() { return Err(crate::input::refuse("no_stack", "the input has no stack: pass the layer_stack companion as stack")); }+    Ok((st, zones, layers))+}++fn res_of(args: &Value) -> f64 { args.get("resolutionMm").and_then(|v| v.as_f64()).unwrap_or(0.1).clamp(0.02, 1.0) }++/// copper_balance: input {board, stack, pours, args: {resolutionMm?}}+pub fn copper_balance(input: &Value) -> Value {+    let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };+    let args = &input["args"];+    let res = res_of(args);+    let (board, maps) = rasters(&st, &zones, &layers, res, None);+    let (rows, pairs) = balance(&board, &maps, &layers);+    let unpoured: Vec<Value> = st["polygons"].as_array().cloned().unwrap_or_default().iter()+        .filter(|g| !zones.iter().any(|z| z["layer"] == g["layer"] && z["net"] == g["net"] && z["filled"] == json!(true)))+        .map(|g| json!({"net": g["net"], "layer": g["layer"]})).collect();+    let ok = pairs.iter().all(|p| p["ok"] == json!(true));+    json!({"success": true, "boardMm2": r1(board.count() as f64 * res * res), "resolutionMm": res, "layers": rows, "pairs": pairs, "balanced": ok,+        "poursWithoutRegions": unpoured,+        "_hint": if ok { "Mirrored layers carry similar copper: the stack should stay flat through reflow." } else { "A mirrored pair differs by more than 20 points of copper: the board can bow or twist through reflow. Pour the lighter layer (apply_pours, a GND pour or copper thieving), or ask the fab to add thieving on its side." }})+}++/// return_path: input {board, stack, pours, args: {nets?, referenceNets?, resolutionMm?, fixMarginMm?}}+pub fn return_path(input: &Value) -> Value {+    let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };+    let args = &input["args"];+    let res = res_of(args);+    // the reference nets: the ones that own pours or planes, unless named+    let mut skip: Vec<String> = args.get("referenceNets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+    if skip.is_empty() {+        skip = st["polygons"].as_array().cloned().unwrap_or_default().iter().filter_map(|g| g["net"].as_str().map(String::from))+            .chain(zones.iter().filter_map(|z| z["net"].as_str().map(String::from))).chain(layers.iter().filter(|l| l.1).map(|l| l.2.clone()))+            .filter(|n| !n.is_empty()).collect();+        skip.sort(); skip.dedup();+    }+    let (_, refs) = rasters(&st, &zones, &layers, res, Some(&skip));+    let mut rows = return_paths(&st, &refs, &layers, &skip, res);+    if let Some(want) = args.get("nets").and_then(|v| v.as_array()) {+        let w: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+        rows.retain(|r| r["net"].as_str().map(|n| w.contains(&n.to_ascii_lowercase())).unwrap_or(false));+    }+    let no_ref: Vec<&String> = layers.iter().map(|l| &l.0).filter(|n| refs.get(*n).map(|r| r.count() == 0).unwrap_or(true)).collect();+    // a plan to close the gaps, for the caller to read and run: nothing is changed here+    let bb = crate::geom::outline_bbox(&st).unwrap_or((0.0, 0.0, 0.0, 0.0));+    let margin = args.get("fixMarginMm").and_then(|v| v.as_f64()).unwrap_or(1.0);+    let patches = fix_plan(&rows, &layers, &refs, &zones, &skip, bb, margin);+    let fix = if patches.is_empty() { Value::Null } else {+        let pours: Vec<Value> = patches.iter().map(|p| { let mut q = p.clone(); if let Some(o) = q.as_object_mut() { o.remove("closes"); o.remove("joinsExistingCopper"); } q }).collect();+        let floating: Vec<Value> = patches.iter().filter(|p| p["joinsExistingCopper"] == json!(false)).map(|p| json!({"name": p["name"], "layer": p["layer"]})).collect();+        json!({"verb": "apply_pours", "args": {"pours": pours}, "patches": patches.len(), "closes": patches.iter().map(|p| p["closes"].clone()).collect::<Vec<_>>(),+               "floating": floating,+               "note": "A plan only: nothing changed. Each patch is a pour of the reference net round one gap run, on the neighbouring layer with the most reference copper. Read it (a patch can cut through other routing's space: the tool pours round it, keeping clearance), then run the verb with args, repour, and run return_path again. floating patches overlap no copper of their net yet: tie each to it with a via (place_vias) or it is an island."})+    };+    let note = if skip.is_empty() { json!("No net owns a pour or a plane on this board: there is no reference copper, so every signal is reported. Pour GND (apply_pours) or name referenceNets.") } else { Value::Null };+    json!({"success": true, "referenceNets": skip, "note": note, "layersWithNoReferenceCopper": no_ref, "nets": rows, "ok": rows.is_empty(), "resolutionMm": res, "fix": fix,+        "_hint": if rows.is_empty() { "Every signal track runs over pour or plane copper on a neighbouring layer." } else { "Each row is a net whose tracks on that layer run over no pour or plane on either neighbouring layer for unreferencedMm (from firstGapAt). Its return current detours around the gap: more loop area, more emission and crosstalk. Close the gap (extend the pour: apply_pours), route the net over solid copper, or add stitching capacitors or vias where it changes reference." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn raster_shapes_have_their_area() {+        let mut r = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+        r.rect(5.0, 5.0, 4.0, 2.0, 0.0);+        assert!((r.count() as f64 * 0.0025 - 8.0).abs() < 0.3);+        let mut c = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+        c.capsule(10.0, 10.0, 10.0, 10.0, 4.0);+        assert!((c.count() as f64 * 0.0025 - std::f64::consts::PI * 4.0).abs() < 0.3);+        let mut p = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+        p.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], vec![(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], true);+        assert!((p.count() as f64 * 0.0025 - 96.0).abs() < 0.5);+        assert!(!p.at(3.0, 3.0) && p.at(6.0, 6.0));+        // KiCad's fractured fill: the hole joined to the outline by a zero-width cut, holes empty. Same copper.+        let mut k = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);+        k.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 2.0), (2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0), (0.0, 2.0)]], true);+        assert_eq!(k.count(), p.count());+        assert!(!k.at(3.0, 3.0) && k.at(6.0, 6.0));+    }++    #[test]+    fn a_custom_pad_is_its_copper_points_when_sent() {+        let pad = |extra: Value| { let mut p = json!({"ref": "Q1", "name": "5", "x": 5.0, "y": 5.0, "sizeX": 4.0, "sizeY": 4.0, "rotation": 0, "layer": "F.Cu", "net": "D", "shape": "rect"});+            if let Value::Object(m) = extra { for (k, v) in m { p[k] = v; } } p };+        let area = |p: Value| { let st = json!({"outline": [[0, 0], [10, 0], [10, 10], [0, 10]], "pads": [p]});+            let (_, m) = rasters(&st, &[], &[("F.Cu".into(), false, "".into())], 0.05, None); m["F.Cu"].count() as f64 * 0.0025 };+        assert!((area(pad(json!({}))) - 16.0).abs() < 0.5);+        // an L of copper inside that rectangle: 4x2 + 2x2 = 12 mm2+        assert!((area(pad(json!({"copperPoints": [[3, 3], [7, 3], [7, 5], [5, 5], [5, 7], [3, 7]]}))) - 12.0).abs() < 0.5);+    }++    fn st() -> Value {+        json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+               "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+               "tracks": [{"net": "SIG", "layer": "F.Cu", "width": 0.2, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]})+    }++    #[test]+    fn balance_and_return_path_on_a_split_ground() {+        let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];+        // GND poured on B.Cu over x 0..8 and 12..20: a 4 mm split under the signal+        let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [+            {"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}, {"outline": [[12, 0], [20, 0], [20, 10], [12, 10]], "holes": []}]})];+        let (board, maps) = rasters(&st(), &zones, &layers, 0.1, None);+        let (rows, pairs) = balance(&board, &maps, &layers);+        assert!((rows[1]["percent"].as_f64().unwrap() - 80.0).abs() < 1.0);+        assert!((rows[0]["percent"].as_f64().unwrap() - 1.8).abs() < 0.5);+        assert_eq!(pairs[0]["ok"], json!(false));+        let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));+        // the signal itself is not reference copper+        assert!(!refs["F.Cu"].at(10.0, 5.0));+        let rp = return_paths(&st(), &refs, &layers, &["GND".to_string()], 0.1);+        assert_eq!(rp.len(), 1);+        assert!((rp[0]["unreferencedMm"].as_f64().unwrap() - 4.0).abs() < 0.2);+        assert_eq!(rp[0]["firstGapAt"][0].as_f64().unwrap().floor(), 8.0);+    }++    #[test]+    fn stack_names_match_routing_state() {+        let s = json!({"layers": [{"name": "Top"}, {"kind": "plane", "plane": 1, "net": "GND"}, {"kind": "signal", "mid": 1}, {"name": "Bottom"}]});+        let l = stack_layers(&s);+        assert_eq!(l.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+        assert!(l[1].1 && l[1].2 == "GND");+        // the bridge's own name wins when it gives one+        let named = json!({"layers": [{"layer": "F.Cu"}, {"layer": "In1.Cu", "kind": "plane", "net": "GND", "layerId": 39}, {"layer": "B.Cu"}]});+        assert_eq!(stack_layers(&named)[1], ("In1.Cu".to_string(), true, "GND".to_string()));+    }+    #[test]+    fn gap_runs_group_and_the_fix_patches_the_neighbour() {+        let pts: Vec<(f64, f64)> = (0..40).map(|k| (8.05 + k as f64 * 0.1, 5.0)).chain((0..5).map(|k| (15.0 + k as f64 * 0.1, 5.0))).collect();+        let runs = gap_runs(&pts, 1.0);+        assert_eq!(runs.len(), 2);+        let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];+        let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [{"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}]})];+        let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));+        let rows = vec![json!({"net": "SIG", "layer": "F.Cu", "gapRuns": [[8.05, 5.0, 11.95, 5.0]]})];+        let p = fix_plan(&rows, &layers, &refs, &zones, &["GND".to_string()], (0.0, 0.0, 20.0, 10.0), 1.0);+        assert_eq!(p.len(), 1);+        assert_eq!(p[0]["layer"], "B.Cu");+        assert_eq!(p[0]["net"], "GND");+        assert_eq!(p[0]["points"][0], json!([7.05, 4.0]));+        assert_eq!(p[0]["joinsExistingCopper"], json!(true));+        assert_eq!(p[0]["removeDead"], json!(false));+    }+}
rust/vendor/adom-eda-checks/src/current.rsadded+140
@@ -0,0 +1,140 @@+//! Current capacity of a net's copper (IPC-2221): the width each layer needs for a current at an allowed temperature+//! rise, against the narrowest track the net actually has there, and how many of its vias one layer change needs.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }++const MIL: f64 = 0.0254;++/// IPC-2221's chart as a formula: the cross-section (mil^2) for `amps` at `rise` degrees C, outer or inner layer.+fn area_mil2(amps: f64, rise: f64, outer: bool) -> f64 {+    let k = if outer { 0.048 } else { 0.024 };+    (amps / (k * rise.powf(0.44))).powf(1.0 / 0.725)+}++/// The track width (mm) `amps` needs on copper `copper_um` thick.+pub fn width_for(amps: f64, rise: f64, copper_um: f64, outer: bool) -> f64 {+    let t_mil = copper_um / 1000.0 / MIL;+    area_mil2(amps, rise, outer) / t_mil * MIL+}++/// What one via carries: its barrel (plating `plating_um` around a `drill` mm hole) as an inner conductor.+pub fn via_amps(drill: f64, plating_um: f64, rise: f64) -> f64 {+    let t = plating_um / 1000.0;+    let a_mil2 = std::f64::consts::PI * (drill + t) * t / (MIL * MIL);+    0.024 * rise.powf(0.44) * a_mil2.powf(0.725)+}++/// routing_state's layer name to its index in the stack (F.Cu first, In<n>.Cu, B.Cu last).+fn stack_index(layer: &str, copper: usize) -> Option<usize> {+    match layer {+        "F.Cu" => Some(0),+        "B.Cu" => Some(copper.saturating_sub(1)),+        l => l.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|n| n.parse().ok()),+    }+}++/// Per net asked: each layer it is routed on with its narrowest track and the width the current needs. Pure.+pub fn check(state: &Value, copper_um: &[f64], nets: &Map<String, Value>, rise: f64, plating_um: f64) -> Vec<Value> {+    let tracks = state["tracks"].as_array().cloned().unwrap_or_default();+    let arcs = state["arcs"].as_array().cloned().unwrap_or_default();+    let vias = state["vias"].as_array().cloned().unwrap_or_default();+    let polys = state["polygons"].as_array().cloned().unwrap_or_default();+    let n = copper_um.len().max(2);+    nets.iter().map(|(net, amps)| {+        let amps = f(amps);+        let same = |v: &Value| v.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false);+        // the narrowest piece of copper on each layer, tracks and arcs alike+        let mut narrow: std::collections::BTreeMap<String, (f64, Value)> = Default::default();+        let mut see = |layer: &str, w: f64, at: Value| {+            let e = narrow.entry(layer.to_string()).or_insert((f64::MAX, Value::Null));+            if w < e.0 { *e = (w, at); }+        };+        for t in tracks.iter().filter(|t| same(&t["net"])) { see(t["layer"].as_str().unwrap_or("?"), f(&t["width"]), json!([t["start"]["x"], t["start"]["y"]])); }+        for a in arcs.iter().filter(|a| same(&a["net"])) { see(a["layer"].as_str().unwrap_or("?"), f(&a["width"]), json!([a["center"]["x"], a["center"]["y"]])); }+        let mut short = Vec::new();+        let layers: Vec<Value> = narrow.into_iter().filter(|(l, _)| l.ends_with(".Cu")).map(|(layer, (w, at))| {+            let i = stack_index(&layer, n).unwrap_or(0);+            let outer = i == 0 || i + 1 == n;+            let cu = copper_um.get(i).copied().filter(|c| *c > 0.0).unwrap_or(35.0);+            let need = width_for(amps, rise, cu, outer);+            if w + 1e-9 < need { short.push(layer.clone()); }+            json!({"layer": layer, "copperUm": cu, "narrowestMm": r3(w), "at": at, "neededMm": r3(need), "ok": w + 1e-9 >= need})+        }).collect();+        let net_vias: Vec<&Value> = vias.iter().filter(|v| same(&v["net"])).collect();+        let smallest = net_vias.iter().map(|v| f(&v["drill"])).fold(f64::MAX, f64::min);+        let via = if net_vias.is_empty() { Value::Null } else {+            let each = via_amps(smallest, plating_um, rise);+            json!({"count": net_vias.len(), "smallestDrillMm": r3(smallest), "ampsEach": r2(each), "neededPerLayerChange": (amps / each).ceil() as i64})+        };+        let pours: Vec<Value> = polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()).collect();+        json!({"net": net, "amps": amps, "layers": layers, "shortOn": short, "ok": short.is_empty(), "vias": via, "poursOn": pours})+    }).collect()+}++/// current_capacity: input {board, stack?, args: {nets: {net: amps}, riseC?, platingUm?}}. Without a stack every layer+/// is taken as 35 um (1 oz) copper, and the reply says so.+pub fn current_capacity(input: &Value) -> Value {+    let args = &input["args"];+    let nets = match args.get("nets").and_then(|v| v.as_object()) {+        Some(m) if !m.is_empty() && m.values().all(|v| v.as_f64().map(|a| a > 0.0).unwrap_or(false)) => m.clone(),+        _ => return json!({"success": false, "errorCode": "bad_args", "error": "nets is {net: amps}, e.g. {\"VIN\": 3, \"GND\": 3}"}),+    };+    let rise = args.get("riseC").and_then(|v| v.as_f64()).unwrap_or(10.0);+    let plating = args.get("platingUm").and_then(|v| v.as_f64()).unwrap_or(25.0);+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let missing: Vec<&String> = nets.keys().filter(|k| !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(k)).unwrap_or(false))).unwrap_or(false)).collect();+    if !missing.is_empty() { return json!({"success": false, "errorCode": "no_such_net", "error": format!("not nets on this board: {missing:?}"), "nets": st["nets"]}); }+    let stack = crate::input::stack(input);+    let copper: Vec<f64> = stack.as_ref().and_then(|s| s["layers"].as_array().cloned()).unwrap_or_default().iter().map(|l| f(&l["copperUm"])).collect();+    let rows = check(&st, &copper, &nets, rise, plating);+    let all_ok = rows.iter().all(|r| r["ok"].as_bool() == Some(true));+    json!({"success": true, "riseC": rise, "platingUm": plating, "nets": rows, "ok": all_ok,+        "assumed": if copper.is_empty() { json!("no stack in the input: every layer taken as 35 um (1 oz) copper") } else { Value::Null },+        "_hint": if all_ok { "Every routed layer of these nets carries its current within the temperature rise (IPC-2221). Vias: a layer change needs neededPerLayerChange of them side by side." }+                 else { "shortOn names the layers whose narrowest copper is under neededMm: widen it (set_track_width, or a width rule with add_rule) or pour the net (apply_pours). A pour on the same layer (poursOn) may already carry the current; the check reads tracks only." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn ipc_2221_matches_its_chart() {+        // 1 oz (35 um) outer copper, 10 C rise: 1 A needs about 0.30 mm (12 mil), 3 A about 1.4 mm+        let w1 = width_for(1.0, 10.0, 35.0, true);+        assert!((0.25..0.35).contains(&w1), "{w1}");+        let w3 = width_for(3.0, 10.0, 35.0, true);+        assert!((1.2..1.6).contains(&w3), "{w3}");+        // inner layers need about 2.6x the width+        assert!(width_for(1.0, 10.0, 35.0, false) / w1 > 2.4);+        // a 0.3 mm via with 25 um plating carries roughly 1 to 1.5 A at 10 C+        let v = via_amps(0.3, 25.0, 10.0);+        assert!((0.8..1.6).contains(&v), "{v}");+    }++    #[test]+    fn check_finds_the_narrow_layer() {+        let st = json!({"tracks": [{"net": "VIN", "layer": "F.Cu", "width": 1.5, "start": {"x": 0.0, "y": 0.0}}, {"net": "VIN", "layer": "B.Cu", "width": 0.3, "start": {"x": 4.0, "y": 1.0}}],+                        "vias": [{"net": "VIN", "drill": 0.3}, {"net": "VIN", "drill": 0.4}], "polygons": [{"net": "VIN", "layer": "B.Cu"}]});+        let nets = json!({"VIN": 3.0}).as_object().unwrap().clone();+        let r = &check(&st, &[35.0, 35.0], &nets, 10.0, 25.0)[0];+        assert_eq!(r["shortOn"], json!(["B.Cu"]));+        assert_eq!(r["vias"]["smallestDrillMm"], 0.3);+        assert!(r["vias"]["neededPerLayerChange"].as_i64().unwrap() >= 2);+        assert_eq!(r["poursOn"], json!(["B.Cu"]));+    }++    #[test]+    fn the_verb_says_what_it_assumed_and_refuses_unknown_nets() {+        let st = json!({"nets": ["VIN"], "tracks": [{"net": "VIN", "layer": "F.Cu", "width": 0.2, "start": {"x": 0.0, "y": 0.0}}]});+        let r = current_capacity(&json!({"board": st, "args": {"nets": {"VIN": 2}}}));+        assert_eq!(r["ok"], json!(false));+        assert!(r["assumed"].as_str().unwrap().contains("35 um"));+        assert_eq!(current_capacity(&json!({"board": st, "args": {"nets": {"VOUT": 1}}}))["errorCode"], "no_such_net");+        assert_eq!(current_capacity(&json!({"board": st, "args": {}}))["errorCode"], "bad_args");+    }+}+
rust/vendor/adom-eda-checks/src/fab.rsadded+294
@@ -0,0 +1,294 @@+//! The board's fab-relevant minimums, measured from the board model, and how they compare with a fab's limits: the+//! numbers a fab's order form and capability page ask about, so the AI can check a board before ordering. Fab limits+//! are never kept here: they come in as a record from adom/pcb-design-rules (`fab`), or as explicit `limits`.+use serde_json::{json, Map, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++/// The nearest copper (track, arc, pad, via) to the real outline, edge of copper to the outline's line, mm. Pour+/// boundaries are left out: they are drawn on the edge and the tool pulls the poured copper back by its rules.+pub fn edge_distance(state: &Value) -> Option<(f64, Value)> {+    let ring = crate::coppermap::outline_ring(state);+    if ring.len() < 3 { return None; }+    let seg = |x: f64, y: f64| -> f64 {+        (0..ring.len()).map(|k| {+            let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+            let (dx, dy) = (b.0 - a.0, b.1 - a.1);+            let l2 = dx * dx + dy * dy;+            let t = if l2 > 0.0 { (((x - a.0) * dx + (y - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+            ((a.0 + t * dx - x).powi(2) + (a.1 + t * dy - y).powi(2)).sqrt()+        }).fold(f64::MAX, f64::min)+    };+    let copper = |l: &Value| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false);+    let mut pts: Vec<(f64, f64, f64, Value)> = Vec::new();+    for t in state["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| copper(&t["layer"])) {+        for p in [&t["start"], &t["end"]] { pts.push((f(&p["x"]), f(&p["y"]), f(&t["width"]) / 2.0, json!({"track": t["net"], "layer": t["layer"]}))); }+    }+    for v in state["vias"].as_array().cloned().unwrap_or_default() { pts.push((f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0, json!({"via": v["net"]}))); }+    for p in state["pads"].as_array().cloned().unwrap_or_default() {+        let (rf, nm) = (p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+        pts.push((f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["sizeY"])) / 2.0, json!({"pad": if rf.is_empty() { nm.to_string() } else { format!("{rf}.{nm}") }})));+    }+    pts.into_iter().map(|(x, y, half, mut what)| { what["at"] = json!([r3(x), r3(y)]); (r3(seg(x, y) - half), what) })+        .min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())+}++/// The smallest of each feature with where it is. Pure, for the tests.+pub fn measure(state: &Value) -> Map<String, Value> {+    let mut m = Map::new();+    let mut min_by = |key: &str, items: Vec<(f64, Value)>| {+        if let Some((v, at)) = items.into_iter().filter(|(v, _)| *v > 0.0).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) {+            m.insert(key.into(), json!({"mm": r3(v), "at": at}));+        }+    };+    // copper only: outline and drawing lines on mechanical layers (L57...) are not tracks a fab etches+    let copper = |l: &Value| l.as_str().map(|s| s == "F.Cu" || s == "B.Cu" || (s.starts_with("In") && s.ends_with(".Cu"))).unwrap_or(false);+    min_by("track", state["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| copper(&t["layer"])).map(|t| (f(&t["width"]), json!({"net": t["net"], "layer": t["layer"], "from": [t["start"]["x"], t["start"]["y"]]}))).collect());+    let vias = state["vias"].as_array().cloned().unwrap_or_default();+    min_by("viaDrill", vias.iter().map(|v| (f(&v["drill"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+    min_by("viaDiameter", vias.iter().map(|v| (f(&v["size"]), json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+    min_by("viaAnnularRing", vias.iter().map(|v| ((f(&v["size"]) - f(&v["drill"])) / 2.0, json!({"net": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect());+    let pads = state["pads"].as_array().cloned().unwrap_or_default();+    let drilled: Vec<&Value> = pads.iter().filter(|p| f(&p["drill"]) > 0.0).collect();+    let label = |p: &Value| json!({"pad": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "at": [p["x"], p["y"]]});+    min_by("padHole", drilled.iter().map(|p| (f(&p["drill"]), label(p))).collect());+    // a pad's ring on its narrow side (a round hole in an oblong pad)+    min_by("padAnnularRing", drilled.iter().filter(|p| f(&p["sizeX"]) > f(&p["drill"])).map(|p| ((f(&p["sizeX"]).min(f(&p["sizeY"])) - f(&p["drill"])) / 2.0, label(p))).collect());+    min_by("smdPad", pads.iter().filter(|p| f(&p["drill"]) == 0.0).map(|p| (f(&p["sizeX"]).min(f(&p["sizeY"])), label(p))).collect());+    if let Some((x0, y0, x1, y1)) = crate::geom::outline_bbox(state) {+        m.insert("boardSize".into(), json!({"mm": [r3(x1 - x0), r3(y1 - y0)]}));+        let _ = (x0, y0, x1, y1);+        if let Some((d, mut at)) = edge_distance(state) { at["mm"] = json!(d); m.insert("edgeClearance".into(), at); }+    }+    // drilled holes: the largest, and the closest pair edge to edge (via to via, and any pair with a pad hole)+    let mut holes: Vec<(f64, f64, f64, bool, Value)> = vias.iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["drill"]), false, json!({"via": v["net"], "at": [v["position"]["x"], v["position"]["y"]]}))).collect();+    holes.extend(drilled.iter().map(|p| (f(&p["x"]), f(&p["y"]), f(&p["drill"]), true, label(p))));+    if let Some(h) = holes.iter().max_by(|a, b| a.2.partial_cmp(&b.2).unwrap()) { m.insert("largestDrill".into(), json!({"mm": r3(h.2), "at": h.4})); }+    holes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+    let (mut vv, mut pp): (Option<(f64, Value)>, Option<(f64, Value)>) = (None, None);+    for i in 0..holes.len() {+        for j in i + 1..holes.len() {+            let (a, b) = (&holes[i], &holes[j]);+            if b.0 - a.0 > 10.0 { break; }+            let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() - (a.2 + b.2) / 2.0;+            if (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6 { continue; } // a via in a pad+            let slot = if a.3 || b.3 { &mut pp } else { &mut vv };+            if slot.as_ref().map(|s| d < s.0).unwrap_or(true) { *slot = Some((d, json!([a.4.clone(), b.4.clone()]))); }+        }+    }+    if let Some((d, at)) = vv { m.insert("holeToHoleVia".into(), json!({"mm": r3(d), "at": at})); }+    if let Some((d, at)) = pp { m.insert("holeToHolePad".into(), json!({"mm": r3(d), "at": at})); }+    m.insert("counts".into(), json!({"tracks": state["tracks"].as_array().map(|a| a.len()), "vias": vias.len(), "pads": pads.len(), "drilledPads": drilled.len()}));+    m+}++/// The measured values against the caller's limits; each limit names its feature (min*, or max for board and layers).+pub fn compare(meas: &Map<String, Value>, limits: &Map<String, Value>, clearance: Option<f64>, layers: Option<i64>) -> Vec<Value> {+    let mut out = Vec::new();+    let got = |k: &str| meas.get(k).and_then(|v| v["mm"].as_f64());+    for (limit, feature) in [("minTrack", "track"), ("minViaDrill", "viaDrill"), ("minViaDiameter", "viaDiameter"), ("minViaAnnularRing", "viaAnnularRing"),+                             ("minPadHole", "padHole"), ("minPadAnnularRing", "padAnnularRing"), ("minSmdPad", "smdPad"),+                             ("minEdgeClearance", "edgeClearance"), ("minHoleToHoleVia", "holeToHoleVia"), ("minHoleToHolePad", "holeToHolePad"),+                             ("minSilkLine", "silkLine"), ("minSilkText", "silkText")] {+        if let (Some(l), Some(v)) = (limits.get(limit).and_then(|x| x.as_f64()), got(feature)) {+            if v + 1e-9 < l { out.push(json!({"limit": limit, "limitMm": l, "boardMm": v, "at": meas[feature]["at"]})); }+        }+    }+    if let (Some(l), Some(c)) = (limits.get("minClearance").and_then(|x| x.as_f64()), clearance) {+        if c + 1e-9 < l { out.push(json!({"limit": "minClearance", "limitMm": l, "boardMm": c, "at": "the board's clearance rule"})); }+    }+    if let (Some(l), Some(n)) = (limits.get("maxLayers").and_then(|x| x.as_i64()), layers) {+        if n > l { out.push(json!({"limit": "maxLayers", "limit_": l, "board": n})); }+    }+    if let (Some(l), Some(v)) = (limits.get("maxDrill").and_then(|x| x.as_f64()), got("largestDrill")) {+        if v > l + 1e-9 { out.push(json!({"limit": "maxDrill", "limitMm": l, "boardMm": v, "at": meas["largestDrill"]["at"], "note": "larger holes are milled (routed) instead"})); }+    }+    if let (Some(lim), Some(sz)) = (limits.get("minBoardMm").and_then(|x| x.as_array()), meas.get("boardSize").and_then(|b| b["mm"].as_array())) {+        let (lw, lh) = (lim.first().and_then(|x| x.as_f64()).unwrap_or(0.0), lim.get(1).and_then(|x| x.as_f64()).unwrap_or(0.0));+        let (w, h) = (sz[0].as_f64().unwrap_or(0.0), sz[1].as_f64().unwrap_or(0.0));+        if w.min(h) + 1e-9 < lw.min(lh) || w.max(h) + 1e-9 < lw.max(lh) { out.push(json!({"limit": "minBoardMm", "limitMm": [lw, lh], "boardMm": [w, h]})); }+    }+    if let (Some(lim), Some(sz)) = (limits.get("maxBoardMm").and_then(|x| x.as_array()), meas.get("boardSize").and_then(|b| b["mm"].as_array())) {+        let (lw, lh) = (lim.first().and_then(|x| x.as_f64()).unwrap_or(f64::MAX), lim.get(1).and_then(|x| x.as_f64()).unwrap_or(f64::MAX));+        let (w, h) = (sz[0].as_f64().unwrap_or(0.0), sz[1].as_f64().unwrap_or(0.0));+        // either orientation fits+        if !((w <= lw && h <= lh) || (w <= lh && h <= lw)) { out.push(json!({"limit": "maxBoardMm", "limitMm": [lw, lh], "boardMm": [w, h]})); }+    }+    out+}++/// The smallest clearance among the board's enabled clearance rules, in mm. A rule gives it as a number+/// (`clearanceMm`, `gapMm` or `minMm`) or in a descriptor the tool wrote ("Gap=10mil", "Gap=0.2mm": Altium's form).+pub fn min_clearance(rules: &[Value]) -> Option<f64> {+    rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("clearance")).unwrap_or(false)+            || r["kindName"].as_str() == Some("Clearance") || r["name"].as_str().map(|n| n.to_lowercase().contains("clearance")).unwrap_or(false))+        .filter(|r| r["enabled"].as_bool() != Some(false))+        .filter_map(|r| {+            if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }+            let d = r["descriptor"].as_str()?;+            let i = d.find("Gap=")? + 4;+            let num: String = d[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+            let v: f64 = num.parse().ok()?;+            let unit = &d[i + num.len()..];+            Some(if unit.starts_with("mil") { v * 0.0254 } else { v })+        }).reduce(f64::min).map(r3)+}++/// A pcb-design-rules record (one entry of `rules/records/<vendor>.json`'s `records`, with the file's `vendor`) as the+/// limits this check compares, plus what to say about where they came from. Limits the record does not give are left+/// out, never guessed. Pure.+pub fn limits_from_record(rec: &Value, vendor: &str) -> (Map<String, Value>, Value) {+    let mut l = Map::new();+    let mut put = |k: &str, v: Option<f64>| { if let Some(v) = v { l.insert(k.into(), json!(v)); } };+    put("minTrack", rec["minTraceWidth"]["mm"].as_f64());+    put("minClearance", rec["minCopperClearance"]["mm"].as_f64().or(rec["minSpacing"]["mm"].as_f64()));+    put("minViaDrill", rec["minVia"]["drillMm"].as_f64());+    put("minViaDiameter", rec["minVia"]["padMm"].as_f64());+    put("minPadHole", rec["minDrill"]["mm"].as_f64());+    // the record's annular ring is the plated-through-hole ring ("PTH ... absolute minimum")+    put("minPadAnnularRing", rec["annularRing"]["viaMm"].as_f64());+    put("minEdgeClearance", rec["copperToBoardEdge"]["mm"].as_f64());+    put("minHoleToHoleVia", rec["holeToHole"]["mm"].as_f64());+    put("minSilkLine", rec["silkLineWidth"]["mm"].as_f64());+    if let (Some(x), Some(y)) = (rec["maxBoardSize"]["xMm"].as_f64(), rec["maxBoardSize"]["yMm"].as_f64()) { l.insert("maxBoardMm".into(), json!([x, y])); }+    if let Some(n) = rec["layerCount"].as_i64() { l.insert("maxLayers".into(), json!(n)); }+    let src = rec["sources"].as_array().and_then(|s| s.first()).cloned().unwrap_or(Value::Null);+    let conditions: Map<String, Value> = rec.as_object().map(|o| o.iter().filter_map(|(k, v)| v.get("condition").map(|c| (k.clone(), c.clone()))).collect()).unwrap_or_default();+    let fab = json!({"name": vendor, "process": rec["process"], "layers": rec["layerCount"], "source": src["url"], "read": src["dateRead"],+                     "conditions": conditions, "notes": rec["notes"]});+    (l, fab)+}++/// fab_check: input {board, stack?, rules?, silk?, fab?, args: {limits?, copperOz?}}. `fab` is a pcb-design-rules+/// record (one of a vendor file's `records`, with `vendor` beside it or given as args.vendor); `limits` adds or+/// overrides any limit by name, in mm.+pub fn fab_check(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let mut meas = measure(&st);+    let rule_list = crate::input::rules(input);+    let clearance = min_clearance(&rule_list);+    if let Some(c) = clearance { meas.insert("clearanceRule".into(), json!({"mm": c})); }+    let stack = crate::input::stack(input);+    let layers = stack.as_ref().and_then(|s| s["copperLayers"].as_i64());+    if let Some(n) = layers { meas.insert("copperLayers".into(), json!(n)); }+    let mut limits = Map::new();+    let mut fab = Value::Null;+    if let Some(rec) = input.get("fab").filter(|r| !r.is_null()) {+        let vendor = rec["vendor"].as_str().or(args["vendor"].as_str()).unwrap_or("the fab").to_string();+        let (l, mut fv) = limits_from_record(rec, &vendor);+        if let (Some(want), Some(n)) = (rec["layerCount"].as_i64(), layers) {+            if want != n { fv["mismatch"] = json!(format!("the record is for {want} layers; the board has {n}: pass the {n}-layer record")); }+        }+        // the record's limits hold for its stated copper (1 oz outer as a rule); heavier copper needs wider tracks+        let outer_um = stack.as_ref().and_then(|s| s["layers"][0]["copperUm"].as_f64()).unwrap_or(35.0);+        let oz = args["copperOz"].as_f64().unwrap_or(outer_um / 35.0);+        fv["copperOz"] = json!(r3(oz));+        if oz >= 1.75 { fv["copperNote"] = json!("the board's outer copper is heavier than the record's 1 oz condition: its notes give the wider track and gap limits for heavier copper; pass them as limits"); }+        limits = l;+        fab = fv;+    }+    for (k, v) in args["limits"].as_object().cloned().unwrap_or_default() { limits.insert(k, v); }+    // silkscreen sizes from silk_state, only when a limit asks and the input has it+    if limits.contains_key("minSilkText") || limits.contains_key("minSilkLine") {+        if let Some(silk) = crate::input::part(input, "silk") {+            let mut texts: Vec<(f64, Value)> = silk["texts"].as_array().cloned().unwrap_or_default().iter().filter(|t| t["side"] != "other").map(|t| (f(&t["size"]), json!({"text": t["text"], "at": [t["x"], t["y"]]}))).collect();+            for c in silk["components"].as_array().cloned().unwrap_or_default() {+                for k in ["name", "comment"] { if c[k]["shown"].as_bool() == Some(true) { texts.push((f(&c[k]["size"]), json!({"ref": c["ref"], "text": c[k]["text"], "at": [c[k]["x"], c[k]["y"]]}))); } }+            }+            if let Some((v, at)) = texts.into_iter().filter(|t| t.0 > 0.0).min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()) { meas.insert("silkText".into(), json!({"mm": r3(v), "at": at})); }+            if let Some(l) = silk["lines"].as_array().cloned().unwrap_or_default().iter().filter(|l| l["side"] != "other" && f(&l["width"]) > 0.0).min_by(|a, b| f(&a["width"]).partial_cmp(&f(&b["width"])).unwrap()) {+                meas.insert("silkLine".into(), json!({"mm": r3(f(&l["width"])), "at": [l["x1"], l["y1"]]}));+            }+        }+    }+    let mut fails = compare(&meas, &limits, clearance, layers);+    // each finding names where its limit came from+    if !fab.is_null() { for x in fails.iter_mut() { x["source"] = json!({"name": fab["name"], "url": fab["source"], "read": fab["read"]}); } }+    let unseen: Vec<&str> = [("minClearance", clearance.is_some()), ("minSilkText", meas.contains_key("silkText")), ("minSilkLine", meas.contains_key("silkLine")), ("maxLayers", layers.is_some())]+        .iter().filter(|(k, seen)| limits.contains_key(*k) && !seen).map(|(k, _)| *k).collect();+    json!({"success": true, "fab": fab, "measured": meas, "limitsChecked": limits.keys().collect::<Vec<_>>(), "outsideLimits": fails,+        "notChecked": unseen, "fits": if limits.is_empty() { Value::Null } else { json!(fails.is_empty()) },+        "costlierOrBelowRecommended": [],+        "_hint": if limits.is_empty() { "Measured only. Pass a fab's record from adom/pcb-design-rules as fab, or limits:{minTrack, minClearance, minViaDrill, minViaDiameter, minViaAnnularRing, minPadHole, minPadAnnularRing, minSmdPad, minEdgeClearance, minHoleToHoleVia, minHoleToHolePad, maxDrill, minSilkLine, minSilkText, maxLayers, minBoardMm:[w,h], maxBoardMm:[w,h]} in mm, to check them." }+                 else { "outsideLimits names each feature below the fab's limit, where it is, and the page the limit came from. notChecked lists limits the input gave nothing to measure against (pass rules, stack or silk). Read fab.notes: costlier options and recommended sizes are there. Fix the board (set_track_width, add_rule, a footprint change) or choose the fab's advanced service; then run run_drc." }})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"tracks": [{"width": 0.25, "net": "A", "layer": "F.Cu", "start": {"x": 0.0, "y": 0.0}}, {"width": 0.1, "net": "B", "layer": "B.Cu", "start": {"x": 5.0, "y": 5.0}}, {"width": 0.05, "net": "", "layer": "L57", "start": {"x": 0.0, "y": 0.0}}],+               "vias": [{"size": 0.6, "drill": 0.3, "net": "GND", "position": {"x": 1.0, "y": 1.0}}, {"size": 0.45, "drill": 0.25, "net": "B", "position": {"x": 2.0, "y": 2.0}}],+               "pads": [{"ref": "J1", "name": "1", "x": 0.0, "y": 0.0, "sizeX": 1.7, "sizeY": 1.7, "drill": 1.0}, {"ref": "U1", "name": "3", "x": 3.0, "y": 0.0, "sizeX": 0.3, "sizeY": 1.0, "drill": 0.0}],+               "outline": [[0, 0], [50, 0], [50, 30], [0, 30]]})+    }++    #[test]+    fn the_smallest_features_are_found_with_where_they_are() {+        let m = measure(&board());+        assert_eq!(m["track"]["mm"], json!(0.1));+        assert_eq!(m["track"]["at"]["net"], json!("B"));+        assert_eq!((m["viaDrill"]["mm"].clone(), m["viaDiameter"]["mm"].clone(), m["viaAnnularRing"]["mm"].clone()), (json!(0.25), json!(0.45), json!(0.1)));+        assert_eq!((m["padHole"]["mm"].clone(), m["padAnnularRing"]["mm"].clone(), m["smdPad"]["mm"].clone()), (json!(1.0), json!(0.35), json!(0.3)));+        assert_eq!(m["boardSize"]["mm"], json!([50.0, 30.0]));+    }++    #[test]+    fn arc_edges_count_toward_the_board_size() {+        let b = json!({"outline": [[0, 0], [10, 0]], "outlineEdges": [{"kind": "arc", "center": [5.0, 0.0], "radius": 5.0, "angle1": 0.0, "angle2": 180.0}]});+        assert_eq!(measure(&b)["boardSize"]["mm"], json!([10.0, 5.0]));+    }++    #[test]+    fn limits_name_what_falls_short_and_a_board_fits_either_way_round() {+        let m = measure(&board());+        let lim = json!({"minTrack": 0.127, "minViaDrill": 0.3, "maxBoardMm": [30, 60], "maxLayers": 2, "minClearance": 0.127});+        let f = compare(&m, lim.as_object().unwrap(), Some(0.1), Some(2));+        let names: Vec<&str> = f.iter().map(|x| x["limit"].as_str().unwrap()).collect();+        assert_eq!(names, vec!["minTrack", "minViaDrill", "minClearance"]);+    }++    #[test]+    fn clearance_is_read_from_rule_descriptors_in_mil_or_mm() {+        let rules = vec![json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=10mil) (All),(All)", "enabled": true}),+                         json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.15mm) (InNet('SW')),(All)", "enabled": true}),+                         json!({"kind": 0, "descriptor": "Clearance Constraint (Gap=0.05mm) (All),(All)", "enabled": false})];+        assert_eq!(min_clearance(&rules), Some(0.15));+    }+    /// JLCPCB's 2-layer record as adom/pcb-design-rules gives it (rules/records/jlcpcb.json, read 2026-09-03), trimmed+    fn jlc_2l() -> Value {+        json!({"vendor": "JLCPCB", "process": "standard", "layerCount": 2, "minTraceWidth": {"mm": 0.1, "condition": "1 oz copper outer layer"},+               "minSpacing": {"mm": 0.1}, "minDrill": {"mm": 0.15}, "minVia": {"drillMm": 0.15, "padMm": 0.25}, "annularRing": {"viaMm": 0.18},+               "copperToBoardEdge": {"mm": 0.2}, "holeToHole": {"mm": 0.2}, "silkLineWidth": {"mm": 0.15}, "minCopperClearance": {"mm": 0.1},+               "maxBoardSize": {"xMm": 670.0, "yMm": 600.0}, "notes": "Min drill 0.15 mm costs more.",+               "sources": [{"url": "https://jlcpcb.com/capabilities/pcb-capabilities", "dateRead": "2026-09-03"}]})+    }++    #[test]+    fn a_pcb_design_rules_record_becomes_limits_with_its_source() {+        let (l, fab) = limits_from_record(&jlc_2l(), "JLCPCB");+        assert_eq!((l["minTrack"].clone(), l["minClearance"].clone(), l["minPadAnnularRing"].clone()), (json!(0.1), json!(0.1), json!(0.18)));+        assert_eq!(l["maxBoardMm"], json!([670.0, 600.0]));+        assert!(!l.contains_key("minSilkText"), "a limit the record does not give is not guessed");+        assert_eq!((fab["source"].as_str(), fab["read"].as_str()), (Some("https://jlcpcb.com/capabilities/pcb-capabilities"), Some("2026-09-03")));+        // a 0.08 mm track fails, holes 0.15 mm apart edge to edge fail the via rule; each finding names the page+        let st = json!({"tracks": [{"width": 0.08, "net": "A", "layer": "F.Cu", "start": {"x": 1.0, "y": 1.0}}],+                        "vias": [{"drill": 0.3, "size": 0.6, "net": "A", "position": {"x": 0.0, "y": 0.0}}, {"drill": 0.3, "size": 0.6, "net": "B", "position": {"x": 0.45, "y": 0.0}}],+                        "outline": [[-5, -5], [5, -5], [5, 5], [-5, 5]]});+        let r = fab_check(&json!({"board": st, "stack": {"copperLayers": 2, "layers": [{"copperUm": 35}, {"copperUm": 35}]}, "fab": jlc_2l()}));+        let fails: Vec<&str> = r["outsideLimits"].as_array().unwrap().iter().map(|f| f["limit"].as_str().unwrap()).collect();+        assert!(fails.contains(&"minTrack") && fails.contains(&"minHoleToHoleVia"), "{fails:?}");+        assert_eq!(r["outsideLimits"][0]["source"]["read"], "2026-09-03");+        assert_eq!(r["fits"], json!(false));+        // no rules in the input: the clearance limit is reported as not checked, never passed+        assert!(r["notChecked"].as_array().unwrap().contains(&json!("minClearance")));+    }++    #[test]+    fn clearance_from_a_standard_rule() {+        assert_eq!(min_clearance(&[json!({"kind": "clearance", "clearanceMm": 0.127}), json!({"kind": "clearance", "gapMm": 0.2})]), Some(0.127));+    }+}
rust/vendor/adom-eda-checks/src/geom.rsadded+23
@@ -0,0 +1,23 @@+//! Board geometry shared by the checks: the outline's bounding box with its arc edges sampled.+use serde_json::Value;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }++/// The outline's bounding box (x0, y0, x1, y1) in mm, arc edges included.+pub fn outline_bbox(state: &Value) -> Option<(f64, f64, f64, f64)> {+    let mut o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect();+    // an arc edge bulges past its end points: sample it (the Bluetooth Sentinel example measured 80 x 4.5 mm by its+    // vertices alone)+    for e in state["outlineEdges"].as_array().cloned().unwrap_or_default() {+        if e["kind"].as_str() != Some("arc") { continue; }+        let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+        let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));+        if a2 <= a1 { a2 += 360.0; }+        let mut a = a1;+        while a <= a2 + 1e-9 { o.push((cx + r * a.to_radians().cos(), cy + r * a.to_radians().sin())); a += 2.0; }+    }+    if o.is_empty() { return None; }+    let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    Some((x0, y0, x1, y1))+}
rust/vendor/adom-eda-checks/src/impedance.rsadded+234
@@ -0,0 +1,234 @@+//! Controlled impedance from the board's stack-up (the layer_stack companion): the track width (and pair gap) each copper layer needs for a+//! target impedance, by closed-form models (Hammerstad-Jensen microstrip, Wheeler stripline, IPC-2141 coupling). Within+//! a few percent of a field solver for ordinary FR-4 geometry; the fab's stack-up and its solver have the last word.+use serde_json::{json, Value};+use std::f64::consts::PI;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }++/// Free-space impedance of a microstrip of width ratio u (Hammerstad-Jensen).+fn z01(u: f64) -> f64 {+    let f = 6.0 + (2.0 * PI - 6.0) * (-(30.666 / u).powf(0.7528)).exp();+    60.0 * (f / u + (1.0 + 4.0 / (u * u)).sqrt()).ln()+}++fn eeff0(u: f64, er: f64) -> f64 {+    let a = 1.0 + ((u.powi(4) + (u / 52.0).powi(2)) / (u.powi(4) + 0.432)).ln() / 49.0 + (1.0 + (u / 18.1).powi(3)).ln() / 18.7;+    let b = 0.564 * ((er - 0.9) / (er + 3.0)).powf(0.053);+    (er + 1.0) / 2.0 + (er - 1.0) / 2.0 * (1.0 + 10.0 / u).powf(-a * b)+}++/// Uncoated microstrip: width w over a plane h below, copper t thick, dielectric er (any length unit).+pub fn microstrip(w: f64, h: f64, t: f64, er: f64) -> f64 {+    let u = w / h;+    let tn = t / h;+    let coth = |x: f64| x.cosh() / x.sinh();+    let du1 = if tn > 0.0 { tn / PI * (1.0 + 4.0 * std::f64::consts::E / (tn * coth((6.517 * u).sqrt()).powi(2))).ln() } else { 0.0 };+    let dur = 0.5 * (1.0 + 1.0 / (er - 1.0).sqrt().cosh()) * du1;+    let (u1, ur) = (u + du1, u + dur);+    let ee = eeff0(ur, er) * (z01(u1) / z01(ur)).powi(2);+    z01(ur) / ee.sqrt()+}++/// Centred stripline (Wheeler 1978, thickness corrected): width w between planes b apart, copper t.+fn stripline_sym(w: f64, b: f64, t: f64, er: f64) -> f64 {+    let x = t / b;+    let bt = b - t;+    let dw = if x > 0.0 {+        let n = 2.0 / (1.0 + 2.0 * x / (3.0 * (1.0 - x)));+        bt * (x / (PI * (1.0 - x))) * (1.0 - 0.5 * ((x / (2.0 - x)).powi(2) + (0.0796 * x / (w / b + 1.1 * x)).powf(n)).ln())+    } else { 0.0 };+    let m = (w + dw) / bt;+    let k = 8.0 / (PI * m);+    30.0 / er.sqrt() * (1.0 + 4.0 / (PI * m) * (k + (k * k + 6.27).sqrt())).ln()+}++/// Stripline h1 above and h2 below the track (planes h1 + t + h2 apart): Cohn's combination of the two centred cases.+pub fn stripline(w: f64, h1: f64, h2: f64, t: f64, er: f64) -> f64 {+    let za = stripline_sym(w, 2.0 * h1 + t, t, er);+    let zb = stripline_sym(w, 2.0 * h2 + t, t, er);+    2.0 * za * zb / (za + zb)+}++/// Edge-coupled differential impedance (IPC-2141): the single-ended z0, gap s, and h (microstrip) or plane spacing b+/// (stripline).+pub fn differential(z0: f64, s: f64, hb: f64, strip: bool) -> f64 {+    if strip { 2.0 * z0 * (1.0 - 0.374 * (-2.9 * s / hb).exp()) } else { 2.0 * z0 * (1.0 - 0.48 * (-0.96 * s / hb).exp()) }+}++/// The width (same unit as the geometry) where `z(w)` meets `target`; z falls as w grows. None when out of range.+fn solve(target: f64, lo: f64, hi: f64, z: impl Fn(f64) -> f64) -> Option<f64> {+    if z(lo) < target || z(hi) > target { return None; }+    let (mut a, mut b) = (lo, hi);+    for _ in 0..80 {+        let m = (a + b) / 2.0;+        if z(m) > target { a = m } else { b = m }+    }+    Some((a + b) / 2.0)+}++/// One copper layer's geometry from the stack (mm): the dielectric to the copper above and below, and their Dk.+#[derive(Debug, Clone)]+pub struct Geo { pub name: String, pub t: f64, pub above: Option<(f64, f64)>, pub below: Option<(f64, f64)> }++/// The stack summary (`layer_stack` data) as each copper layer's geometry.+pub fn geometry(stack: &Value) -> Vec<Geo> {+    let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();+    let um = |v: &Value| v.as_f64().unwrap_or(0.0) / 1000.0;+    let diel = |l: &Value| -> Option<(f64, f64)> {+        let h = um(&l["dielectricUm"]);+        if h <= 0.0 { None } else { Some((h, l["dk1000"].as_f64().map(|d| d / 1000.0).unwrap_or(4.3))) }+    };+    (0..ls.len()).map(|i| Geo {+        name: ls[i]["name"].as_str().unwrap_or("").to_string(),+        t: um(&ls[i]["copperUm"]).max(0.0),+        above: if i > 0 { diel(&ls[i - 1]) } else { None },+        below: diel(&ls[i]),+    }).collect()+}++/// Width (and gap) for each layer, mm. `diff` is the differential target; `gap` fixes the pair gap (else gap = width).+pub fn widths(geo: &[Geo], z0: Option<f64>, diff: Option<f64>, gap: Option<f64>) -> Vec<Value> {+    geo.iter().map(|g| {+        let (strip, z): (bool, Box<dyn Fn(f64) -> f64>) = match (g.above, g.below) {+            (Some((h1, e1)), Some((h2, e2))) => {+                let er = (e1 * h1 + e2 * h2) / (h1 + h2);+                let t = g.t;+                (true, Box::new(move |w| stripline(w, h1, h2, t, er)))+            }+            (None, Some((h, er))) | (Some((h, er)), None) => { let t = g.t; (false, Box::new(move |w| microstrip(w, h, t, er))) }+            (None, None) => return json!({"layer": g.name, "error": "no dielectric next to this layer in the stack"}),+        };+        // the spacing the coupling term uses: h for microstrip, plane to plane for stripline+        let hb = match (g.above, g.below) { (Some((a, _)), Some((b, _))) => a + b, (Some((h, _)), None) | (None, Some((h, _))) => h, _ => 1.0 };+        let mut o = json!({"layer": g.name, "model": if strip { "stripline" } else { "microstrip" }, "copperUm": r1(g.t * 1000.0),+                           "dielectricMm": [g.above.map(|a| r3(a.0)), g.below.map(|b| r3(b.0))]});+        if let Some(t) = z0 {+            match solve(t, 0.01, 20.0, &z) { Some(w) => { o["width"] = json!(r3(w)); o["z0"] = json!(r1(z(w))); } None => { o["z0Error"] = json!(format!("{t} ohm is out of reach on this layer (0.01 to 20 mm)")); } }+        }+        if let Some(t) = diff {+            let zd = |w: f64| differential(z(w), gap.unwrap_or(w), hb, strip);+            match solve(t, 0.01, 20.0, zd) {+                Some(w) => { o["diffWidth"] = json!(r3(w)); o["diffGap"] = json!(r3(gap.unwrap_or(w))); o["zdiff"] = json!(r1(zd(w))); o["diffZ0"] = json!(r1(z(w))); }+                None => { o["diffError"] = json!(format!("{t} ohm differential is out of reach on this layer with that gap")); }+            }+        }+        o+    }).collect()+}++/// impedance: input {stack, args: {z0?, diff?, gapMm?, layers?, apply?: {scope, name?}}}. Needs only the stack-up.+/// `apply` answers a plan, never writes: one `add_rule` per layer (a width rule, or a diffpair rule for `diff`) scoped+/// by the caller's `scope` in the tool's own query language, at +/-10%, for the bridge to run.+pub fn impedance(input: &Value) -> Value {+    let args = &input["args"];+    let z0 = args.get("z0").and_then(|v| v.as_f64());+    let diff = args.get("diff").and_then(|v| v.as_f64());+    let z0 = if z0.is_none() && diff.is_none() { Some(50.0) } else { z0 };+    let gap = args.get("gapMm").and_then(|v| v.as_f64());+    let Some(stack) = crate::input::stack(input) else { return crate::input::refuse("no_stack", "the input has no stack: pass the layer_stack companion as stack") };+    if stack.get("ok") == Some(&json!(false)) || stack["layers"].as_array().map(|a| a.is_empty()).unwrap_or(true) {+        return crate::input::refuse("no_stack", "the stack has no layers");+    }+    let mut geo = geometry(&stack);+    // each copper layer's board model name beside the tool's own (F.Cu, In1.Cu, B.Cu)+    let board_names: Vec<String> = crate::coppermap::stack_layers(&stack).into_iter().map(|l| l.0).collect();+    let mut named: Vec<(Geo, String)> = geo.drain(..).zip(board_names).collect();+    if let Some(want) = args.get("layers").and_then(|v| v.as_array()) {+        let names: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();+        named.retain(|(g, b)| names.contains(&g.name.to_ascii_lowercase()) || names.contains(&b.to_ascii_lowercase()));+    }+    let geo: Vec<Geo> = named.iter().map(|n| n.0.clone()).collect();+    let mut rows = widths(&geo, z0, diff, gap);+    for (row, (_, b)) in rows.iter_mut().zip(&named) { row["boardLayer"] = json!(b); }+    // a rigid-flex stack (the Bluetooth Sentinel example) has a coverlay a few microns thick next to a copper layer:+    // the numbers are right for that geometry and useless for a rigid board, so say which rows to doubt+    let mut warnings = Vec::new();+    for (g, row) in geo.iter().zip(&rows) {+        for d in [g.above, g.below].iter().flatten() {+            if d.0 < 0.05 { warnings.push(format!("{}: a {} mm dielectric next to it (a flex coverlay or a stack-up typo?): check the stack-up in the tool", g.name, r3(d.0))); break; }+        }+        for k in ["width", "diffWidth", "diffGap"] {+            if let Some(w) = row[k].as_f64() { if w < 0.075 { warnings.push(format!("{}: {k} {w} mm is below what most fabs make (0.075 to 0.1 mm)", g.name)); } }+        }+    }+    let mut plan = Value::Null;+    if let Some(ap) = args.get("apply").filter(|a| !a.is_null()) {+        let Some(scope) = ap.get("scope").and_then(|v| v.as_str()) else { return crate::input::refuse("bad_args", "apply.scope is the tool's query for what the rule covers (Altium: InNetClass('RF'); KiCad: A.NetClass == 'RF')") };+        let base = ap.get("name").and_then(|v| v.as_str()).unwrap_or("Impedance");+        let mut rules = Vec::new();+        for row in &rows {+            let layer = row["layer"].as_str().unwrap_or("");+            let common = json!({"name": format!("{base} {layer}"), "scope": scope, "layer": row["boardLayer"], "toolLayer": layer});+            let mut a = if diff.is_some() {+                let (Some(w), Some(s)) = (row["diffWidth"].as_f64(), row["diffGap"].as_f64()) else { continue };+                json!({"kind": "diffpair", "width": w, "gap": s, "minWidth": r3(w * 0.9), "maxWidth": r3(w * 1.1), "minGap": r3(s * 0.9), "maxGap": r3(s * 1.1)})+            } else {+                let Some(w) = row["width"].as_f64() else { continue };+                json!({"kind": "width", "min": r3(w * 0.9), "preferred": w, "max": r3(w * 1.1)})+            };+            for (k, v) in common.as_object().unwrap() { a[k] = v.clone(); }+            rules.push(a);+        }+        plan = json!({"verb": "add_rule", "rules": rules, "note": "A plan only: nothing changed. Run add_rule once per rule; each is scoped to its layer and to scope."});+    }+    json!({"success": true, "targets": {"z0": z0, "diff": diff, "gapMm": gap}, "layers": rows, "apply": plan, "warnings": warnings,+        "stack": {"copperLayers": stack["copperLayers"], "totalUm": stack["totalUm"]},+        "_hint": "Widths are for uncoated copper: solder mask lowers an outer layer's impedance by about 1 to 3 ohm. Inner layers assume planes on both neighbours. Within a few percent of a field solver; send the fab the target and let its stack-up tune it. apply:{scope} answers one width or diffpair rule per layer at +/-10% for add_rule."})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn microstrip_matches_the_textbook_50_ohm_line() {+        // FR-4 er 4.4, 1.6 mm, 35 um: a 50 ohm line is about 3.0 mm wide (Pozar, field solvers 2.9 to 3.1)+        let w = solve(50.0, 0.01, 20.0, |w| microstrip(w, 1.6, 0.035, 4.4)).unwrap();+        assert!((2.8..3.2).contains(&w), "{w}");+    }++    #[test]+    fn stripline_matches_the_textbook_case() {+        // centred stripline, planes 1 mm apart, er 4.3, t 17 um: IPC-2141's formula puts 50 ohm near 0.40 mm+        let w = solve(50.0, 0.01, 20.0, |w| stripline(w, 0.4915, 0.4915, 0.017, 4.3)).unwrap();+        assert!((0.38..0.48).contains(&w), "{w}");+        // off centre, the nearer plane pulls the impedance down+        assert!(stripline(0.3, 0.2, 0.8, 0.017, 4.3) < stripline(0.3, 0.5, 0.5, 0.017, 4.3));+    }++    #[test]+    fn geometry_and_widths_from_a_two_layer_stack() {+        let st = json!({"layers": [{"name": "Top Layer", "copperUm": 36, "dielectricUm": 320, "dk1000": 4800}, {"name": "Bottom Layer", "copperUm": 36}]});+        let g = geometry(&st);+        assert_eq!(g.len(), 2);+        assert!(g[1].above.is_some() && g[1].below.is_none());+        let rows = widths(&g, Some(50.0), Some(90.0), None);+        let w = rows[0]["width"].as_f64().unwrap();+        assert!((0.45..0.65).contains(&w), "{w}");+        assert_eq!(rows[0]["model"], "microstrip");+        // gap = width: a 90 ohm pair's tracks come out near the 50 ohm width+        assert!((rows[0]["diffWidth"].as_f64().unwrap() - w).abs() < 0.1);+        assert!(widths(&g, None, Some(90.0), Some(0.2))[0]["diffWidth"].as_f64().unwrap() < rows[0]["diffWidth"].as_f64().unwrap());+        assert!((rows[0]["zdiff"].as_f64().unwrap() - 90.0).abs() < 0.5);+    }++    #[test]+    fn apply_is_a_plan_of_add_rule_args_on_standard_layers() {+        let input = json!({"stack": {"copperLayers": 2, "layers": [{"name": "Top Layer", "copperUm": 36, "dielectricUm": 320, "dk1000": 4800}, {"name": "Bottom Layer", "copperUm": 36}]},+                           "args": {"z0": 50, "apply": {"scope": "InNetClass('RF')"}}});+        let r = impedance(&input);+        assert_eq!(r["layers"][0]["boardLayer"], "F.Cu");+        assert_eq!(r["apply"]["verb"], "add_rule");+        let rule = &r["apply"]["rules"][1];+        assert_eq!((rule["kind"].as_str(), rule["layer"].as_str(), rule["toolLayer"].as_str()), (Some("width"), Some("B.Cu"), Some("Bottom Layer")));+        assert_eq!(rule["scope"], "InNetClass('RF')");+        // a filter by either name+        let only = impedance(&json!({"stack": input["stack"], "args": {"layers": ["B.Cu"]}}));+        assert_eq!(only["layers"].as_array().unwrap().len(), 1);+        assert_eq!(impedance(&json!({"args": {}}))["errorCode"], "no_stack");+        assert_eq!(impedance(&json!({"stack": input["stack"], "args": {"apply": {}}}))["errorCode"], "bad_args");+    }+}+
rust/vendor/adom-eda-checks/src/input.rsadded+55
@@ -0,0 +1,55 @@+//! The input bundle every check takes: `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}`, each the+//! standard model a bridge answers (schema/board.schema.json, schema/companions.schema.json). A bridge's whole reply+//! is accepted too: the model's keys at the top level, or under `data` (the reply envelope keys are ignored).+use serde_json::{json, Value};++/// A refusal in the standard reply envelope.+pub fn refuse(code: &str, error: &str) -> Value {+    json!({"success": false, "errorCode": code, "error": error})+}++/// The companion under `key`, unwrapped from a reply's `data` when it came as one.+pub fn part(input: &Value, key: &str) -> Option<Value> {+    let v = input.get(key)?;+    if v.is_null() { return None; }+    Some(if v.get("data").map(|d| d.is_object()).unwrap_or(false) { v["data"].clone() } else { v.clone() })+}++/// The board model, or `bad_args` when there is none or the bridge's reply was a refusal.+pub fn board(input: &Value) -> Result<Value, Value> {+    let Some(b) = part(input, "board") else { return Err(refuse("bad_args", "the input has no board: pass the board model (routing_state) as board")) };+    if b.get("success") == Some(&json!(false)) { return Err(b); }+    Ok(b)+}++/// The stack-up companion (layer_stack), if given.+pub fn stack(input: &Value) -> Option<Value> { part(input, "stack") }++/// The poured copper (pour_state's `zones`), empty when not given.+pub fn pours(input: &Value) -> Vec<Value> {+    let Some(p) = part(input, "pours") else { return Vec::new() };+    p.get("zones").or(Some(&p)).and_then(|z| z.as_array()).cloned().unwrap_or_default()+}++/// The board's rules (read_rules): a list, or a reply with `rules`.+pub fn rules(input: &Value) -> Vec<Value> {+    let Some(r) = part(input, "rules") else { return Vec::new() };+    r.get("rules").or(Some(&r)).and_then(|x| x.as_array()).cloned().unwrap_or_default()+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn companions_come_bare_or_in_a_reply() {+        let bare = json!({"board": {"outline": []}, "stack": {"layers": [{"name": "Top"}]}, "pours": {"zones": [{"net": "GND"}]}});+        let wrapped = json!({"board": {"success": true, "outline": []}, "stack": {"success": true, "data": {"layers": [{"name": "Top"}]}}, "pours": [{"net": "GND"}]});+        for i in [bare, wrapped] {+            assert!(board(&i).is_ok());+            assert_eq!(stack(&i).unwrap()["layers"][0]["name"], "Top");+            assert_eq!(pours(&i)[0]["net"], "GND");+        }+        assert_eq!(board(&json!({})).unwrap_err()["errorCode"], "bad_args");+    }+}
rust/vendor/adom-eda-checks/src/lib.rsadded+29
@@ -0,0 +1,29 @@+//! adom-eda-checks: the EDA Bridge Standard's checks tier, computed from the board model and its companions as plain+//! JSON. No bridge calls, no tool, no I/O: a bridge links this crate (or runs the `adom-eda-checks` command) and+//! answers the check under its own prefix (`kicad_return_path`). Replies keep the standard envelope (`success`,+//! `errorCode`, `error`, `_hint`) and name verbs without a prefix.+//!+//! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`).+use serde_json::Value;++pub mod coppermap;+pub mod current;+pub mod fab;+pub mod geom;+pub mod impedance;+pub mod input;++/// The checks this build answers, by standard verb name.+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"];++/// Run one check by its standard name.+pub fn run(check: &str, input: &Value) -> Value {+    match check {+        "copper_balance" => coppermap::copper_balance(input),+        "current_capacity" => current::current_capacity(input),+        "fab_check" => fab::fab_check(input),+        "impedance" => impedance::impedance(input),+        "return_path" => coppermap::return_path(input),+        _ => input::refuse("not_supported", &format!("{check} is not a check this engine answers (it answers {})", CHECKS.join(", "))),+    }+}
rust/vendor/adom-eda-checks/src/main.rsadded+48
@@ -0,0 +1,48 @@+//! adom-eda-checks <check> [bundle.json | -]+//! adom-eda-checks <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']+//! Prints the check's reply as JSON. `adom-eda-checks list` names the checks.+use serde_json::{json, Value};+use std::io::Read;++fn read(path: &str) -> Result<Value, String> {+    let text = if path == "-" {+        let mut s = String::new();+        std::io::stdin().read_to_string(&mut s).map_err(|e| format!("stdin: {e}"))?;+        s+    } else {+        std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?+    };+    serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))+}++fn bundle(rest: &[String]) -> Result<Value, String> {+    if rest.len() == 1 { return read(&rest[0]); }+    let mut b = json!({});+    let mut it = rest.iter();+    while let Some(flag) = it.next() {+        let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?;+        match flag.as_str() {+            "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--sheet-parts" | "--netlist" => {+                let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] };+                b[key] = read(val)?;+            }+            "--args" => b["args"] = serde_json::from_str(val).map_err(|e| format!("--args: {e}"))?,+            _ => return Err(format!("unknown flag {flag}")),+        }+    }+    Ok(b)+}++fn main() {+    let argv: Vec<String> = std::env::args().skip(1).collect();+    let out = match argv.first().map(String::as_str) {+        None | Some("-h") | Some("--help") => json!({"success": true, "usage": "adom-eda-checks <check> [bundle.json | -] | <check> --board b.json [--stack s.json] [--pours p.json] [--args '{...}']", "checks": adom_eda_checks::CHECKS}),+        Some("list") => json!({"success": true, "checks": adom_eda_checks::CHECKS}),+        Some(check) => match bundle(&argv[1..]) {+            Ok(b) => adom_eda_checks::run(check, &b),+            Err(e) => json!({"success": false, "errorCode": "bad_args", "error": e, "_hint": "Pass one bundle file {board, stack, pours, args}, or --board, --stack, --pours and --args."}),+        },+    };+    println!("{}", serde_json::to_string_pretty(&out).unwrap());+    if out.get("success") == Some(&json!(false)) { std::process::exit(1); }+}
tests/run_verb_tests.py+6−1
@@ -237,6 +237,11 @@ t("fusion_remove_parts#dry", "daily", dict(B, refs=["R2"], dryRun=True), {"ok": t("fusion_edit_parts#dry", "daily", dict(B, edits=[{"ref": "R2", "value": "0.01"}], dryRun=True), {"ok": True, "fields": ["plan"]}, "plan only") for v in ("fusion_edit_parts", "fusion_align_parts", "fusion_remove_graphics", "fusion_remove_parts", "fusion_generate_body", "fusion_library_views"):     t(v, "manual", note="edits a design, writes files or closes documents: tested live on scratch imports and fl-sync-test")+t("fusion_copper_balance", "daily", B, {"ok": True, "fields": ["pairs", "engine"]}, "the shared checks engine (adom-eda-checks, vendored)")+t("fusion_return_path", "daily", B, {"ok": True, "fields": ["engine"]})+t("fusion_impedance", "daily", dict(B, z0=50), {"ok": True, "fields": ["layers"]})+t("fusion_current_capacity", "daily", dict(B, nets={"VBUS": 3}), {"ok": True, "fields": ["nets"]})+t("fusion_fab_check", "daily", dict(B, vendor="jlcpcb"), {"ok": True, "fields": ["measured", "outsideLimits", "fab"]}, "fetches JLCPCB's record from adom/pcb-design-rules") t("fusion_export_bom#parts", "daily", {"outputPath": f"{SCRATCH}/bom-parts.csv"}, {"ok": True, "pred": "bom_has_part_numbers"}, "1.12.0: MPN/LCSC columns from the part attributes (page issue #105)") t("fusion_list_text_commands", "daily", {"filter": "Electron"}, {"ok": True, "fields": ["commands"]}, "TextCommands.List needs the electronics editor context") t("fusion_export_source", "weekly", {"outputPath": f"{SCRATCH}/verbtest.fbrd"}, {"ok": True, "fields": ["savedTo"]}, ".fbrd needs the 2D board view ACTIVE (Document.CopyToDesktop)")@@ -429,7 +434,7 @@ _SECTION_BY_VERB = {     "fusion_verb_times": "bridge-lifecycle", "fusion_live_feed": "bridge-lifecycle", "fusion_account": "auth-tokens", "fusion_claim_seat": "auth-tokens", "fusion_release_seat": "auth-tokens", "fusion_prewarm": "bridge-lifecycle", "fusion_prove_result": "cad-3d-bom",     "fusion_set_driving_badge": "bridge-lifecycle", "fusion_set_window_sizing": "gui-driving", "fusion_cancel_run": "cad-3d-bom",     "fusion_preflight_lbr": "cad-3d-bom", "fusion_library_parts": "cad-3d-bom", "fusion_take_screenshot": "gui-driving",-    "fusion_schematic": "gui-driving", "fusion_show_attributes": "cad-3d-bom", "fusion_make_showcase_project": "cad-3d-bom", "fusion_eagle_import_status": "cad-3d-bom", "fusion_routing_state": "routing", "fusion_route_net": "routing", "fusion_routing_drc": "routing", "fusion_plan_route": "routing", "fusion_move_parts": "routing", "fusion_place_vias": "routing", "fusion_apply_pours": "routing", "fusion_pour_state": "routing", "fusion_board_source": "routing", "fusion_ripup_nets": "routing", "fusion_schematic_run": "routing", "fusion_board_bom": "routing", "fusion_change_pours": "routing", "fusion_board_seed": "routing", "fusion_schematic_plan": "routing", "fusion_remove_pours": "routing", "fusion_layer_stack": "routing", "fusion_netlist": "routing", "fusion_read_sheet_file": "routing", "fusion_bodies": "routing", "fusion_save_document": "aps", "fusion_apply_silkscreen": "routing", "fusion_component_data": "routing", "fusion_component_pins": "routing", "fusion_list_nets": "routing", "fusion_net_connections": "routing", "fusion_list_designators": "routing", "fusion_layers": "routing", "fusion_copper_layers": "routing", "fusion_layers_in_use": "routing", "fusion_read_rules": "routing", "fusion_rules_by_kind": "routing", "fusion_silk_state": "routing", "fusion_net_classes": "routing", "fusion_run_drc": "routing", "fusion_repour": "routing", "fusion_zoom_to": "routing", "fusion_launch": "bridge-lifecycle", "fusion_export_gerber": "exports", "fusion_dismiss_dialogs": "gui-driving", "fusion_board_summary": "routing", "fusion_install": "bridge-lifecycle", "fusion_sch_items": "routing", "fusion_sch_pins": "routing", "fusion_sch_trace_point": "routing", "fusion_sch_page": "routing", "fusion_sch_hierarchy": "routing", "fusion_sch_sheet_pins": "routing", "fusion_sch_connect_pins": "routing", "fusion_sch_fix_connectivity": "routing", "fusion_render_schematic": "routing", "fusion_sch_update_from_library": "routing", "fusion_sch_text": "routing", "fusion_sch_junction": "routing", "fusion_sch_delete_wire": "routing", "fusion_sch_split_wire": "routing", "fusion_sch_bus": "routing", "fusion_sch_edit_label": "routing", "fusion_sch_move_region": "routing", "fusion_sch_bus_entry": "routing", "fusion_sch_connect_to_bus": "routing", "fusion_sch_reset_fields": "routing", "fusion_sch_create": "routing", "fusion_update_board_from_eagle": "routing", "fusion_render_board_3d": "cad-3d-bom", "fusion_generate_body": "cad-3d-bom", "fusion_library_views": "gui-driving", "fusion_edit_parts": "routing", "fusion_align_parts": "routing", "fusion_remove_graphics": "routing", "fusion_remove_parts": "routing", "fusion_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",+    "fusion_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_select_net": "routing", "fusion_set_layer_visibility": "routing", "fusion_board_outline": "routing", "fusion_run_erc": "routing", "fusion_set_track_width": "routing", "fusion_remove_items": "routing", "fusion_set_outline": "routing", "fusion_board_shape": "routing", "fusion_autoroute": "routing", "fusion_check_dialogs": "gui-driving", "fusion_status": "bridge-lifecycle", "fusion_save": "aps", "fusion_run_script": "gui-driving", "fusion_run_command": "gui-driving", "fusion_list_commands": "gui-driving", "fusion_dialog_log": "gui-driving", "fusion_signin_status": "bridge-lifecycle", "fusion_license_status": "bridge-lifecycle", "fusion_open_document": "cad-3d-bom", "fusion_show_3d_chip": "cad-3d-bom",     "fusion_apply_fusion_update": "bridge-lifecycle", "fusion_resolve_seat_dialog": "bridge-lifecycle", "fusion_interactive": "gui-driving", "fusion_narrate": "gui-driving", "fusion_view": "gui-driving", "fusion_electronics_query": "cad-3d-bom",     "fusion_activate_document": "gui-driving", "fusion_library_manager": "gui-driving", "fusion_plan_library_import": "cad-3d-bom",     "fusion_create_library": "cad-3d-bom", "fusion_install_library_bundle": "cad-3d-bom",
tests/test_routing.py+12
@@ -335,4 +335,16 @@ class BoardDxfTests(unittest.TestCase):             r.board_dxf(None, root, b, {}, {'layers': ['Nope.Layer']})  +class CheckInputsTests(unittest.TestCase):+    def test_clearances_in_the_standard_form(self):+        root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+        out = r.check_inputs(None, root, b, {'boardId': 'b', 'revision': 'r'}, {})+        for k in ('board', 'stack', 'pours', 'rules', 'silk'):+            self.assertIn(k, out)+        std = [x for x in out['rules']['rules'] if x['kind'] == 'clearance']+        self.assertTrue(all(isinstance(x['clearanceMm'], float) for x in std))+        self.assertEqual(0.0254 * 10, r._mm_value('10mil'))+        self.assertEqual(0.2, r._mm_value('0.2mm'))++ if __name__=='__main__':unittest.main()
tools/release.sh+1
@@ -370,6 +370,7 @@ for f in handlers/*.py addin/AdomBridge/*.py addin/AdomBridge/commands/*.py \          tools/promote.sh tools/promote.py tools/check-portable.sh tools/check-naming.sh tools/freeze-contract.py \          contracts/describe.json rust/Cargo.toml rust/PORTING.md \          rust/*/Cargo.toml rust/*/src/*.rs rust/bridge/src/*.py rust/bridge/src/*.json rust/bridge/src/*.xml \+         rust/vendor/*/Cargo.toml rust/vendor/*/src/*.rs rust/vendor/*/*.md \          tools/sign_pe.py tools/gen-readme-verbs.py tests/*.py contracts/eda-bridge-standard/*.json CHANGELOG.md \          library_bundle.py README.md docs/*.md docs/img/*.svg docs/img/*.png \          docs/img/*/*.png dashboard/ui/*.html; do