← Commit history

Merge PR #310: kicad-lane: 1.0.67, the standard's checks tier (14 verbs) and board report

Colby Knox ·ad7a3f559e ·14d ago ·parent 8b386a0
38 files changed +3715−28
SKILL.md+7bridge.json+14−1package.json+1rust/Cargo.lock+11−3rust/Cargo.toml+1−1rust/crates/kicad-bridge/Cargo.toml+2rust/crates/kicad-bridge/src/catalog.rs−1rust/crates/kicad-bridge/src/groups.rs+3rust/crates/kicad-bridge/src/kicad_only.txt+1rust/crates/kicad-bridge/src/main.rs+3rust/crates/kicad-bridge/src/taskbar.rs+1−1rust/crates/kicad-bridge/src/verbs_checks.rs+305rust/crates/kicad-bridge/src/verbs_checks_native.rs+486rust/crates/kicad-bridge/src/verbs_composite.rs+12rust/crates/kicad-bridge/src/verbs_pcb_sync.rs+1−1rust/crates/kicad-bridge/src/verbs_report.rs+234rust/crates/kicad-bridge/src/verbs_standard.rs+8−8rust/crates/kicad-core/src/board_checks.rs+922rust/crates/kicad-core/src/board_reads.rs+73−4rust/crates/kicad-core/src/ipc.rs+35−3rust/crates/kicad-core/src/lib.rs+1rust/crates/kicad-core/src/pcb_sync.rs+24−2rust/vendor/adom-eda-checks/Cargo.toml+22rust/vendor/adom-eda-checks/README.md+78rust/vendor/adom-eda-checks/VENDOR.md+13rust/vendor/adom-eda-checks/src/coppermap.rs+446rust/vendor/adom-eda-checks/src/current.rs+142rust/vendor/adom-eda-checks/src/fab.rs+342rust/vendor/adom-eda-checks/src/geom.rs+23rust/vendor/adom-eda-checks/src/impedance.rs+234rust/vendor/adom-eda-checks/src/input.rs+55rust/vendor/adom-eda-checks/src/lib.rs+29rust/vendor/adom-eda-checks/src/main.rs+48skills/kicad-board-checks/SKILL.md+76skills/kicad-bridge-dev/SKILL.md+10skills/kicad-bridge-test/run_verb_tests.py+48−1skills/kicad-schematic-and-board-edits/SKILL.md+1−1tools/build_native_release.py+3−1
SKILL.md+7
@@ -166,6 +166,13 @@ moves with copper, parts or outline; live or from the file. Companions: `kicad_l `kicad_bodies`, `kicad_netlist`. The edit verbs take its revision, and its coordinates with `frame:"standard"`. All of it is the `kicad-board-model` skill in this package. +## Board checks (from 1.0.67)++Return paths, copper balance, impedance widths, current capacity, net lengths and pair skew, 3D heights, part+orientation, free space, symbols against footprints and net classes from names, each from the file or live; thermal+vias and via fences placed after a DRC check; and `kicad_board_report`, one HTML page of every check with a picture.+All of it is the `kicad-board-checks` skill in this package.+ ## Live routing (from 0.9.340)  Needs KiCad 10.0.1+ with Preferences > Plugins > Enable IPC API server turned on.
bridge.json+14−1
@@ -276,7 +276,20 @@     "kicad_list_installed_libraries",     "kicad_replicate_layout",     "kicad_teardrops",-    "kicad_panelize"+    "kicad_panelize",+    "kicad_copper_balance",+    "kicad_current_capacity",+    "kicad_impedance",+    "kicad_return_path",+    "kicad_net_lengths",+    "kicad_check_orientation",+    "kicad_find_space",+    "kicad_height_check",+    "kicad_thermal_vias",+    "kicad_auto_classes",+    "kicad_pin_pad_check",+    "kicad_via_fence",+    "kicad_board_report"   ],   "statusVerb": "kicad_status",   "timeouts": {
package.json+1
@@ -32,6 +32,7 @@     "skills/kicad-place-route-loop/SKILL.md",     "skills/kicad-copper-pours/SKILL.md",     "skills/kicad-board-model/SKILL.md",+    "skills/kicad-board-checks/SKILL.md",     "skills/kicad-schematic-and-board-edits/SKILL.md",     "skills/kicad-tour/SKILL.md",     "skills/kicad-tour/tour_runner.py",
rust/Cargo.lock+11−3
@@ -8,6 +8,13 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]]+name = "adom-eda-checks"+version = "0.1.4"+dependencies = [+ "serde_json",+]+ [[package]] name = "android_system_properties" version = "0.1.6"@@ -310,8 +317,9 @@ dependencies = [  [[package]] name = "kicad-bridge"-version = "1.0.66"+version = "1.0.67" dependencies = [+ "adom-eda-checks",  "kicad-core",  "kicad-platform",  "serde",@@ -321,7 +329,7 @@ dependencies = [  [[package]] name = "kicad-core"-version = "1.0.66"+version = "1.0.67" dependencies = [  "kicad-ipc-rs",  "kicad-platform",@@ -348,7 +356,7 @@ dependencies = [  [[package]] name = "kicad-platform"-version = "1.0.66"+version = "1.0.67" dependencies = [  "serde",  "serde_json",
rust/Cargo.toml+1−1
@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"]  [workspace.package]-version = "1.0.66"+version = "1.0.67" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/Cargo.toml+2
@@ -16,6 +16,8 @@ serde_json = { workspace = true } tiny_http = { workspace = true } kicad-core = { path = "../kicad-core", default-features = false } kicad-platform = { path = "../kicad-platform" }+# The EDA Bridge Standard's shared checks engine, vendored unchanged (vendor/adom-eda-checks/VENDOR.md).+adom-eda-checks = { path = "../../vendor/adom-eda-checks" }  [features] default = ["net", "ipc"]
rust/crates/kicad-bridge/src/catalog.rs−1
@@ -200,7 +200,6 @@ pub static ALIASES: &[(&str, &str)] = &[     ("kicad_sch_sheet_symbol", "kicad_sch_sheet"),     ("kicad_selection", "kicad_get_selection"),     ("kicad_route_pair", "kicad_route_diff_pair"),-    ("kicad_fab_check", "kicad_validate_for_fab"),     ("kicad_find_symbol", "kicad_search_library"),     ("kicad_export_manufacturing", "kicad_export_fab_package"),     ("kicad_add_rule", "kicad_custom_rules"),
rust/crates/kicad-bridge/src/groups.rs+3
@@ -39,6 +39,9 @@ pub static GROUPS: &[Group] = &[     Group { name: "pcb_sync", verbs: || crate::verbs_pcb_sync::VERBS, dispatch: crate::verbs_pcb_sync::dispatch },     Group { name: "demo", verbs: || crate::verbs_demo::VERBS, dispatch: crate::verbs_demo::dispatch },     Group { name: "composite", verbs: || crate::verbs_composite::VERBS, dispatch: crate::verbs_composite::dispatch },+    Group { name: "checks", verbs: || crate::verbs_checks::VERBS, dispatch: crate::verbs_checks::dispatch },+    Group { name: "checks_native", verbs: || crate::verbs_checks_native::VERBS, dispatch: crate::verbs_checks_native::dispatch },+    Group { name: "report", verbs: || crate::verbs_report::VERBS, dispatch: crate::verbs_report::dispatch }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {
rust/crates/kicad-bridge/src/kicad_only.txt+1
@@ -105,5 +105,6 @@ kicad_text_bounds kicad_trace_net kicad_update_footprints_from_library kicad_upgrade+kicad_validate_for_fab kicad_verb_times kicad_zone_state
rust/crates/kicad-bridge/src/main.rs+3
@@ -36,6 +36,9 @@ mod verbs_show; mod verbs_standard; mod std_shapes; mod verbs_composite;+mod verbs_checks;+mod verbs_checks_native;+mod verbs_report; mod verbs_windows;  use std::sync::atomic::{AtomicU64, Ordering};
rust/crates/kicad-bridge/src/taskbar.rs+1−1
@@ -91,7 +91,7 @@ fn badge_b64() -> Option<String> {     g.clone() } -fn base64(bytes: &[u8]) -> String {+pub(crate) fn base64(bytes: &[u8]) -> String {     const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";     let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);     for c in bytes.chunks(3) {
rust/crates/kicad-bridge/src/verbs_checks.rsadded+305
@@ -0,0 +1,305 @@+//! Verb group "checks": the EDA Bridge Standard's checks tier through its shared engine+//! (adom-eda-checks, vendored unchanged in rust/vendor). One read of the board (the live PCB editor+//! when it serves it, else the file) gives the board model, the stack-up and the poured copper; the+//! engine computes; the reply's fix plans are rewritten as this bridge's own calls, ready to run:+//! - return_path's pour patches become `kicad_apply_zone_plan` args (frame standard, y up);+//! - impedance's width and pair rules become `kicad_custom_rules` adds (track_width, diff_pair_gap).+//! - fab_check reads a fab's record from adom/pcb-design-rules (fab:"jlcpcb"), the board's clearance rules+//!   (.kicad_pro) and the silkscreen (the standard's silk_state shape).+//! kicad_validate_for_fab stays this bridge's own: KiCad's DRC against a built-in profile and the fab's .kicad_dru.++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs_standard::{file_note, opts, read, stamp};+use kicad_core::board_model;++const ENGINE: &str = "adom-eda-checks 0.1.2 (EDA Bridge Standard 0.4.3, vendored)";++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_fab_check",+        summary: "Will a fab make this board? Its smallest features (track, clearance rule, via and pad holes and rings, SMD pads, edge clearance, hole spacing, silkscreen text and lines, layers, size) measured and compared with a fab's limits from adom/pcb-design-rules, each finding naming the fab's page and the date it was read; or with limits you give.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 90,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"fab\"?: \"jlcpcb\" | {a pcb-design-rules record}, \"process\"?: \"standard\", \"limits\"?: {\"minTrack\": 0.1, \"minClearance\": 0.1, ...} (mm, override the fab's), \"copperOz\"?: 1}",+        example: "kicad_fab_check {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"fab\":\"jlcpcb\"}",+        hint: "fits is true when nothing is outside the limits; outsideLimits {limit, limitMm, boardMm, at} names each feature under the fab's limit and where; measured has every measurement; fab names the record's page and the date it was read. notChecked lists limits with nothing to measure. Without fab or limits it only measures. kicad_validate_for_fab is KiCad's own DRC against a built-in profile and the fab's .kicad_dru.",+        related: &["kicad_validate_for_fab", "kicad_set_design_rules", "kicad_layer_stack", "kicad_silk_state"],+        pitfalls: &["The clearance rule is the smallest of the project's net classes and Board Setup's minimum clearance; custom rules (.kicad_dru) are not read here.", "A fab record is for one layer count: fab.mismatch says when the board's differs."],+    },+    Verb {+        name: "kicad_copper_balance",+        summary: "Copper per layer as a share of the board, poured copper included, and how evenly mirrored layers match (F.Cu against B.Cu, In1 against the last inner layer): a pair that differs by more than 20 points can bow or twist through reflow.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"resolutionMm\"?: 0.1}",+        example: "kicad_copper_balance {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "layers[] give copperPct per layer; pairs[] compare mirrored layers (ok within 20 points). Pours count as KiCad poured them (pour_state): refill before trusting a file read. poursWithoutRegions are zones with no fill. The standard's copper_balance, computed by the shared engine.",+        related: &["kicad_pour_state", "kicad_layer_stack", "kicad_zone_state", "kicad_apply_zone_plan"],+        pitfalls: &["The board is rasterised at resolutionMm (0.1 mm default): finer is slower and changes the percentages by a fraction of a point."],+    },+    Verb {+        name: "kicad_return_path",+        summary: "Signal tracks that run over a gap in the reference copper (pours and planes) on their neighbouring layers, with how far and where the gap starts, and a ready kicad_apply_zone_plan call that pours the reference net under each gap.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"nets\"?: [\"USB_D+\"], \"referenceNets\"?: [\"GND\"], \"resolutionMm\"?: 0.1, \"fixMarginMm\"?: 1.0}",+        example: "kicad_return_path {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"nets\":[\"USB_D+\",\"USB_D-\"]}",+        hint: "nets[] rows: net, layer, unreferencedMm, firstGapAt (mm, y up), gapRuns. referenceNets default to the nets that own pours or planes. fix is a plan, nothing changed: run fix.verb (kicad_apply_zone_plan) with fix.args, refill (kicad_zone_state {refill:true}), then check again. fix.floating patches touch no copper of their net yet: stitch them with kicad_add_via.",+        related: &["kicad_apply_zone_plan", "kicad_zone_state", "kicad_pour_state", "kicad_add_via"],+        pitfalls: &["Pours count only as filled: an unfilled zone is no reference copper.", "A file read does not see unsaved edits; fix.args.expectedRevision is the revision this read saw."],+    },+    Verb {+        name: "kicad_impedance",+        summary: "The track width for a target impedance (and the pair width and gap for a differential target) on each copper layer, from the board's own stack-up; optionally the width or pair rules for kicad_custom_rules.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"z0\"?: 50, \"diff\"?: 90, \"gapMm\"?: 0.15, \"layers\"?: [\"F.Cu\"], \"apply\"?: {\"scope\": \"A.NetClass == 'USB'\", \"name\"?: \"USB\"}}",+        example: "kicad_impedance {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"z0\":50,\"diff\":90}",+        hint: "layers[] rows: model (microstrip or stripline), width for z0, diffWidth and diffGap for diff (gapMm fixes the gap). apply:{scope} adds a plan: fix the rules with kicad_custom_rules using apply.args (one rule per layer at +/-10%), then kicad_run_drc. A board saved without a stack-up uses KiCad's defaults: set Board Setup > Physical Stackup first.",+        related: &["kicad_layer_stack", "kicad_custom_rules", "kicad_diff_pairs", "kicad_netclasses"],+        pitfalls: &["Uncoated copper: solder mask lowers an outer layer by 1 to 3 ohm.", "Send the fab the target; its stack-up tunes the final width."],+    },+    Verb {+        name: "kicad_current_capacity",+        summary: "Whether each named net's copper carries its current: per layer, the narrowest track against the width IPC-2221 needs for the temperature rise, and how many vias a layer change needs.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"nets\": {\"VIN\": 3, \"GND\": 3}, \"riseC\"?: 10, \"platingUm\"?: 25}",+        example: "kicad_current_capacity {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"nets\":{\"VIN\":3}}",+        hint: "nets[] rows: per layer the narrowest width and neededMm, shortOn (layers too thin), poursOn (layers where a pour of the net may carry it), vias {count, smallestDrillMm, neededPerLayerChange}. Widen with kicad_set_track_width or a width rule (kicad_custom_rules); copper thickness comes from the stack-up.",+        related: &["kicad_set_track_width", "kicad_custom_rules", "kicad_layer_stack", "kicad_netclasses"],+        pitfalls: &["Tracks only: a pour of the net is listed in poursOn but not measured.", "IPC-2221's chart is conservative for short runs and wide pours."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    let check = match command {+        "kicad_fab_check" => "fab_check",+        "kicad_copper_balance" => "copper_balance",+        "kicad_return_path" => "return_path",+        "kicad_impedance" => "impedance",+        "kicad_current_capacity" => "current_capacity",+        _ => return None,+    };+    Some(run(state, check, args))+}++fn run(state: &mut State, check: &str, args: &Value) -> Value {+    // the engine reads the standard model (y up); the frame of the reply is the standard's+    if args.get("frame").and_then(Value::as_str).map_or(false, |f| f != "standard") {+        return fail("bad_args", "the checks answer in the standard's frame only (mm, y up)", "Leave frame out.");+    }+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let mut args = args.clone();+    if check == "fab_check" {+        if let Some(vendor) = args.get("fab").and_then(Value::as_str).map(str::to_string) {+            let layers = board_model::stackup(&r.text).ok().and_then(|s| s["copperLayers"].as_u64()).unwrap_or(2);+            match fab_record(&vendor, layers, args.get("process").and_then(Value::as_str)) {+                Ok(rec) => args["fab"] = rec,+                Err(e) => return fail("no_fab_record", e, "Name a vendor adom/pcb-design-rules has (jlcpcb, pcbway, osh-park, eurocircuits, aisler ...), pass its record as fab, or give limits:{...} in mm."),+            }+        }+    }+    let args = &args;+    let mut v = match compute(check, &r.text, &r.file, &o, args) {+        Ok(v) => v,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    if v["success"] == json!(true) {+        stamp(&mut v, &r);+        let hint = v["_hint"].as_str().unwrap_or("").to_string();+        v["_hint"] = json!(format!("{}{hint}{}", file_note(&r), KICAD_NAMES));+    }+    v+}++/// The engine names the standard's verbs; say which KiCad verbs they are.+const KICAD_NAMES: &str = " In KiCad: apply_pours is kicad_apply_zone_plan, add_rule is kicad_custom_rules, place_vias is kicad_add_via, set_track_width is kicad_set_track_width.";++/// The board model and its companions from one text, the engine's answer, and its plans as this+/// bridge's calls.+fn compute(check: &str, text: &str, file: &str, o: &board_model::Opts, args: &Value) -> Result<Value, String> {+    let board = board_model::build(text, Some(file), o)?;+    let stack = board_model::stackup(text)?;+    let pours = board_model::pours(text, o, true)?;+    let mut engine_args = args.clone();+    if let Some(m) = engine_args.as_object_mut() {+        for k in ["filePath", "source", "socketPath", "frame"] {+            m.remove(k);+        }+    }+    let mut bundle = json!({"board": board, "stack": stack, "pours": pours, "args": engine_args});+    if check == "fab_check" {+        bundle["silk"] = kicad_core::board_reads::silk_state(text, o.y_up)?;+        bundle["rules"] = json!(clearance_rules(file));+        if args["fab"].is_object() {+            bundle["fab"] = args["fab"].clone();+        }+        if let Some(m) = bundle["args"].as_object_mut() {+            m.remove("fab");+        }+    }+    let mut v = adom_eda_checks::run(check, &bundle);+    if v["success"] != json!(true) {+        return Ok(v);+    }+    v["revision"] = board["revision"].clone();+    v["frame"] = json!("standard");+    v["engine"] = json!(ENGINE);+    if stack["fromDefaults"] == json!(true) && matches!(check, "impedance" | "current_capacity") {+        v["assumed"] = json!("this board has no stack-up of its own: KiCad's defaults (35 um copper, FR4 Dk 4.5) were used. Set Board Setup > Physical Stackup.");+    }+    if check == "return_path" && v["fix"].is_object() {+        v["fix"] = zone_plan(&v["fix"], file, &board["revision"]);+    }+    if check == "impedance" && v["apply"].is_object() {+        v["apply"] = rules_plan(&v["apply"], file);+    }+    Ok(v)+}++/// A fab's record from adom/pcb-design-rules (rules/records/<vendor>.json): the one for the board's+/// copper layer count and the asked process (default the first that fits), with the file's vendor.+fn fab_record(vendor: &str, layers: u64, process: Option<&str>) -> Result<Value, String> {+    let slug: String = vendor.trim().to_lowercase().chars().map(|c| if c == ' ' || c == '_' { '-' } else { c }).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 bytes = kicad_core::design_rules::fetch(&url).map_err(|e| format!("no record for {vendor} on adom/pcb-design-rules ({e})"))?;+    let file: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{url} is not JSON: {e}"))?;+    let recs = file["records"].as_array().cloned().unwrap_or_default();+    let fits = |r: &&Value| process.map_or(true, |p| r["process"].as_str() == Some(p));+    let pick = recs.iter().filter(fits).find(|r| r["layerCount"].as_u64() == Some(layers)).or_else(|| recs.iter().find(fits));+    match pick {+        Some(r) => {+            let mut r = r.clone();+            r["vendor"] = file["vendor"].clone();+            Ok(r)+        }+        None => Err(format!("{vendor} has no record{}", process.map(|p| format!(" for the {p} process")).unwrap_or_default())),+    }+}++/// The board's copper clearance rules as the engine reads them: each net class's clearance and Board+/// Setup's minimum, from the .kicad_pro beside the board.+fn clearance_rules(file: &str) -> Vec<Value> {+    let pro = std::path::PathBuf::from(file).with_extension("kicad_pro");+    let doc: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    let mut rules: Vec<Value> = doc.pointer("/net_settings/classes").and_then(Value::as_array).into_iter().flatten()+        .filter_map(|c| Some(json!({"kind": "clearance", "name": format!("net class {}", c["name"].as_str().unwrap_or("?")), "clearanceMm": c["clearance"].as_f64()?})))+        .collect();+    if let Some(m) = doc.pointer("/board/design_settings/rules/min_clearance").and_then(Value::as_f64) {+        rules.push(json!({"kind": "clearance", "name": "Board Setup minimum clearance", "clearanceMm": m}));+    }+    rules+}++/// return_path's pour patches as one kicad_apply_zone_plan call: new zones only (ownedItemIds+/// empty, so every zone already on the board stays).+fn zone_plan(fix: &Value, file: &str, revision: &Value) -> Value {+    let zones: Vec<Value> = fix["args"]["pours"].as_array().into_iter().flatten().map(|p| {+        json!({"name": p["name"], "net": p["net"], "layers": [p["layer"]], "polygon": p["points"]})+    }).collect();+    let mut out = fix.clone();+    out["verb"] = json!("kicad_apply_zone_plan");+    out["args"] = json!({"filePath": file, "expectedRevision": revision, "frame": "standard", "ownedItemIds": [], "zones": zones});+    out["note"] = json!(format!("{} Then kicad_zone_state {{refill:true}} and kicad_return_path again.", fix["note"].as_str().unwrap_or("")));+    out+}++/// impedance's rules as kicad_custom_rules adds: a width rule is track_width, a pair rule is+/// track_width plus diff_pair_gap, each on its layer and scoped by the caller's condition.+fn rules_plan(apply: &Value, file: &str) -> Value {+    let adds: Vec<Value> = apply["rules"].as_array().into_iter().flatten().map(|r| {+        let constraints = if r["kind"] == "diffpair" {+            json!([+                {"type": "track_width", "min": r["minWidth"], "opt": r["width"], "max": r["maxWidth"]},+                {"type": "diff_pair_gap", "min": r["minGap"], "opt": r["gap"], "max": r["maxGap"]},+            ])+        } else {+            json!([{"type": "track_width", "min": r["min"], "opt": r["preferred"], "max": r["max"]}])+        };+        json!({"name": r["name"], "layer": r["layer"], "condition": r["scope"], "constraints": constraints})+    }).collect();+    json!({"verb": "kicad_custom_rules", "args": {"filePath": file, "add": adds}, "rules": apply["rules"],+        "note": "A plan only: nothing changed. Run kicad_custom_rules with args (the rules go into the project's .kicad_dru), then kicad_run_drc."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(rel: &str) -> (String, String) {+        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel);+        (std::fs::read_to_string(&p).unwrap(), p.to_string_lossy().into_owned())+    }++    const PLANES: &str = "demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb";+    const Y_UP: board_model::Opts = board_model::Opts { y_up: true };++    #[test]+    fn copper_balance_reads_the_kicad_model() {+        let (text, file) = board(PLANES);+        let v = compute("copper_balance", &text, &file, &Y_UP, &json!({})).unwrap();+        assert_eq!(v["success"], true, "{v}");+        let layers = v["layers"].as_array().unwrap();+        assert!(layers.len() >= 2, "{v}");+        assert!(v["pairs"].as_array().map_or(false, |p| !p.is_empty()), "{v}");+        assert!(v["boardMm2"].as_f64().unwrap() > 100.0, "{v}");+    }++    #[test]+    fn impedance_answers_widths_and_a_custom_rules_plan() {+        let (text, file) = board(PLANES);+        let v = compute("impedance", &text, &file, &Y_UP, &json!({"z0": 50, "diff": 90, "apply": {"scope": "A.NetClass == 'USB'"}})).unwrap();+        assert_eq!(v["success"], true, "{v}");+        assert!(v["layers"][0]["width"].as_f64().unwrap() > 0.05, "{v}");+        assert_eq!(v["apply"]["verb"], "kicad_custom_rules");+        let add = &v["apply"]["args"]["add"][0];+        assert_eq!(add["layer"], "F.Cu");+        assert_eq!(add["constraints"][1]["type"], "diff_pair_gap");+        assert_eq!(add["condition"], "A.NetClass == 'USB'");+    }++    #[test]+    fn current_capacity_names_real_nets_and_refuses_others() {+        let (text, file) = board(PLANES);+        let model = board_model::build(&text, Some(&file), &Y_UP).unwrap();+        let net = model["nets"].as_array().unwrap().iter().filter_map(|n| n.as_str().or(n["name"].as_str())).find(|n| !n.is_empty()).unwrap().to_string();+        let v = compute("current_capacity", &text, &file, &Y_UP, &json!({"nets": {net.clone(): 1}})).unwrap();+        assert_eq!(v["success"], true, "{v}");+        assert_eq!(v["nets"][0]["net"].as_str().map(str::to_ascii_lowercase), Some(net.to_ascii_lowercase()), "{v}");+        let bad = compute("current_capacity", &text, &file, &Y_UP, &json!({"nets": {"NO_SUCH_NET_X": 1}})).unwrap();+        assert_eq!(bad["errorCode"], "no_such_net");+    }++    #[test]+    fn return_path_plans_kicad_zones() {+        let (text, file) = board(PLANES);+        let v = compute("return_path", &text, &file, &Y_UP, &json!({})).unwrap();+        assert_eq!(v["success"], true, "{v}");+        assert!(!v["referenceNets"].as_array().unwrap().is_empty(), "{v}");+        if v["fix"].is_object() {+            assert_eq!(v["fix"]["verb"], "kicad_apply_zone_plan");+            let z = &v["fix"]["args"]["zones"][0];+            assert_eq!(z["polygon"].as_array().unwrap().len(), 4);+            assert_eq!(v["fix"]["args"]["expectedRevision"], v["revision"]);+        }+    }+}++/// A look at the engine's replies on any board while developing:+/// `CHECK_BOARD=/path/x.kicad_pcb CHECK_NETS='{"nets":{"GND":3}}' cargo test -p kicad-bridge show::print_replies -- --ignored --nocapture`+#[cfg(test)]+mod show {+    #[test]+    #[ignore]+    fn print_replies() {+        let p = std::path::PathBuf::from(std::env::var("CHECK_BOARD").unwrap());+        let text = std::fs::read_to_string(&p).unwrap();+        let o = kicad_core::board_model::Opts { y_up: true };+        for (c, a) in [("copper_balance", serde_json::json!({})), ("return_path", serde_json::json!({})), ("impedance", serde_json::json!({"z0": 50, "diff": 90})), ("current_capacity", serde_json::from_str(&std::env::var("CHECK_NETS").unwrap_or("{\"nets\":{\"GND\":3}}".into())).unwrap())] {+            let mut v = super::compute(c, &text, &p.to_string_lossy(), &o, &a).unwrap();+            if let Some(n) = v["nets"].as_array_mut() { n.truncate(3); }+            if let Some(z) = v["fix"]["args"]["zones"].as_array_mut() { z.truncate(2); }+            println!("== {c}\n{}", serde_json::to_string(&v).unwrap().chars().take(2500).collect::<String>());+        }+    }+}
rust/crates/kicad-bridge/src/verbs_checks_native.rsadded+486
@@ -0,0 +1,486 @@+//! Verb group "checks_native": the EDA Bridge Standard's checks that this bridge computes itself+//! (kicad_core::board_checks) from one read of the board, the live PCB editor when it serves it,+//! else the file:+//! - reads: kicad_net_lengths, kicad_check_orientation, kicad_find_space, kicad_height_check+//!   (on kicad_bodies) and kicad_pin_pad_check (the schematic's netlist against the board);+//! - with an edit: kicad_thermal_vias (vias in a part's exposed pad: live as one Undo step after a+//!   DRC preflight, else into the closed file) and kicad_auto_classes {apply} (net classes through+//!   kicad_netclasses).+//! Coordinates are the standard's (mm, y up) unless frame:"kicad".++use std::path::PathBuf;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs::call;+use crate::verbs_standard::{file_note, opts, read, stamp, Read};+use kicad_core::{board_checks, board_model, pcb};++pub static VERBS: &[Verb] = &[+    Verb {+        name: "kicad_net_lengths",+        summary: "Routed length of every net (or the ones named) by layer, with its vias; the skew inside each differential pair; and the spread of matched-length groups against a tolerance.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"nets\"?: [\"USB_D+\"], \"pairs\"?: [{\"name\"?, \"pos\", \"neg\"}] (default the pairs KiCad recognises by name), \"groups\"?: [{\"nets\": [..], \"toleranceMm\"?: 0.5}]}",+        example: "kicad_net_lengths {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"groups\":[{\"nets\":[\"D0\",\"D1\",\"D2\",\"D3\"],\"toleranceMm\":0.5}]}",+        hint: "nets[] {net, lengthMm, byLayer, vias, segments}, longest first; pairs[] {posMm, negMm, skewMm}; groups[] {spreadMm, withinTolerance, nets[{shortByMm}]}. Lengthen a short net in KiCad with the interactive length tuner (Route > Tune Length) or reroute it with kicad_route_net.",+        related: &["kicad_diff_pairs", "kicad_route_net", "kicad_route_pair", "kicad_routing_state"],+        pitfalls: &["Copper only: a via's barrel is counted, not measured (KiCad's Net Inspector can add it from the stack-up).", "Branches add up: a net with a stub reports its whole copper, not one pad-to-pad path."],+    },+    Verb {+        name: "kicad_check_orientation",+        summary: "Advisory: two-pad parts whose rotation could be better. Each part is scored at 0, 90, 180 and 270 degrees by airline length (a net's spanning tree, or for a plane-like net the distance to its nearest pad); parts whose best turn saves minImprovementMm are listed. Nothing is moved.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"parts\"?: [\"R1\", \"C3\"], \"minImprovementMm\"?: 0.6, \"planeNetPads\"?: 12}",+        example: "kicad_check_orientation {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "suggestions[] {ref, rotation, suggested, airlineNowMm, airlineThenMm, savesMm}. Turn a part with kicad_move_footprint {ref, x, y, rotation: suggested} at its own x and y.",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_find_space"],+        pitfalls: &["Routed copper is not considered: on a routed board these are hints for the next layout."],+    },+    Verb {+        name: "kicad_find_space",+        summary: "Free spots on the board for a part near another: clear of every other part's courtyard on that side, of that side's tracks and vias, and inside the outline, nearest first. check:true lists parts that already overlap or leave the outline.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"part\"?: \"C12\" | \"sizeMm\"?: [w, h], \"near\"?: \"U3\" | \"at\"?: {\"x\", \"y\"}, \"side\"?: \"top|bottom\", \"radiusMm\"?: 10, \"count\"?: 5, \"clearanceMm\"?: 0.25, \"edgeMm\"?: 0.3, \"copper\"?: true, \"check\"?: false, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_find_space {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"part\":\"C12\",\"near\":\"U3\"}",+        hint: "spots[] {x, y, rotation, distanceMm, extent} are where the part's anchor goes (extent is its courtyard there): kicad_move_footprint {ref, x, y, frame:\"standard\"} (the spot's frame). The part keeps its rotation; turn it first with kicad_check_orientation's suggestion if you want.",+        related: &["kicad_move_footprint", "kicad_placement_state", "kicad_check_orientation"],+        pitfalls: &["Courtyards decide: a footprint without one is its pads plus 0.25 mm.", "Through-hole pins of parts on the other side are not seen; kicad_move_footprint's own checks still run."],+    },+    Verb {+        name: "kicad_height_check",+        summary: "Will it fit the enclosure? Each part's height from its 3D model (top and bottom), the parts over a height limit board-wide or under a lid feature in a region, and bodies that collide or come closer than a gap.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"maxTopMm\"?: 12, \"maxBottomMm\"?: 3, \"regions\"?: [{\"rect\": [x1, y1, x2, y2], \"maxMm\", \"side\"?: \"top|bottom\", \"name\"?}], \"minGapMm\"?: 0, \"limit\"?: 25, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_height_check {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"maxTopMm\":10}",+        hint: "heights[] tallest first, tallest {top, bottom}; overLimit, regionHits, collisions and tooClose (closer than minGapMm) are what fails (ok false). noBody parts have no readable 3D model, so their height is unknown: give them one before trusting ok. Bodies are boxes from kicad_bodies, in its frame.",+        related: &["kicad_bodies", "kicad_show_3d_board", "kicad_find_space"],+        pitfalls: &["A body is its model's bounding box: two round parts can be reported as colliding at their corners.", "Heights come from the 3D model files, not the footprint's height field."],+    },+    Verb {+        name: "kicad_thermal_vias",+        summary: "A thermal via grid in a part's exposed pad (the ground or thermal pad under a QFN, DFN, PowerPAD or regulator): centred, inset from the pad's edge, on the pad's net, sized by the project's Default net class. Live as one Undo step after a DRC preflight, else written into the closed file.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"ref\": \"U3\", \"pad\"?: \"17\" (default the part's largest pad), \"size\"?: 0.6, \"drill\"?: 0.3, \"pitchMm\"?: 1.1, \"insetMm\"?: 0.1, \"dryRun\"?: false, \"expectedRevision\"?, \"save\"?: false, \"backup\"?: true}",+        example: "kicad_thermal_vias {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"ref\":\"U3\",\"dryRun\":true}",+        hint: "planned {pad, net, padMm, vias, pitchMm, via, points} (points in the reply's frame). dryRun plans only. Pour the pad's net on the far side (kicad_add_zone) so the heat has somewhere to go, and ask the fab for plugged or tented vias if solder must not wick through.",+        related: &["kicad_add_via", "kicad_add_zone", "kicad_component_pins", "kicad_run_drc"],+        pitfalls: &["Live: refused (drc_rejected) when the vias add DRC errors, e.g. a hole too close to another; nothing changes.", "A point that lands on an existing via is skipped (skippedForExistingVias).", "Until the far side has copper of the pad's net, KiCad's DRC warns that each via is dangling (via_dangling)."],+    },+    Verb {+        name: "kicad_via_fence",+        summary: "A via fence for EMC: a row of ground vias on both sides of RF or fast nets' tracks, and/or round the board edge, at a pitch from the frequency (lambda/20 in FR-4) or given; each via clear of other copper and pads, other vias, via keepouts and the edge. Live as one Undo step after a DRC preflight, else written into the closed file.",+        mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 180,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"viaNet\": \"GND\", \"nets\"?: [\"ANT_RF\"], \"edge\"?: false, \"freqGHz\"?: 2.4, \"pitchMm\"?: 2 (a cap when freqGHz is given), \"offsetMm\"?, \"edgeInsetMm\"?, \"size\"?: 0.6, \"drill\"?: 0.3, \"clearance\"?: 0.2, \"dryRun\"?: false, \"expectedRevision\"?, \"save\"?: false, \"backup\"?: true}",+        example: "kicad_via_fence {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"viaNet\":\"GND\",\"nets\":[\"ANT_RF\"],\"freqGHz\":2.4,\"dryRun\":true}",+        hint: "planned {vias, alongTracks, alongEdge, candidates, skipped, pitchMm, offsetMm, points}. Vias go through every layer: the reference pours should be on viaNet. Refill (kicad_zone_state {refill:true}) and run kicad_run_drc after; kicad_return_path shows where a net still runs over a gap.",+        related: &["kicad_return_path", "kicad_zone_state", "kicad_add_via", "kicad_run_drc"],+        pitfalls: &["Spots too close to other copper, pads, vias or the edge are skipped, not forced (skipped says how many and why).", "Size and drill default to the project's Default net class via."],+    },+    Verb {+        name: "kicad_auto_classes",+        summary: "Net classes from the nets' own names: GND, Power (VCC, VDD, 3V3, VBAT...), Clock and HighSpeed (USB, Ethernet, HDMI, PCIe, DDR, MIPI, LVDS, SATA), and the differential pairs their names spell; a proposal first, written into the project's net classes with apply.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 60,+        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"apply\"?: false, \"powerWidthMm\"?: 0.5, \"dryRun\"?: false}",+        example: "kicad_auto_classes {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "proposal {classes {GND, Power, Clock, HighSpeed}, pairs, unclassified}. apply writes each non-empty class (Power with powerWidthMm as its track width) and assigns its nets by exact name, through kicad_netclasses; KiCad pairs nets by name already (kicad_diff_pairs sizes them). Then kicad_impedance {apply} for widths and kicad_current_capacity for power.",+        related: &["kicad_netclasses", "kicad_diff_pairs", "kicad_impedance", "kicad_current_capacity"],+        pitfalls: &["Names only: a net called SIG7 that carries a clock is not found.", "A net already matched by another class's pattern keeps the first match in KiCad: kicad_netclasses lists what each net resolves to."],+    },+    Verb {+        name: "kicad_pin_pad_check",+        summary: "Do the footprints fit their symbols? Part by part, the symbol's pin numbers (every pin, from the schematic's netlist) against the footprint's pad numbers on the board: pins with no pad, pads with no pin, and parts only on the sheets or only on the board.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 180,+        input: "{\"filePath\" | \"pcbPath\": \"C:/.../x.kicad_pcb\" | \"projectPath\": \"C:/.../x.kicad_pro\", \"schematicPath\"?: \"C:/.../root.kicad_sch\", \"netlistPath\"?: \"KiCad XML netlist\"}",+        example: "kicad_pin_pad_check {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+        hint: "ok is true when every pin has a pad. A pin with no pad is a net that can never reach copper: change the footprint (kicad_swap_footprint) or the symbol's pin numbers, then update the board (kicad_update_pcb_from_schematic).",+        related: &["kicad_update_pcb_from_schematic", "kicad_swap_footprint", "kicad_component_pins", "kicad_sch_pins"],+        pitfalls: &["Mounting, shield, exposed and thermal pads (MH*, MP*, SH*, EP*, PAD*, TAB*, unnamed or 0) are expected to have no pin.", "Reads the saved schematic (kicad-cli), and the board live when the editor serves it."],+    },+];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    Some(match command {+        "kicad_net_lengths" => model_check(state, args, Check::Lengths),+        "kicad_check_orientation" => model_check(state, args, Check::Orientation),+        "kicad_find_space" => model_check(state, args, Check::Space),+        "kicad_height_check" => height_check(state, args),+        "kicad_thermal_vias" => via_job(state, args, ViaJob::Thermal),+        "kicad_via_fence" => via_job(state, args, ViaJob::Fence),+        "kicad_auto_classes" => auto_classes(state, args),+        "kicad_pin_pad_check" => pin_pad_check(state, args),+        _ => return None,+    })+}++#[derive(Clone, Copy)]+enum Check {+    Lengths,+    Orientation,+    Space,+}++fn board_nets(model: &Value) -> Vec<String> {+    model["nets"].as_array().into_iter().flatten().filter_map(|n| n.as_str().or(n["name"].as_str()).map(String::from)).collect()+}++fn finish(mut v: Value, r: &Read, model: &Value, hint: &str) -> Value {+    if v["success"] != json!(true) {+        return v;+    }+    stamp(&mut v, r);+    v["revision"] = model["revision"].clone();+    v["frame"] = model["frame"].clone();+    v["_hint"] = json!(format!("{}{hint}", file_note(r)));+    v+}++fn model_check(state: &mut State, args: &Value, which: Check) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let model = match board_model::build(&r.text, Some(&r.file), &o) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let v = match which {+        Check::Lengths => board_checks::net_lengths(&model, args, &crate::verbs_composite::board_pairs(&board_nets(&model))),+        Check::Orientation => board_checks::check_orientation(&model, args),+        Check::Space => {+            let board = match pcb::parse_pcb_text(&r.text, &r.file) {+                Ok(b) => b,+                Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+            };+            // the outline in KiCad's frame (the model's is y up when o.y_up)+            let outline: Vec<(f64, f64)> = model["outline"].as_array().into_iter().flatten()+                .map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0) * if o.y_up { -1.0 } else { 1.0 })).collect();+            board_checks::find_space(&board, &outline, args, o.y_up)+        }+    };+    let hint = match which {+        Check::Lengths => "Lengths in mm of routed copper. A group out of tolerance names each net's shortByMm: lengthen those (KiCad: Route > Tune Length).",+        Check::Orientation => "Advisory only, nothing moved: turn a part with kicad_move_footprint {ref, x, y, rotation}.",+        Check::Space => "Move the part to a spot with kicad_move_footprint {ref, x, y} in the reply's frame (frame:\"standard\" for y up); its own courtyard and outline checks still run.",+    };+    finish(v, &r, &model, hint)+}++fn height_check(state: &mut State, args: &Value) -> Value {+    let mut a = args.clone();+    for k in ["maxTopMm", "maxBottomMm", "regions", "minGapMm", "limit"] {+        if let Some(m) = a.as_object_mut() {+            m.remove(k);+        }+    }+    let bodies = call(state, "kicad_bodies", &a);+    if bodies["success"] != json!(true) {+        return bodies;+    }+    let mut v = board_checks::height_check(&bodies, args);+    for k in ["source", "file", "kicadVersion", "live"] {+        if !bodies[k].is_null() {+            v[k] = bodies[k].clone();+        }+    }+    v["frame"] = json!(if args.get("frame").and_then(Value::as_str) == Some("kicad") { "kicad" } else { "standard" });+    let missing = v["noBody"].as_array().map_or(0, |a| a.len());+    v["_hint"] = json!(format!("{}heights in mm above the side each part sits on; regions use the reply's frame. {}",+        if v["ok"] == json!(true) { "Nothing over a limit, nothing colliding. " } else { "ok is false: see overLimit, regionHits, collisions and tooClose. " },+        if missing > 0 { format!("{missing} part(s) in noBody have no readable 3D model, so their height is unknown.") } else { "Every part has a body.".into() }));+    v+}++/// The project's Default net class via (diameter, drill), else the bridge's defaults.+fn default_via(board_file: &str) -> (f64, f64) {+    let pro = PathBuf::from(board_file).with_extension("kicad_pro");+    let doc: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    let class = doc.pointer("/net_settings/classes").and_then(Value::as_array).and_then(|a| a.iter().find(|c| c["name"] == "Default").cloned()).unwrap_or(Value::Null);+    (class["via_diameter"].as_f64().unwrap_or(pcb::DEFAULT_VIA_SIZE), class["via_drill"].as_f64().unwrap_or(pcb::DEFAULT_VIA_DRILL))+}++#[derive(Clone, Copy, PartialEq)]+enum ViaJob {+    Thermal,+    Fence,+}++/// kicad_thermal_vias and kicad_via_fence: plan in KiCad's frame (what the edit takes), report in+/// the caller's, then place the vias live (one Undo step after a DRC preflight) or in the file.+fn via_job(state: &mut State, args: &Value, job: ViaJob) -> Value {+    let o = match opts(args) { Ok(o) => o, Err(e) => return e };+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let kicad = board_model::Opts { y_up: false };+    let model = match board_model::build(&r.text, Some(&r.file), &kicad) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let (dsize, ddrill) = default_via(&r.file);+    let size = args.get("size").and_then(Value::as_f64).unwrap_or(dsize);+    let drill = args.get("drill").and_then(Value::as_f64).unwrap_or(ddrill);+    if drill <= 0.0 || drill >= size {+        return fail("bad_via_geometry", format!("drill {drill} is not smaller than size {size}"), "A via's drill must be smaller than its pad.");+    }+    let planned = match job {+        ViaJob::Thermal => board_checks::thermal_vias(&model, args, size, drill),+        ViaJob::Fence => {+            let mut a = args.clone();+            a["size"] = json!(size);+            a["drill"] = json!(drill);+            board_checks::via_fence(&model, &a)+        }+    };+    let plan = match planned {+        Ok(p) => p,+        Err((code, msg)) => return fail(&code, msg, if job == ViaJob::Thermal { "kicad_component_pins lists the part's pads with their sizes and nets." } else { "kicad_list_nets names the nets exactly; kicad_net_lengths shows which are routed." }),+    };+    let kicad_points: Vec<Value> = plan["points"].as_array().cloned().unwrap_or_default();+    let mut shown = plan.clone();+    if o.y_up {+        shown["points"] = json!(kicad_points.iter().map(|p| json!([p[0], -p[1].as_f64().unwrap_or(0.0) + 0.0])).collect::<Vec<_>>());+    }+    let standard_rev = board_model::revision_of_text(&r.text).unwrap_or_default();+    let mut out = json!({"success": true, "planned": shown, "frame": if o.y_up { "standard" } else { "kicad" }, "revision": standard_rev});+    if plan["vias"] == json!(0) {+        out["applied"] = json!(0);+        out["failed"] = json!(0);+        out["_hint"] = json!(match job {+            ViaJob::Thermal => "No via fits that is not already there: the pad holds its vias (skippedForExistingVias) or is too small for the pitch.",+            ViaJob::Fence => "No spot is clear: skipped says why (other copper and pads, other vias, the edge). A smaller via or clearance, or offsetMm, may find room.",+        });+        return out;+    }+    if args.get("dryRun") == Some(&json!(true)) {+        out["dryRun"] = json!(true);+        out["applied"] = json!(0);+        out["failed"] = json!(0);+        out["_hint"] = json!(format!("{}A plan only: run again without dryRun to place {} via(s).", file_note(&r), plan["vias"]));+        return out;+    }+    let vias: Vec<Value> = kicad_points.iter().map(|p| json!({"at": p, "size": size, "drill": drill})).collect();+    let (net, message) = match job {+        ViaJob::Thermal => (plan["net"].as_str().unwrap_or("").to_string(), format!("Adom thermal vias {}.{}", plan["part"].as_str().unwrap_or(""), plan["pad"].as_str().unwrap_or(""))),+        ViaJob::Fence => (plan["viaNet"].as_str().unwrap_or("").to_string(), "Adom via fence".to_string()),+    };+    let applied = if r.live { place_live(state, args, &standard_rev, &net, &vias, &message) } else { place_in_file(state, args, &r, &standard_rev, &net, &vias) };+    match applied {+        Ok(mut v) => {+            v["planned"] = out["planned"].clone();+            v["applied"] = plan["vias"].clone();+            v["failed"] = json!(0);+            v["frame"] = out["frame"].clone();+            stamp(&mut v, &r);+            v["_hint"] = json!(match job {+                ViaJob::Thermal => format!("{} thermal via(s) in {}.{} on {net}. Pour {net} on the far side (kicad_add_zone) so the heat spreads; until then KiCad's DRC warns each via is dangling. kicad_run_drc checks the board.", plan["vias"], plan["part"].as_str().unwrap_or(""), plan["pad"].as_str().unwrap_or("")),+                ViaJob::Fence => format!("{} fence via(s) on {net}. Refill the pours so they join the vias (kicad_zone_state {{refill:true}}), then kicad_run_drc; kicad_return_path shows where a net still runs over a gap.", plan["vias"]),+            });+            v+        }+        Err(e) => e,+    }+}++#[cfg(feature = "ipc")]+fn place_live(state: &mut State, args: &Value, standard_rev: &str, net: &str, vias: &[Value], message: &str) -> Result<Value, Value> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let mut a = json!({"net": net, "vias": vias, "expectedRevision": args.get("expectedRevision").cloned().unwrap_or(json!(standard_rev))});+    for k in ["filePath", "socketPath", "save", "validate"] {+        if let Some(v) = args.get(k) {+            a[k] = v.clone();+        }+    }+    ipc::with_standard_revision(&ctx, &a, |a| ipc::place_vias(&ctx, a, message)).map_err(|e| ipc::error_response(&e))+}++#[cfg(not(feature = "ipc"))]+fn place_live(_state: &mut State, _args: &Value, _rev: &str, _net: &str, _vias: &[Value], _message: &str) -> Result<Value, Value> {+    Err(fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "Close the board in KiCad and run again to write the file."))+}++/// The closed board: splice the vias into the file after the DRC preflight (backup kept unless+/// backup:false).+fn place_in_file(state: &mut State, args: &Value, r: &Read, standard_rev: &str, net: &str, vias: &[Value]) -> Result<Value, Value> {+    if let Some(want) = args.get("expectedRevision").and_then(Value::as_str) {+        if want != standard_rev {+            return Err(fail("stale_board", "expectedRevision does not match the board file", "Read it again (kicad_routing_state {model:\"standard\"}) and pass its revision."));+        }+    }+    let board = pcb::parse_pcb_text(&r.text, &r.file).map_err(|e| fail("board_unparsable", e, "Open and save it in KiCad to repair it."))?;+    let net = pcb::resolve_net(&board, Some(&json!(net)))?;+    let copper = pcb::copper_layers(&board);+    let (top, bottom) = (copper.first().cloned().unwrap_or_else(|| "F.Cu".into()), copper.last().cloned().unwrap_or_else(|| "B.Cu".into()));+    let blocks: Vec<String> = vias.iter().map(|v| {+        let at = (v["at"][0].as_f64().unwrap_or(0.0), v["at"][1].as_f64().unwrap_or(0.0));+        pcb::via_sexpr(at, v["size"].as_f64().unwrap_or(0.6), v["drill"].as_f64().unwrap_or(0.3), (&top, &bottom), &net)+    }).collect();+    let new_text = pcb::splice(&r.text, &blocks);+    let drc = file_preflight(state, args, r, &new_text)?;+    let backup = args.get("backup") != Some(&json!(false));+    let bak = pcb::write_board(std::path::Path::new(&r.file), &new_text, backup, Some(&r.text))?;+    Ok(json!({"success": true, "mutated": true, "boardPath": r.file, "backupPath": bak, "revision": board_model::revision_of_text(&new_text).unwrap_or_default(), "reloaded": false, "drc": drc}))+}++/// The closed file gets the live path's check: KiCad's DRC (pours refilled) on the board as it is+/// and as it would be; any new error refuses, nothing written. validate:false skips it.+#[cfg(feature = "ipc")]+fn file_preflight(state: &mut State, args: &Value, r: &Read, new_text: &str) -> Result<Value, Value> {+    use kicad_core::ipc;+    if args.get("validate") == Some(&json!(false)) {+        return Ok(Value::Null);+    }+    let cli = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file());+    let source = PathBuf::from(&r.file);+    let check = || -> Result<(Value, Vec<Value>), kicad_core::pcb::RoutingError> {+        let before = ipc::drc_snapshot(cli.as_deref(), &source, &r.text)?;+        let after = ipc::drc_snapshot(cli.as_deref(), &source, new_text)?;+        let added = ipc::new_errors(&before, &after)?;+        Ok((after, added))+    };+    match check() {+        Ok((_, added)) if !added.is_empty() => {+            let mut v = fail("drc_rejected", format!("the vias add {} DRC error(s); nothing was written", added.len()), "Read violations: a smaller via or clearance, or fewer points, may fit. validate:false writes without the check.");+            v["violations"] = json!(added);+            v["mutated"] = json!(false);+            Err(v)+        }+        Ok((after, _)) => Ok(json!({"newErrors": 0, "errors": after["summary"]["errors"], "warnings": after["summary"]["warnings"], "unconnected": after["summary"]["unconnected"]})),+        Err(e) => Err(ipc::error_response(&e)),+    }+}++#[cfg(not(feature = "ipc"))]+fn file_preflight(_state: &mut State, _args: &Value, _r: &Read, _new_text: &str) -> Result<Value, Value> {+    Ok(Value::Null)+}++fn auto_classes(state: &mut State, args: &Value) -> Value {+    let r = match read(state, args) { Ok(r) => r, Err(e) => return e };+    let model = match board_model::build(&r.text, Some(&r.file), &board_model::Opts { y_up: true }) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let nets = board_nets(&model);+    let pairs = crate::verbs_composite::board_pairs(&nets);+    let proposal = board_checks::auto_classes(&nets, &pairs);+    let mut out = json!({"success": true, "proposal": proposal, "applied": false});+    stamp(&mut out, &r);+    if args.get("apply") != Some(&json!(true)) {+        out["_hint"] = json!("A proposal: nothing written. apply:true writes the non-empty classes into the project (kicad_netclasses) and assigns their nets by exact name.");+        return out;+    }+    let width = args.get("powerWidthMm").and_then(Value::as_f64).unwrap_or(0.5);+    let dry = args.get("dryRun").cloned().unwrap_or(json!(false));+    let mut written = Vec::new();+    for (class, members) in proposal["classes"].as_object().into_iter().flatten() {+        let members: Vec<&str> = members.as_array().into_iter().flatten().filter_map(Value::as_str).collect();+        if members.is_empty() {+            continue;+        }+        let mut set = json!({"name": class});+        if class == "Power" {+            set["trackWidth"] = json!(width);+        }+        let reply = call(state, "kicad_netclasses", &json!({"filePath": r.file, "set": set, "assign": {"netclass": class, "patterns": members}, "dryRun": dry}));+        written.push(json!({"class": class, "nets": members.len(), "success": reply["success"], "error": reply.get("error"), "changes": reply.get("changes")}));+    }+    let ok = written.iter().all(|w| w["success"] == json!(true));+    out["success"] = json!(ok);+    out["applied"] = json!(ok && dry != json!(true));+    out["written"] = json!(written);+    out["_hint"] = json!("Classes written into the .kicad_pro beside the board (an open PCB editor sees them after reloading the project). Scope rules to them (A.NetClass == 'Power'), size pairs with kicad_impedance {apply}, check power with kicad_current_capacity.");+    out+}++fn pin_pad_check(state: &mut State, args: &Value) -> Value {+    let mut a = args.clone();+    if a.get("filePath").is_none() {+        if let Some(p) = arg_str(args, "pcbPath") {+            a["filePath"] = json!(p);+        } else if let Some(p) = arg_str(args, "projectPath") {+            a["filePath"] = json!(norm(&PathBuf::from(p).with_extension("kicad_pcb")));+        }+    }+    let r = match read(state, &a) { Ok(r) => r, Err(e) => return e };+    let board = match pcb::parse_pcb_text(&r.text, &r.file) {+        Ok(b) => b,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let (nl, from) = match crate::verbs_pcb_sync::netlist_for(state, &a, std::path::Path::new(&r.file)) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut v = board_checks::pin_pad_check(&board, &r.text, &nl);+    stamp(&mut v, &r);+    v["schematic"] = json!(from);+    let bad = v["mismatches"].as_array().map_or(0, |m| m.len());+    v["_hint"] = json!(format!("{}{} part(s) match, {bad} do not. {}", file_note(&r), v["matching"],+        if v["ok"] == json!(true) { "Every symbol pin has a pad." } else { "A pin with no pad can never reach copper: fix the footprint or the symbol's pin numbers, then kicad_update_pcb_from_schematic." }));+    v+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn composite_and_live_mechanisms_are_safe() {+        // height_check and auto_classes call other verbs (verbs::call): they must not hold the GUI lock+        for n in ["kicad_height_check", "kicad_auto_classes", "kicad_pin_pad_check"] {+            assert!(matches!(VERBS.iter().find(|v| v.name == n).unwrap().mechanism, Mechanism::File), "{n}");+        }+    }++    #[test]+    fn pin_pad_check_reads_the_route_fixture() {+        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");+        let pcb_path = dir.join("route-fixture.kicad_pcb");+        let text = std::fs::read_to_string(&pcb_path).unwrap();+        let board = pcb::parse_pcb_text(&text, &pcb_path.to_string_lossy()).unwrap();+        // a netlist that agrees on every footprint but one pin+        let mut xml = String::from("<export><components>");+        let mut nets = String::from("<nets>");+        for (i, fp) in board.footprints.iter().enumerate() {+            xml.push_str(&format!("<comp ref=\"{}\"><value>x</value><footprint>{}</footprint></comp>", fp.reference, fp.footprint));+            for p in &fp.pads {+                nets.push_str(&format!("<net code=\"{i}{0}\" name=\"n{i}{0}\"><node ref=\"{1}\" pin=\"{0}\"/></net>", p.number, fp.reference));+            }+        }+        let first = &board.footprints[0].reference;+        nets.push_str(&format!("<net code=\"999\" name=\"extra\"><node ref=\"{first}\" pin=\"99\"/></net>"));+        xml.push_str("</components>");+        xml.push_str(&nets);+        xml.push_str("</nets></export>");+        let nl = kicad_core::netlist::parse_kicadxml(&xml).unwrap();+        let v = board_checks::pin_pad_check(&board, &text, &nl);+        assert_eq!(v["ok"], false, "{v}");+        assert_eq!(v["mismatches"][0]["ref"], json!(first));+        assert_eq!(v["mismatches"][0]["pinsWithoutPad"], json!(["99"]));+        assert_eq!(v["matching"].as_u64().unwrap() as usize, board.footprints.len() - 1);+    }++    #[test]+    fn find_space_and_orientation_on_a_real_board() {+        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb");+        let text = std::fs::read_to_string(&p).unwrap();+        let file = p.to_string_lossy().into_owned();+        let model = board_model::build(&text, Some(&file), &board_model::Opts { y_up: true }).unwrap();+        let board = pcb::parse_pcb_text(&text, &file).unwrap();+        let outline: Vec<(f64, f64)> = model["outline"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), -q[1].as_f64().unwrap())).collect();+        let part = board.footprints.iter().find(|f| f.reference.starts_with('C')).unwrap().reference.clone();+        let v = board_checks::find_space(&board, &outline, &json!({"part": part, "radiusMm": 15}), true);+        assert_eq!(v["success"], true, "{v}");+        let spots = v["spots"].as_array().unwrap();+        assert!(!spots.is_empty(), "{v}");+        // y up: the board sits at negative y in the standard frame+        assert!(spots[0]["y"].as_f64().unwrap() < 0.0, "{v}");+        let check = board_checks::find_space(&board, &outline, &json!({"check": true}), true);+        assert_eq!(check["success"], true);+        let o = board_checks::check_orientation(&model, &json!({}));+        assert!(o["checked"].as_u64().unwrap() > 10, "{o}");+    }+}
rust/crates/kicad-bridge/src/verbs_composite.rs+12
@@ -233,6 +233,18 @@ fn pair_base(net: &str) -> Option<(String, bool)> {     None } +/// The differential pairs KiCad's router recognises among `nets` by name: (name, pos, neg).+pub(crate) fn board_pairs(nets: &[String]) -> Vec<(String, String, String)> {+    let mut groups: std::collections::BTreeMap<String, (Option<String>, Option<String>)> = Default::default();+    for n in nets {+        if let Some((key, pos)) = pair_base(n) {+            let e = groups.entry(key).or_default();+            if pos { e.0 = Some(n.clone()) } else { e.1 = Some(n.clone()) }+        }+    }+    groups.into_iter().filter_map(|(k, (p, n))| Some((k.split('|').next().unwrap_or("").trim_end_matches('_').to_string(), p?, n?))).collect()+}+ /// `kicad_diff_pairs`: list, define (a net class holding both nets) or remove. fn diff_pairs(state: &mut State, args: &Value) -> Value {     let Some(file) = arg_str(args, "filePath") else { return fail("missing_arg", "filePath is required", "The .kicad_pcb (its project holds the net classes).") };
rust/crates/kicad-bridge/src/verbs_pcb_sync.rs+1−1
@@ -52,7 +52,7 @@ fn lib_ctx(info: &KicadInfo) -> LibCtx { }  /// The netlist the call names: a kicadxml file, or kicad-cli on the schematic.-fn netlist_for(state: &mut State, args: &Value, board: &Path) -> Result<(Netlist, String), Value> {+pub(crate) fn netlist_for(state: &mut State, args: &Value, board: &Path) -> Result<(Netlist, String), Value> {     if let Some(p) = arg_str(args, "netlistPath") {         let p = PathBuf::from(p.trim());         let xml = std::fs::read_to_string(&p).map_err(|e| fail("netlist_unreadable", format!("cannot read {}: {e}", norm(&p)), "netlistPath is a KiCad XML netlist (Eeschema File > Export > Netlist, KiCad XML)."))?;
rust/crates/kicad-bridge/src/verbs_report.rsadded+234
@@ -0,0 +1,234 @@+//! Verb group "report": `kicad_board_report`, the EDA Bridge Standard's board_report. Every check+//! this bridge has, run against the board and its project one after another through+//! `verbs::call` (so each takes its own lock and keeps its own refusals), summarised as sections+//! with a rendered picture of the board, written as one self-contained HTML page. A composite:+//! catalogued File, it holds no GUI_LOCK itself.++use std::path::PathBuf;+use std::time::Instant;++use serde_json::{json, Value};++use crate::catalog::{Mechanism, Verb};+use crate::util::*;+use crate::verbs::call;++pub static VERBS: &[Verb] = &[Verb {+    name: "kicad_board_report",+    summary: "One shareable page per board: every check run against the board and its project (KiCad's DRC and ERC, a fab's limits, the board against the schematic, symbols against footprints, return paths, copper balance, impedance widths, 3D heights, pair skew and part orientation) with a picture of the board, written as a single HTML file.",+    mechanism: Mechanism::File, risk: "write", timeout_sec: 900,+    input: "{\"filePath\": \"C:/.../x.kicad_pcb\" | \"pcbPath\" | \"projectPath\": \"C:/.../x.kicad_pro\", \"out\"?: \"C:/.../x report.html\", \"fab\"?: \"jlcpcb\", \"z0\"?: 50, \"diff\"?: 90, \"picture\"?: true}",+    example: "kicad_board_report {\"projectPath\":\"C:/Users/john/proj/proj.kicad_pro\"}",+    hint: "sections[] {title, ok (true, false, or null for information), summary, verb}: each names the verb that gives its full detail. Open the report in a browser or send it; it is one file with the picture inside.",+    related: &["kicad_run_drc", "kicad_fab_check", "kicad_pin_pad_check", "kicad_return_path", "kicad_copper_balance", "kicad_height_check"],+    pitfalls: &["Reads the saved files (DRC, ERC and the render run kicad-cli); the board checks read the live editor when it serves the board.", "Takes a minute or more on a large board: the render and DRC dominate."],+}];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    match command {+        "kicad_board_report" => Some(report(state, args)),+        _ => None,+    }+}++struct Section {+    title: &'static str,+    verb: &'static str,+    ok: Option<bool>,+    summary: String,+    detail: Value,+}++fn n(v: &Value) -> u64 {+    v.as_u64().unwrap_or(0)+}++fn len(v: &Value) -> usize {+    v.as_array().map_or(0, |a| a.len())+}++/// A check that could not run is reported as such, never as a pass.+fn not_run(title: &'static str, verb: &'static str, r: &Value) -> Section {+    Section { title, verb, ok: None, summary: format!("not run: {}", r["error"].as_str().unwrap_or("no answer")), detail: r.clone() }+}++fn report(state: &mut State, args: &Value) -> Value {+    let started = Instant::now();+    let board = match arg_any(args, &["filePath", "pcbPath"]).map(PathBuf::from)+        .or_else(|| arg_str(args, "projectPath").map(|p| PathBuf::from(p).with_extension("kicad_pcb"))) {+        Some(b) if b.is_file() => b,+        Some(b) => return fail("board_not_found", format!("no board at {}", norm(&b)), "Pass filePath (the .kicad_pcb) or projectPath (the .kicad_pro beside it)."),+        None => return fail("missing_arg", "filePath or projectPath is required", "Pass the .kicad_pcb or its .kicad_pro."),+    };+    let file = norm(&board);+    let sch = board.with_extension("kicad_sch");+    let has_sch = sch.is_file();+    let fab = arg_str(args, "fab").unwrap_or("jlcpcb");+    let b = |extra: Value| { let mut a = json!({"filePath": file}); if let (Some(m), Some(e)) = (a.as_object_mut(), extra.as_object()) { for (k, v) in e { m.insert(k.clone(), v.clone()); } } a };+    let mut sections: Vec<Section> = Vec::new();++    let r = call(state, "kicad_run_drc", &b(json!({})));+    sections.push(if r["success"] == true {+        let s = &r["summary"];+        Section { title: "Design rules (KiCad DRC)", verb: "kicad_run_drc", ok: Some(s["clean"] == true),+            summary: format!("{} error(s), {} unconnected, {} warning(s)", n(&s["errors"]), n(&s["unconnected"]), n(&s["warnings"])), detail: json!({"summary": s, "first": s["first"]}) }+    } else { not_run("Design rules (KiCad DRC)", "kicad_run_drc", &r) });++    if has_sch {+        let r = call(state, "kicad_run_erc", &json!({"filePath": norm(&sch)}));+        sections.push(if r["success"] == true {+            let s = &r["summary"];+            Section { title: "Schematic (KiCad ERC)", verb: "kicad_run_erc", ok: Some(n(&s["errors"]) == 0),+                summary: format!("{} error(s), {} warning(s)", n(&s["errors"]), n(&s["warnings"])), detail: json!({"summary": s, "first": s["first"]}) }+        } else { not_run("Schematic (KiCad ERC)", "kicad_run_erc", &r) });+        let r = call(state, "kicad_update_pcb_from_schematic", &b(json!({"apply": false})));+        let plan = r["plan"].clone();+        sections.push(if r["success"] == true {+            let r = plan;+            Section { title: "Board against the schematic", verb: "kicad_update_pcb_from_schematic", ok: Some(r["inSync"] == true),+                summary: if r["inSync"] == true { "in sync".into() } else { format!("{} change(s) to bring over", n(&r["changeCount"])) },+                detail: json!({"missingFootprints": r["missingFootprints"], "extraFootprints": r["extraFootprints"], "changedValues": r["changedValues"], "changedFootprints": r["changedFootprints"], "padNetChanges": r["padNetChanges"]}) }+        } else { not_run("Board against the schematic", "kicad_update_pcb_from_schematic", &r) });+        let r = call(state, "kicad_pin_pad_check", &b(json!({})));+        sections.push(if r["success"] == true {+            Section { title: "Symbols against footprints", verb: "kicad_pin_pad_check", ok: Some(r["ok"] == true),+                summary: format!("{} part(s) match, {} do not", n(&r["matching"]), len(&r["mismatches"])), detail: json!({"mismatches": r["mismatches"], "onBoardOnly": r["onBoardOnly"], "onSheetsOnly": r["onSheetsOnly"]}) }+        } else { not_run("Symbols against footprints", "kicad_pin_pad_check", &r) });+    }++    let r = call(state, "kicad_fab_check", &b(json!({"fab": fab})));+    sections.push(if r["success"] == true {+        let outside = len(&r["outsideLimits"]);+        let read = match (r["fab"]["source"].as_str(), r["fab"]["read"].as_str()) { (Some(src), Some(d)) => format!(" (limits read {d} from {src})"), _ => String::new() };+        Section { title: "Fab limits", verb: "kicad_fab_check", ok: r["fits"].as_bool(),+            summary: format!("{fab}: {outside} feature(s) outside its limits{read}"), detail: json!({"fab": r["fab"], "outsideLimits": r["outsideLimits"], "measured": r["measured"], "notChecked": r["notChecked"]}) }+    } else { not_run("Fab limits", "kicad_fab_check", &r) });++    let r = call(state, "kicad_return_path", &b(json!({})));+    sections.push(if r["success"] == true {+        Section { title: "Return paths", verb: "kicad_return_path", ok: Some(r["ok"] == true),+            summary: if r["ok"] == true { "every signal runs over reference copper".into() } else { format!("{} net/layer run(s) over a gap in the reference copper", len(&r["nets"])) },+            detail: json!({"referenceNets": r["referenceNets"], "nets": r["nets"], "fix": r["fix"]}) }+    } else { not_run("Return paths", "kicad_return_path", &r) });++    let r = call(state, "kicad_copper_balance", &b(json!({})));+    sections.push(if r["success"] == true {+        let pairs: Vec<String> = r["pairs"].as_array().into_iter().flatten().map(|p| format!("{} {} pts", p["layers"].as_array().map(|l| l.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("/")).unwrap_or_default(), p["differencePoints"])).collect();+        Section { title: "Copper balance", verb: "kicad_copper_balance", ok: Some(r["balanced"] == true), summary: pairs.join(", "), detail: json!({"layers": r["layers"], "pairs": r["pairs"]}) }+    } else { not_run("Copper balance", "kicad_copper_balance", &r) });++    let z = json!({"z0": args.get("z0").cloned().unwrap_or(json!(50)), "diff": args.get("diff").cloned().unwrap_or(json!(90))});+    let r = call(state, "kicad_impedance", &b(z.clone()));+    sections.push(if r["success"] == true {+        let rows: Vec<String> = r["layers"].as_array().into_iter().flatten().map(|l| format!("{} {} mm", l["layer"].as_str().unwrap_or(""), l["width"])).collect();+        Section { title: "Impedance widths", verb: "kicad_impedance", ok: None, summary: format!("{} ohm: {}", z["z0"], rows.join(", ")), detail: json!({"layers": r["layers"], "assumed": r["assumed"], "warnings": r["warnings"]}) }+    } else { not_run("Impedance widths", "kicad_impedance", &r) });++    let r = call(state, "kicad_height_check", &b(json!({})));+    sections.push(if r["success"] == true {+        let tall = r["heights"][0].clone();+        Section { title: "3D heights", verb: "kicad_height_check", ok: Some(len(&r["collisions"]) == 0),+            summary: format!("tallest {} {} mm; {} body collision(s); {} part(s) without a 3D model", tall["ref"].as_str().unwrap_or("-"), tall["heightMm"], len(&r["collisions"]), len(&r["noBody"])),+            detail: json!({"heights": r["heights"], "collisions": r["collisions"], "noBody": r["noBody"]}) }+    } else { not_run("3D heights", "kicad_height_check", &r) });++    let r = call(state, "kicad_net_lengths", &b(json!({})));+    sections.push(if r["success"] == true {+        let worst = r["pairs"].as_array().into_iter().flatten().filter(|p| p["routed"] == true).map(|p| p["skewMm"].as_f64().unwrap_or(0.0)).fold(0.0, f64::max);+        Section { title: "Lengths and pair skew", verb: "kicad_net_lengths", ok: None,+            summary: format!("{} routed net(s), {} pair(s), worst skew {:.3} mm", len(&r["nets"]), len(&r["pairs"]), worst), detail: json!({"pairs": r["pairs"], "longest": r["nets"].as_array().map(|a| a.iter().take(10).cloned().collect::<Vec<_>>())}) }+    } else { not_run("Lengths and pair skew", "kicad_net_lengths", &r) });++    let r = call(state, "kicad_check_orientation", &b(json!({})));+    sections.push(if r["success"] == true {+        Section { title: "Part orientation", verb: "kicad_check_orientation", ok: None,+            summary: format!("{} of {} two-pad part(s) would shorten the airline turned", len(&r["suggestions"]), n(&r["checked"])), detail: json!({"suggestions": r["suggestions"]}) }+    } else { not_run("Part orientation", "kicad_check_orientation", &r) });++    // the picture: kicad-cli's render, read back and put inside the page+    let mut picture: Option<String> = None;+    if args.get("picture") != Some(&json!(false)) {+        let dir = std::env::temp_dir().join(format!("adom-kicad-report-{}", std::process::id()));+        let r = call(state, "kicad_render_board", &b(json!({"side": "top", "width": 1400, "height": 900, "outputDir": norm(&dir)})));+        if let Some(p) = r["data"]["output_path"].as_str() {+            if let Ok(bytes) = std::fs::read(p) {+                picture = Some(crate::taskbar::base64(&bytes));+            }+        }+        let _ = std::fs::remove_dir_all(&dir);+    }++    let out = arg_str(args, "out").map(PathBuf::from).unwrap_or_else(|| {+        let stem = board.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "board".into());+        board.with_file_name(format!("{stem} report.html"))+    });+    let html = page(&file, &sections, picture.as_deref());+    if let Err(e) = std::fs::write(&out, html) {+        return fail("write_failed", format!("could not write {}: {e}", norm(&out)), "Pass out: a .html path the desktop user can write.");+    }+    let pass = sections.iter().filter(|s| s.ok == Some(true)).count();+    let needs = sections.iter().filter(|s| s.ok == Some(false)).count();+    json!({"success": true, "report": norm(&out), "pass": pass, "needsWork": needs, "info": sections.len() - pass - needs, "picture": picture.is_some(),+        "elapsedSec": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0,+        "sections": sections.iter().map(|s| json!({"title": s.title, "ok": s.ok, "summary": s.summary, "verb": s.verb})).collect::<Vec<_>>(),+        "output": format!("{pass} pass, {needs} need work: {}", norm(&out)),+        "_hint": "Open the report in a browser (desktop_open_path) or send it. Each section names the verb that gives its full detail; fix what needs work and run this again."})+}++fn esc(s: &str) -> String {+    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")+}++/// The page: a header, the picture, one row per section with its detail folded away.+fn page(board: &str, sections: &[Section], picture: Option<&str>) -> String {+    let name = std::path::Path::new(board).file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+    let mut rows = String::new();+    for s in sections {+        let (badge, class) = match s.ok { Some(true) => ("pass", "ok"), Some(false) => ("needs work", "bad"), None => ("info", "info") };+        let mut detail = serde_json::to_string_pretty(&s.detail).unwrap_or_default();+        if detail.len() > 40_000 {+            detail.truncate(40_000);+            detail.push_str("\n... (truncated: run the verb for all of it)");+        }+        rows.push_str(&format!("<section><div class=\"row\"><span class=\"badge {class}\">{badge}</span><h2>{}</h2></div><p>{}</p><details><summary>detail ({})</summary><pre>{}</pre></details></section>\n",+            esc(s.title), esc(&s.summary), esc(s.verb), esc(&detail)));+    }+    let pic = picture.map(|b| format!("<img alt=\"the board, top side\" src=\"data:image/png;base64,{b}\">")).unwrap_or_default();+    format!(r#"<!doctype html>+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">+<title>{title} board report</title>+<style>+:root {{ --bg: #ffffff; --fg: #1d1d1f; --muted: #6e6e73; --line: #e5e5ea; --ok: #1a7f37; --bad: #c62828; --info: #5856d6; --code: #f5f5f7; }}+@media (prefers-color-scheme: dark) {{ :root {{ --bg: #16161a; --fg: #ececf1; --muted: #a1a1aa; --line: #2c2c33; --ok: #4ade80; --bad: #f87171; --info: #a5b4fc; --code: #1f1f25; }} }}+body {{ background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; margin: 0 auto; max-width: 980px; padding: 24px 16px; }}+h1 {{ font-size: 22px; margin: 0 0 4px; }} .sub {{ color: var(--muted); margin: 0 0 20px; word-break: break-all; }}+img {{ width: 100%; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 20px; }}+section {{ border-top: 1px solid var(--line); padding: 12px 0; }} .row {{ display: flex; gap: 12px; align-items: center; }}+h2 {{ font-size: 16px; margin: 0; }} p {{ margin: 6px 0; }}+.badge {{ font-size: 12px; font-weight: 600; border-radius: 999px; padding: 2px 10px; border: 1px solid currentColor; white-space: nowrap; }}+.ok {{ color: var(--ok); }} .bad {{ color: var(--bad); }} .info {{ color: var(--info); }}+summary {{ color: var(--muted); cursor: pointer; }} pre {{ background: var(--code); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; }}+</style></head><body>+<h1>{title}</h1><p class="sub">{board} &middot; KiCad Bridge board report</p>+{pic}+{rows}</body></html>+"#, title = esc(&name), board = esc(board))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn the_page_names_each_section_and_never_calls_a_skipped_check_a_pass() {+        let s = vec![+            Section { title: "Design rules (KiCad DRC)", verb: "kicad_run_drc", ok: Some(false), summary: "2 error(s)".into(), detail: json!({"a": "<b>"}) },+            not_run("Fab limits", "kicad_validate_for_fab", &json!({"error": "kicad-cli missing"})),+        ];+        let html = page("C:/p/b.kicad_pcb", &s, None);+        assert!(html.contains("needs work") && html.contains("not run: kicad-cli missing"));+        assert!(html.contains("&lt;b&gt;") && !html.contains("<b>"));+        assert_eq!(s[1].ok, None);+        assert!(matches!(VERBS[0].mechanism, Mechanism::File));+    }+}
rust/crates/kicad-bridge/src/verbs_standard.rs+8−8
@@ -156,10 +156,10 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> const NO_EDITOR: &[&str] = &["ipc_unavailable", "api_server_off", "no_board_open", "board_mismatch", "no_pcb_frame", "unsupported_kicad", "ipc_feature_off"];  /// The board's text and where it came from.-struct Read {-    text: String,-    file: String,-    live: bool,+pub(crate) struct Read {+    pub(crate) text: String,+    pub(crate) file: String,+    pub(crate) live: bool,     kicad_version: Option<String>,     /// why the live read was not used, when the file was read instead     live_error: Option<Value>,@@ -167,7 +167,7 @@ struct Read {  /// The live editor's serialisation when it serves the board (source auto or live), else the file /// (auto or file).-fn read(state: &mut State, args: &Value) -> Result<Read, Value> {+pub(crate) fn read(state: &mut State, args: &Value) -> Result<Read, Value> {     let source = args.get("source").and_then(Value::as_str).unwrap_or("auto");     if !matches!(source, "auto" | "live" | "file") {         return Err(fail("bad_args", "source must be auto, live or file", "auto reads the live editor when it serves the board and the file otherwise."));@@ -214,7 +214,7 @@ fn live_text(_state: &mut State, _args: &Value) -> Result<(String, String, Strin     Err(fail("ipc_feature_off", "This build of the bridge has the ipc feature off", "The board is read from the file instead.")) } -fn opts(args: &Value) -> Result<board_model::Opts, Value> {+pub(crate) fn opts(args: &Value) -> Result<board_model::Opts, Value> {     match args.get("frame").and_then(Value::as_str) {         None | Some("standard") | Some("yup") => Ok(board_model::Opts { y_up: true }),         Some("kicad") => Ok(board_model::Opts { y_up: false }),@@ -223,7 +223,7 @@ fn opts(args: &Value) -> Result<board_model::Opts, Value> { }  /// Where the reply came from, said the same way on every standard verb.-fn stamp(v: &mut Value, r: &Read) {+pub(crate) fn stamp(v: &mut Value, r: &Read) {     v["success"] = json!(true);     v["source"] = json!(if r.live { "live-editor" } else { "file" });     v["file"] = json!(r.file);@@ -235,7 +235,7 @@ fn stamp(v: &mut Value, r: &Read) {     } } -fn file_note(r: &Read) -> &'static str {+pub(crate) fn file_note(r: &Read) -> &'static str {     match (r.live, r.live_error.is_some()) {         (true, _) => "",         (false, true) => "Read from the file (no PCB editor serves this board with the IPC API on; see live): unsaved edits are not in it. ",
rust/crates/kicad-core/src/board_checks.rsadded+922
@@ -0,0 +1,922 @@+//! The EDA Bridge Standard's checks that this bridge computes itself, from the board model+//! (`board_model::build`), the parsed board (`pcb`) and the netlist: net_lengths,+//! check_orientation, find_space, height_check, thermal_vias (the plan), auto_classes (the+//! proposal) and pin_pad_check. Pure: no I/O and no KiCad; the verbs read the board and apply.+//!+//! Model coordinates are in the frame the model was built in (the standard's y up, or KiCad's y+//! down); everything here is frame-free except `find_space`, which works on the parsed board (y+//! down) and answers in the frame asked.++use std::collections::{BTreeMap, HashMap};++use serde_json::{json, Value};++use crate::netlist::Netlist;+use crate::pcb::Board;+use crate::placement::{self, Rect};++fn f(v: &Value) -> f64 {+    v.as_f64().unwrap_or(0.0)+}++fn r3(x: f64) -> f64 {+    (x * 1000.0).round() / 1000.0 + 0.0+}++fn txt(v: &Value) -> &str {+    v.as_str().unwrap_or("")+}++/// A point given as `{x, y}` or `[x, y]`.+fn xy(v: &Value) -> (f64, f64) {+    match v {+        Value::Array(a) => (a.first().map(f).unwrap_or(0.0), a.get(1).map(f).unwrap_or(0.0)),+        _ => (f(&v["x"]), f(&v["y"])),+    }+}++fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {+    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()+}++fn arr<'a>(v: &'a Value, k: &str) -> impl Iterator<Item = &'a Value> {+    v[k].as_array().into_iter().flatten()+}++fn wanted(args: &Value, key: &str) -> Option<Vec<String>> {+    args.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect())+}++// ---------------------------------------------------------------------------+// net_lengths+// ---------------------------------------------------------------------------++#[derive(Default)]+struct Length {+    total: f64,+    by_layer: BTreeMap<String, f64>,+    vias: usize,+    segments: usize,+}++/// Routed length per net: straight tracks and arcs (radius x sweep), by layer, with the vias+/// counted (their barrel is not added). `pairs` are the board's own pairs by name, used when the+/// caller names none.+pub fn net_lengths(model: &Value, args: &Value, pairs: &[(String, String, String)]) -> Value {+    let mut nets: BTreeMap<String, Length> = BTreeMap::new();+    for t in arr(model, "tracks") {+        let net = txt(&t["net"]);+        if net.is_empty() {+            continue;+        }+        let l = nets.entry(net.to_string()).or_default();+        let len = dist(xy(&t["start"]), xy(&t["end"]));+        l.total += len;+        *l.by_layer.entry(txt(&t["layer"]).to_string()).or_default() += len;+        l.segments += 1;+    }+    for a in arr(model, "arcs") {+        let net = txt(&a["net"]);+        if net.is_empty() {+            continue;+        }+        // the model's arcs run counterclockwise from startAngle to endAngle+        let sweep = (f(&a["endAngle"]) - f(&a["startAngle"])).rem_euclid(360.0);+        let len = f(&a["radius"]) * sweep.to_radians();+        let l = nets.entry(net.to_string()).or_default();+        l.total += len;+        *l.by_layer.entry(txt(&a["layer"]).to_string()).or_default() += len;+        l.segments += 1;+    }+    for v in arr(model, "vias") {+        let net = txt(&v["net"]);+        if !net.is_empty() {+            nets.entry(net.to_string()).or_default().vias += 1;+        }+    }+    let board_nets: Vec<String> = arr(model, "nets").filter_map(|n| n.as_str().or(n["name"].as_str()).map(String::from)).collect();+    let find = |name: &str| -> Option<String> { board_nets.iter().find(|n| n.eq_ignore_ascii_case(name)).cloned() };+    let length_of = |name: &str| nets.get(name).map(|l| l.total).unwrap_or(0.0);+    let row = |name: &str, l: &Length| {+        json!({"net": name, "lengthMm": r3(l.total), "byLayer": l.by_layer.iter().map(|(k, v)| (k.clone(), json!(r3(*v)))).collect::<serde_json::Map<_, _>>(), "vias": l.vias, "segments": l.segments})+    };+    let mut rows: Vec<Value> = Vec::new();+    let mut unknown = Vec::new();+    let mut unrouted = Vec::new();+    match wanted(args, "nets") {+        Some(want) => {+            for w in &want {+                match board_nets.iter().find(|n| n.to_ascii_lowercase() == *w) {+                    None => unknown.push(json!(w)),+                    Some(n) => match nets.get(n) {+                        Some(l) if l.segments > 0 => rows.push(row(n, l)),+                        _ => unrouted.push(json!(n)),+                    },+                }+            }+        }+        None => rows.extend(nets.iter().filter(|(_, l)| l.segments > 0).map(|(n, l)| row(n, l))),+    }+    rows.sort_by(|a, b| f(&b["lengthMm"]).partial_cmp(&f(&a["lengthMm"])).unwrap_or(std::cmp::Ordering::Equal));+    // pairs: the caller's, else the board's by name+    let asked: Vec<(String, String, String)> = match args.get("pairs").and_then(Value::as_array) {+        Some(a) => a.iter().map(|p| {+            let (pos, neg) = (txt(&p["pos"]).to_string(), txt(&p["neg"]).to_string());+            let name = p["name"].as_str().map(String::from).unwrap_or_else(|| format!("{pos}/{neg}"));+            (name, pos, neg)+        }).collect(),+        None => pairs.to_vec(),+    };+    let mut pair_rows = Vec::new();+    for (name, pos, neg) in &asked {+        let (Some(p), Some(n)) = (find(pos), find(neg)) else {+            pair_rows.push(json!({"name": name, "pos": pos, "neg": neg, "error": "not nets on this board"}));+            continue;+        };+        let (a, b) = (length_of(&p), length_of(&n));+        pair_rows.push(json!({"name": name, "pos": p, "neg": n, "posMm": r3(a), "negMm": r3(b), "skewMm": r3((a - b).abs()), "routed": a > 0.0 && b > 0.0}));+    }+    // matched-length groups+    let mut group_rows = Vec::new();+    let mut out_of = 0;+    for g in arr(args, "groups") {+        let tol = g.get("toleranceMm").and_then(Value::as_f64).unwrap_or(0.5);+        let members: Vec<(String, f64)> = arr(g, "nets").filter_map(Value::as_str).map(|n| {+            let name = find(n).unwrap_or_else(|| n.to_string());+            let len = length_of(&name);+            (name, len)+        }).collect();+        let longest = members.iter().cloned().fold((String::new(), f64::MIN), |acc, m| if m.1 > acc.1 { m } else { acc });+        let shortest = members.iter().map(|m| m.1).fold(f64::INFINITY, f64::min);+        let spread = if members.is_empty() { 0.0 } else { longest.1 - shortest };+        let ok = spread <= tol + 1e-9;+        if !ok {+            out_of += 1;+        }+        group_rows.push(json!({"nets": members.iter().map(|(n, l)| json!({"net": n, "lengthMm": r3(*l), "shortByMm": r3(longest.1 - l)})).collect::<Vec<_>>(),+            "spreadMm": r3(spread), "toleranceMm": tol, "withinTolerance": ok, "longest": longest.0}));+    }+    json!({"success": true, "nets": rows, "pairs": pair_rows, "groups": group_rows, "groupsOutOfTolerance": out_of,+        "unknownNets": unknown, "unroutedNets": unrouted,+        "note": "Routed copper only: tracks and arcs end to end on each layer. Vias are counted, their barrel is not added (KiCad's Net Inspector can add it from the stack-up)."})+}++// ---------------------------------------------------------------------------+// check_orientation+// ---------------------------------------------------------------------------++/// Two-pad parts whose airline would be shorter turned by 90, 180 or 270 degrees about their+/// anchor. A net with more than `planeNetPads` pads counts by the distance to its nearest other+/// pad (a plane reaches it), any other by its minimum spanning tree.+pub fn check_orientation(model: &Value, args: &Value) -> Value {+    let min_gain = args.get("minImprovementMm").and_then(Value::as_f64).unwrap_or(0.6);+    let plane_pads = args.get("planeNetPads").and_then(Value::as_u64).unwrap_or(12) as usize;+    let only = wanted(args, "parts");+    // pads by net, and by part+    let pads: Vec<(String, String, (f64, f64))> = arr(model, "pads").map(|p| (txt(&p["ref"]).to_string(), txt(&p["net"]).to_string(), (f(&p["x"]), f(&p["y"])))).collect();+    let mut by_net: HashMap<&str, Vec<usize>> = HashMap::new();+    let mut by_ref: HashMap<&str, Vec<usize>> = HashMap::new();+    for (i, p) in pads.iter().enumerate() {+        by_ref.entry(p.0.as_str()).or_default().push(i);+        if !p.1.is_empty() && !p.1.starts_with("unconnected-") {+            by_net.entry(p.1.as_str()).or_default().push(i);+        }+    }+    let cost = |moved: &[(usize, (f64, f64))]| -> f64 {+        let at = |i: usize| moved.iter().find(|m| m.0 == i).map(|m| m.1).unwrap_or(pads[i].2);+        let mut nets: Vec<&str> = moved.iter().map(|m| pads[m.0].1.as_str()).filter(|n| by_net.contains_key(n)).collect();+        nets.sort();+        nets.dedup();+        nets.iter().map(|n| {+            let members = &by_net[n];+            if members.len() > plane_pads {+                moved.iter().filter(|m| pads[m.0].1 == *n).map(|m| {+                    members.iter().filter(|j| !moved.iter().any(|x| x.0 == **j)).map(|j| dist(m.1, pads[*j].2)).fold(f64::INFINITY, f64::min)+                }).filter(|d| d.is_finite()).sum::<f64>()+            } else {+                placement::mst_length(&members.iter().map(|j| at(*j)).collect::<Vec<_>>())+            }+        }).sum()+    };+    let mut checked = 0;+    let mut suggestions = Vec::new();+    for c in arr(model, "components") {+        let r = txt(&c["ref"]);+        if let Some(o) = &only {+            if !o.contains(&r.to_ascii_lowercase()) {+                continue;+            }+        }+        let Some(mine) = by_ref.get(r) else { continue };+        if mine.len() != 2 {+            continue;+        }+        checked += 1;+        let anchor = (f(&c["x"]), f(&c["y"]));+        let rot = f(&c["rotation"]);+        let now: Vec<(usize, (f64, f64))> = mine.iter().map(|i| (*i, pads[*i].2)).collect();+        let base = cost(&now);+        let mut best = (0.0, base);+        for turn in [90.0_f64, 180.0, 270.0] {+            let (s, co) = turn.to_radians().sin_cos();+            // the model's frame: a positive turn is counterclockwise in y up; flip for y down+            let s = if model["frame"] == "kicad" { -s } else { s };+            let moved: Vec<(usize, (f64, f64))> = mine.iter().map(|i| {+                let (dx, dy) = (pads[*i].2 .0 - anchor.0, pads[*i].2 .1 - anchor.1);+                (*i, (anchor.0 + dx * co - dy * s, anchor.1 + dx * s + dy * co))+            }).collect();+            let c2 = cost(&moved);+            if c2 < best.1 - 1e-9 {+                best = (turn, c2);+            }+        }+        if best.0 != 0.0 && base - best.1 >= min_gain {+            suggestions.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "turnBy": best.0,+                "airlineNowMm": r3(base), "airlineThenMm": r3(best.1), "savesMm": r3(base - best.1)}));+        }+    }+    suggestions.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));+    json!({"success": true, "checked": checked, "suggestions": suggestions, "minImprovementMm": min_gain, "planeNetPads": plane_pads})+}++// ---------------------------------------------------------------------------+// find_space+// ---------------------------------------------------------------------------++/// Distance from a segment to a rectangle (0 when they touch or cross).+fn seg_rect(a: (f64, f64), b: (f64, f64), r: &Rect) -> f64 {+    let inside = |p: (f64, f64)| p.0 >= r.min_x && p.0 <= r.max_x && p.1 >= r.min_y && p.1 <= r.max_y;+    if inside(a) || inside(b) {+        return 0.0;+    }+    let corners = [(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)];+    let mut d = f64::INFINITY;+    for i in 0..4 {+        let (c, e) = (corners[i], corners[(i + 1) % 4]);+        if segments_cross(a, b, c, e) {+            return 0.0;+        }+        d = d.min(point_seg(c, a, b)).min(point_seg(a, c, e)).min(point_seg(b, c, e));+    }+    d+}++fn point_seg(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    dist(p, (a.0 + t * dx, a.1 + t * dy))+}++fn segments_cross(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool {+    let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+    (d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)+}++fn inside_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {+    let mut inside = false;+    let n = poly.len();+    for i in 0..n {+        let (a, b) = (poly[i], poly[(i + n - 1) % n]);+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+            inside = !inside;+        }+    }+    inside+}++/// Free spots for a part near another, or (`check`) the parts that overlap or leave the outline.+/// `outline` is the board outline in KiCad's frame (y down); the reply is in the standard's frame+/// when `y_up`.+pub fn find_space(board: &Board, outline: &[(f64, f64)], args: &Value, y_up: bool) -> Value {+    let fy = |y: f64| if y_up { -y } else { y };+    let places = placement::placements(board);+    if args.get("check").and_then(Value::as_bool).unwrap_or(false) {+        let overlaps: Vec<Value> = placement::overlaps(&places).iter().map(|o| json!([o.a, o.b])).collect();+        let outside: Vec<&str> = places.iter().filter(|p| p.inside_outline == Some(false)).map(|p| p.reference.as_str()).collect();+        return json!({"success": true, "check": true, "overlaps": overlaps, "outsideOutline": outside, "ok": overlaps.is_empty() && outside.is_empty(),+            "note": "Courtyards decide (their real outline where the footprint has one, else the box of its pads plus 0.25 mm)."});+    }+    let clearance = args.get("clearanceMm").and_then(Value::as_f64).unwrap_or(0.25);+    let edge = args.get("edgeMm").and_then(Value::as_f64).unwrap_or(0.3);+    let radius = args.get("radiusMm").and_then(Value::as_f64).unwrap_or(10.0);+    let count = args.get("count").and_then(Value::as_u64).unwrap_or(5) as usize;+    let copper = args.get("copper").and_then(Value::as_bool).unwrap_or(true);+    let part = args.get("part").and_then(Value::as_str);+    let me = part.and_then(|r| places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)));+    if part.is_some() && me.is_none() {+        return json!({"success": false, "errorCode": "no_such_part", "error": format!("{} is not on this board", part.unwrap_or(""))});+    }+    // the part's courtyard relative to its anchor, or a box of sizeMm round the spot+    let (dx0, dy0, dx1, dy1) = match (me, args.get("sizeMm").and_then(Value::as_array)) {+        (Some(p), _) => (p.courtyard.min_x - p.x, p.courtyard.min_y - p.y, p.courtyard.max_x - p.x, p.courtyard.max_y - p.y),+        (None, Some(s)) if s.len() == 2 => (-f(&s[0]) / 2.0, -f(&s[1]) / 2.0, f(&s[0]) / 2.0, f(&s[1]) / 2.0),+        _ => return json!({"success": false, "errorCode": "bad_args", "error": "name the part to place (part: \"U3\") or its size (sizeMm: [w, h])"}),+    };+    let side = match args.get("side").and_then(Value::as_str) {+        Some("bottom") | Some("B.Cu") => "B.Cu".to_string(),+        Some(_) => "F.Cu".to_string(),+        None => me.map(|p| p.side.clone()).unwrap_or_else(|| "F.Cu".into()),+    };+    let centre = match (args.get("near").and_then(Value::as_str), args.get("at")) {+        (Some(r), _) => match places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)) {+            Some(p) => (p.x, p.y),+            None => return json!({"success": false, "errorCode": "no_such_part", "error": format!("{r} is not on this board")}),+        },+        (None, Some(at)) if !at.is_null() => {+            let (x, y) = xy(at);+            (x, fy(y))+        }+        _ => match me {+            Some(p) => (p.x, p.y),+            None => return json!({"success": false, "errorCode": "bad_args", "error": "near (a designator) or at ({x, y} mm) says where to look"}),+        },+    };+    if outline.len() < 3 {+        return json!({"success": false, "errorCode": "no_outline", "error": "the board has no closed outline on Edge.Cuts"});+    }+    let others: Vec<&placement::Placement> = places.iter().filter(|p| p.side == side && me.map_or(true, |m| m.reference != p.reference)).collect();+    // copper on the part's own nets is no obstacle: it is the part's own routing, or the same net+    // (numbered nets in older files, names in KiCad 10's)+    let own: Vec<(Option<i64>, String)> = me.map(|m| crate::pcb::pads(board).into_iter().filter(|p| p.reference == m.reference && !p.net_name.is_empty())+        .map(|p| (p.net, p.net_name)).collect()).unwrap_or_default();+    let foreign = |net: Option<i64>, name: Option<&String>| !own.iter().any(|(n, nm)| (net.is_some() && *n == net && net != Some(0)) || name == Some(nm));+    let reach = radius + (dx1 - dx0).max(dy1 - dy0) + 2.0;+    let near = |a: (f64, f64)| dist(a, centre) <= reach;+    let tracks: Vec<((f64, f64), (f64, f64), f64)> = if copper {+        board.segments.iter().filter(|s| s.layer.as_deref() == Some(side.as_str()) && foreign(s.net, s.net_name.as_ref())).filter_map(|s| {+            let (a, b) = (s.start.as_ref()?, s.end.as_ref()?);+            let (a, b) = ((a.x, a.y), (b.x, b.y));+            (near(a) || near(b)).then_some((a, b, s.width.unwrap_or(0.2) / 2.0))+        }).collect()+    } else {+        Vec::new()+    };+    let vias: Vec<((f64, f64), f64)> = if copper {+        board.vias.iter().filter(|v| foreign(v.net, v.net_name.as_ref())).filter_map(|v| { let p = v.position.as_ref()?; let a = (p.x, p.y); near(a).then_some((a, v.size.unwrap_or(0.6) / 2.0)) }).collect()+    } else {+        Vec::new()+    };+    let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len()])).collect();+    let free = |c: (f64, f64)| -> bool {+        let r = Rect { min_x: c.0 + dx0, min_y: c.1 + dy0, max_x: c.0 + dx1, max_y: c.1 + dy1 };+        let corners = [(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)];+        if !corners.iter().all(|p| inside_polygon(*p, outline)) || edges.iter().any(|(a, b)| seg_rect(*a, *b, &r) < edge) {+            return false;+        }+        let grown = r.expand(clearance);+        if others.iter().any(|p| p.courtyard.overlaps(&grown)) {+            return false;+        }+        tracks.iter().all(|(a, b, hw)| seg_rect(*a, *b, &r) > hw + clearance) && vias.iter().all(|(p, rad)| seg_rect(*p, *p, &r) > rad + clearance)+    };+    let step = ((dx1 - dx0).min(dy1 - dy0) / 4.0).clamp(0.1, 0.5);+    let n = (radius / step).ceil() as i64;+    let mut cands: Vec<(f64, (f64, f64))> = Vec::new();+    for i in -n..=n {+        for j in -n..=n {+            let c = (centre.0 + i as f64 * step, centre.1 + j as f64 * step);+            let d = dist(c, centre);+            if d <= radius {+                cands.push((d, c));+            }+        }+    }+    cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));+    let spread = ((dx1 - dx0).max(dy1 - dy0) / 2.0).max(step * 2.0);+    let mut spots: Vec<(f64, (f64, f64))> = Vec::new();+    for (d, c) in cands {+        if spots.len() >= count {+            break;+        }+        if spots.iter().any(|s| dist(s.1, c) < spread) {+            continue;+        }+        if free(c) {+            spots.push((d, c));+        }+    }+    let rot = me.map(|p| p.rotation).unwrap_or(0.0);+    json!({"success": true, "part": me.map(|p| p.reference.clone()), "side": if side == "B.Cu" { "bottom" } else { "top" },+        "near": {"x": r3(centre.0), "y": r3(fy(centre.1))}, "sizeMm": [r3(dx1 - dx0), r3(dy1 - dy0)],+        "spots": spots.iter().map(|(d, c)| {+            let (ya, yb) = (fy(c.1 + dy0), fy(c.1 + dy1));+            json!({"x": r3(c.0), "y": r3(fy(c.1)), "rotation": rot, "distanceMm": r3(*d), "extent": [r3(c.0 + dx0), r3(ya.min(yb)), r3(c.0 + dx1), r3(ya.max(yb))]})+        }).collect::<Vec<_>>(),+        "searched": {"radiusMm": radius, "stepMm": r3(step), "clearanceMm": clearance, "edgeMm": edge, "copper": copper}})+}++// ---------------------------------------------------------------------------+// height_check+// ---------------------------------------------------------------------------++/// Will it fit: heights from the bodies companion (kicad_bodies), limits over the whole board or+/// a region, and bodies that collide or come closer than `minGapMm`.+pub fn height_check(bodies: &Value, args: &Value) -> Value {+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25) as usize;+    let min_gap = args.get("minGapMm").and_then(Value::as_f64).unwrap_or(0.0);+    let all: Vec<&Value> = arr(bodies, "bodies").collect();+    // one row per part: its tallest body and the box round all of them+    let mut parts: BTreeMap<(String, String), (f64, [f64; 4])> = BTreeMap::new();+    for b in &all {+        let k = (txt(&b["ref"]).to_string(), txt(&b["side"]).to_string());+        let r = [f(&b["x0"]), f(&b["y0"]), f(&b["x1"]), f(&b["y1"])];+        let e = parts.entry(k).or_insert((0.0, r));+        e.0 = e.0.max(f(&b["heightMm"]));+        e.1 = [e.1[0].min(r[0]), e.1[1].min(r[1]), e.1[2].max(r[2]), e.1[3].max(r[3])];+    }+    let mut heights: Vec<Value> = parts.iter().map(|((r, s), (h, b))| json!({"ref": r, "side": s, "heightMm": r3(*h), "rect": b.iter().map(|v| r3(*v)).collect::<Vec<_>>()})).collect();+    heights.sort_by(|a, b| f(&b["heightMm"]).partial_cmp(&f(&a["heightMm"])).unwrap_or(std::cmp::Ordering::Equal));+    let mut over = Vec::new();+    for ((r, s), (h, _)) in &parts {+        let lim = if s == "bottom" { args.get("maxBottomMm") } else { args.get("maxTopMm") }.and_then(Value::as_f64);+        if let Some(l) = lim {+            if *h > l + 1e-9 {+                over.push(json!({"ref": r, "side": s, "heightMm": r3(*h), "limitMm": l, "overByMm": r3(h - l)}));+            }+        }+    }+    let mut region_rows = Vec::new();+    for (i, g) in arr(args, "regions").enumerate() {+        let rect: Vec<f64> = arr(g, "rect").map(f).collect();+        if rect.len() != 4 {+            continue;+        }+        let (x0, x1, y0, y1) = (rect[0].min(rect[2]), rect[0].max(rect[2]), rect[1].min(rect[3]), rect[1].max(rect[3]));+        let side = g["side"].as_str().unwrap_or("top");+        let max = f(&g["maxMm"]);+        let name = g["name"].as_str().map(String::from).unwrap_or_else(|| format!("region {}", i + 1));+        for ((r, s), (h, b)) in &parts {+            if s == side && b[0] < x1 && b[2] > x0 && b[1] < y1 && b[3] > y0 && *h > max + 1e-9 {+                region_rows.push(json!({"region": name, "ref": r, "side": s, "heightMm": r3(*h), "maxMm": max}));+            }+        }+    }+    let mut collisions = Vec::new();+    let mut too_close = Vec::new();+    for i in 0..all.len() {+        for j in (i + 1)..all.len() {+            let (a, b) = (all[i], all[j]);+            if a["ref"] == b["ref"] || a["side"] != b["side"] {+                continue;+            }+            let ox = f(&a["x1"]).min(f(&b["x1"])) - f(&a["x0"]).max(f(&b["x0"]));+            let oy = f(&a["y1"]).min(f(&b["y1"])) - f(&a["y0"]).max(f(&b["y0"]));+            let oz = f(&a["z1"]).min(f(&b["z1"])) - f(&a["z0"]).max(f(&b["z0"]));+            // apart in plan by more than the gap, or one above the other: no collision+            let gap = -ox.min(oy);+            if gap >= min_gap || (gap < 0.0 && oz <= 0.0) {+                continue;+            }+            let at = [r3((f(&a["x0"]).max(f(&b["x0"])) + f(&a["x1"]).min(f(&b["x1"]))) / 2.0), r3((f(&a["y0"]).max(f(&b["y0"])) + f(&a["y1"]).min(f(&b["y1"]))) / 2.0)];+            if gap < 0.0 {+                collisions.push(json!({"parts": [a["ref"], b["ref"]], "side": a["side"], "overlapMm": r3(-gap), "at": at}));+            } else {+                too_close.push(json!({"parts": [a["ref"], b["ref"]], "side": a["side"], "gapMm": r3(gap), "at": at}));+            }+        }+    }+    collisions.dedup_by(|a, b| a["parts"] == b["parts"]);+    too_close.dedup_by(|a, b| a["parts"] == b["parts"]);+    let tallest = |side: &str| heights.iter().find(|h| h["side"] == side).map(|h| json!({"ref": h["ref"], "heightMm": h["heightMm"]})).unwrap_or(Value::Null);+    let tallest = json!({"top": tallest("top"), "bottom": tallest("bottom")});+    let no_body: Vec<Value> = arr(bodies, "noBody").map(|b| b["ref"].clone()).collect();+    let ok = over.is_empty() && region_rows.is_empty() && collisions.is_empty() && too_close.is_empty();+    heights.truncate(limit);+    json!({"success": true, "ok": ok, "heights": heights, "tallest": tallest, "overLimit": over, "regionHits": region_rows, "collisions": collisions, "tooClose": too_close, "noBody": no_body,+        "limits": {"maxTopMm": args.get("maxTopMm"), "maxBottomMm": args.get("maxBottomMm"), "minGapMm": min_gap}})+}++// ---------------------------------------------------------------------------+// thermal_vias+// ---------------------------------------------------------------------------++/// The plan for a via grid in a part's exposed pad: centred, inset from the pad's edge, on the+/// pad's net. Points are in the model's frame.+pub fn thermal_vias(model: &Value, args: &Value, size: f64, drill: f64) -> Result<Value, (String, String)> {+    let r = args.get("ref").or_else(|| args.get("part")).and_then(Value::as_str).ok_or(("missing_arg".to_string(), "ref names the part (U3)".to_string()))?;+    let pads: Vec<&Value> = arr(model, "pads").filter(|p| txt(&p["ref"]).eq_ignore_ascii_case(r)).collect();+    if pads.is_empty() {+        return Err(("no_such_part".into(), format!("{r} is not on this board, or has no pads")));+    }+    let pad = match args.get("pad").and_then(Value::as_str) {+        Some(n) => *pads.iter().find(|p| txt(&p["name"]) == n).ok_or(("no_such_pad".to_string(), format!("{r} has no pad {n}")))?,+        None => *pads.iter().max_by(|a, b| (f(&a["sizeX"]) * f(&a["sizeY"])).partial_cmp(&(f(&b["sizeX"]) * f(&b["sizeY"]))).unwrap_or(std::cmp::Ordering::Equal)).unwrap(),+    };+    let net = txt(&pad["net"]).to_string();+    if net.is_empty() {+        return Err(("pad_without_net".into(), format!("{r}.{} is on no net: a via there would be an island", txt(&pad["name"]))));+    }+    let inset = args.get("insetMm").and_then(Value::as_f64).unwrap_or(0.1);+    let pitch = args.get("pitchMm").and_then(Value::as_f64).unwrap_or((size + 0.5).max(1.0));+    let (w, h) = (f(&pad["sizeX"]), f(&pad["sizeY"]));+    let (hx, hy) = (w / 2.0 - inset - size / 2.0, h / 2.0 - inset - size / 2.0);+    if hx < 0.0 || hy < 0.0 {+        return Err(("pad_too_small".into(), format!("{r}.{} is {w} x {h} mm: a {size} mm via inset {inset} mm does not fit", txt(&pad["name"]))));+    }+    let round = matches!(txt(&pad["shape"]), "circle" | "oval");+    let axis = |half: f64| -> Vec<f64> {+        let n = (2.0 * half / pitch).floor() as i64 + 1;+        (0..n).map(|i| (i as f64 - (n - 1) as f64 / 2.0) * pitch).collect()+    };+    let (cx, cy) = (f(&pad["x"]), f(&pad["y"]));+    let (s, c) = f(&pad["rotation"]).to_radians().sin_cos();+    let s = if model["frame"] == "kicad" { -s } else { s };+    let existing: Vec<(f64, f64)> = arr(model, "vias").map(|v| xy(&v["position"])).collect();+    let mut points = Vec::new();+    let mut skipped = 0;+    for x in axis(hx) {+        for y in axis(hy) {+            if round && (x / (hx + 1e-9)).powi(2) + (y / (hy + 1e-9)).powi(2) > 1.0 + 1e-9 {+                continue;+            }+            let p = (cx + x * c - y * s, cy + x * s + y * c);+            if existing.iter().any(|e| dist(*e, p) < size) {+                skipped += 1;+                continue;+            }+            points.push(p);+        }+    }+    Ok(json!({"part": r, "pad": pad["name"], "net": net, "padMm": [w, h], "vias": points.len(), "pitchMm": r3(pitch), "insetMm": inset,+        "via": {"size": size, "drill": drill}, "points": points.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>(), "skippedForExistingVias": skipped}))+}++// ---------------------------------------------------------------------------+// via_fence+// ---------------------------------------------------------------------------++/// Distance from `p` to a pad's copper: its rectangle (turned by its rotation) or circle.+fn pad_distance(p: (f64, f64), pad: &Value, y_down: bool) -> f64 {+    let c = (f(&pad["x"]), f(&pad["y"]));+    let (w, h) = (f(&pad["sizeX"]), f(&pad["sizeY"]));+    if txt(&pad["shape"]) == "circle" {+        return dist(p, c) - w.max(h) / 2.0;+    }+    let (s, co) = f(&pad["rotation"]).to_radians().sin_cos();+    let s = if y_down { -s } else { s };+    // into the pad's own frame (the inverse turn)+    let (dx, dy) = (p.0 - c.0, p.1 - c.1);+    let (lx, ly) = (dx * co + dy * s, -dx * s + dy * co);+    let (ox, oy) = ((lx.abs() - w / 2.0).max(0.0), (ly.abs() - h / 2.0).max(0.0));+    (ox * ox + oy * oy).sqrt()+}++/// Points every `pitch` along a polyline (its own vertices kept), with the unit normal there.+fn walk(points: &[(f64, f64)], pitch: f64, closed: bool) -> Vec<((f64, f64), (f64, f64))> {+    let n = points.len();+    let edges = if closed { n } else { n.saturating_sub(1) };+    let mut out = Vec::new();+    for i in 0..edges {+        let (a, b) = (points[i], points[(i + 1) % n]);+        let len = dist(a, b);+        if len < 1e-9 {+            continue;+        }+        let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+        let steps = (len / pitch).floor() as usize;+        for k in 0..=steps {+            let t = k as f64 * pitch;+            out.push(((a.0 + ux * t, a.1 + uy * t), (-uy, ux)));+        }+    }+    out+}++/// The plan for a via fence: ground vias on both sides of the named nets' tracks, and/or round+/// the board edge, at a pitch (lambda/20 in FR-4 when freqGHz is given, capped by pitchMm), each+/// clear of other copper, pads, keepouts that forbid vias, other vias and the edge.+pub fn via_fence(model: &Value, args: &Value) -> Result<Value, (String, String)> {+    let via_net = args.get("viaNet").and_then(Value::as_str).ok_or(("missing_arg".to_string(), "viaNet names the net the vias tie to (usually GND)".to_string()))?;+    let board_nets: Vec<String> = arr(model, "nets").filter_map(|n| n.as_str().or(n["name"].as_str()).map(String::from)).collect();+    let via_net = board_nets.iter().find(|n| n.eq_ignore_ascii_case(via_net)).cloned().ok_or(("no_such_net".to_string(), format!("{via_net} is not a net on this board")))?;+    let fenced: Vec<String> = arr(args, "nets").filter_map(Value::as_str).map(|n| board_nets.iter().find(|b| b.eq_ignore_ascii_case(n)).cloned().ok_or(n.to_string())).collect::<Result<_, _>>()+        .map_err(|n| ("no_such_net".to_string(), format!("{n} is not a net on this board")))?;+    let edge = args.get("edge").and_then(Value::as_bool).unwrap_or(false);+    if fenced.is_empty() && !edge {+        return Err(("bad_args".into(), "name the nets to fence (nets: [\"ANT_RF\"]) and/or edge: true".into()));+    }+    let size = args.get("size").and_then(Value::as_f64).unwrap_or(0.6);+    let drill = args.get("drill").and_then(Value::as_f64).unwrap_or(0.3);+    let clearance = args.get("clearance").and_then(Value::as_f64).unwrap_or(0.2);+    let rv = size / 2.0;+    let cap = args.get("pitchMm").and_then(Value::as_f64).unwrap_or(2.0);+    // lambda/20 in FR-4 (er 4.4): 300 mm/ns / sqrt(4.4) / f / 20+    let pitch = match args.get("freqGHz").and_then(Value::as_f64) {+        Some(g) if g > 0.0 => (300.0 / 4.4_f64.sqrt() / g / 20.0).min(cap),+        _ => cap,+    };+    if pitch < size + clearance {+        return Err(("pitch_too_small".into(), format!("a pitch of {} mm puts {size} mm vias closer than their clearance", r3(pitch))));+    }+    let y_down = model["frame"] == "kicad";+    let tracks: Vec<(String, (f64, f64), (f64, f64), f64)> = arr(model, "tracks").map(|t| (txt(&t["net"]).to_string(), xy(&t["start"]), xy(&t["end"]), f(&t["width"]))).collect();+    // arcs as chords, for clearance and for walking+    let mut arcs: Vec<(String, (f64, f64), (f64, f64), f64)> = Vec::new();+    for a in arr(model, "arcs") {+        let (c, r) = (xy(&a["center"]), f(&a["radius"]));+        let (s0, sweep) = (f(&a["startAngle"]), (f(&a["endAngle"]) - f(&a["startAngle"])).rem_euclid(360.0));+        let n = ((sweep / 10.0).ceil() as usize).max(1);+        let pt = |deg: f64| { let t = deg.to_radians(); (c.0 + r * t.cos(), c.1 + if y_down { -r * t.sin() } else { r * t.sin() }) };+        for k in 0..n {+            arcs.push((txt(&a["net"]).to_string(), pt(s0 + sweep * k as f64 / n as f64), pt(s0 + sweep * (k + 1) as f64 / n as f64), f(&a["width"])));+        }+    }+    let copper: Vec<&(String, (f64, f64), (f64, f64), f64)> = tracks.iter().chain(arcs.iter()).collect();+    let widest = copper.iter().filter(|t| fenced.contains(&t.0)).map(|t| t.3).fold(0.0, f64::max);+    let offset = args.get("offsetMm").and_then(Value::as_f64).unwrap_or(widest / 2.0 + clearance + rv + 0.1);+    let inset = args.get("edgeInsetMm").and_then(Value::as_f64).unwrap_or(rv + 0.5);+    let outline: Vec<(f64, f64)> = arr(model, "outline").map(xy).collect();+    if outline.len() < 3 {+        return Err(("no_outline".into(), "the board has no closed outline on Edge.Cuts".into()));+    }+    let outline_edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len()])).collect();+    let pads: Vec<&Value> = arr(model, "pads").collect();+    let vias: Vec<((f64, f64), f64)> = arr(model, "vias").map(|v| (xy(&v["position"]), f(&v["size"]) / 2.0)).collect();+    let no_via_zones: Vec<Vec<(f64, f64)>> = arr(model, "keepouts").filter(|k| k["rules"]["vias"] == "not_allowed").map(|k| arr(k, "points").map(xy).collect()).collect();+    // candidates: both sides of every fenced track, then the edge+    // each candidate knows its row (one side of one track, or the edge): the pitch spaces a row,+    // plain clearance spaces one row from another+    let mut cands: Vec<((f64, f64), &str, usize)> = Vec::new();+    for (i, t) in copper.iter().filter(|t| fenced.contains(&t.0)).enumerate() {+        for (p, nrm) in walk(&[t.1, t.2], pitch, false) {+            for (k, sgn) in [1.0, -1.0].into_iter().enumerate() {+                cands.push(((p.0 + sgn * offset * nrm.0, p.1 + sgn * offset * nrm.1), "track", 2 * i + k));+            }+        }+    }+    if edge {+        let area: f64 = outline_edges.iter().map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum::<f64>() / 2.0;+        // the left normal points inward on a counterclockwise outline+        let inward = if area > 0.0 { 1.0 } else { -1.0 };+        for (p, nrm) in walk(&outline, pitch, true) {+            cands.push(((p.0 + inward * inset * nrm.0, p.1 + inward * inset * nrm.1), "edge", usize::MAX));+        }+    }+    let total = cands.len();+    let (mut outside, mut crowded, mut blocked) = (0, 0, 0);+    let mut placed: Vec<((f64, f64), &str, usize)> = Vec::new();+    let (apart, along) = (size + clearance, (size + clearance).max(0.9 * pitch));+    for (p, kind, row) in cands {+        let edge_min = if kind == "edge" { inset - 1e-6 } else { rv + 0.3 };+        if !inside_polygon(p, &outline) || outline_edges.iter().any(|(a, b)| point_seg(p, *a, *b) < edge_min) || no_via_zones.iter().any(|z| inside_polygon(p, z)) {+            outside += 1;+            continue;+        }+        if placed.iter().any(|q| dist(q.0, p) < if q.2 == row { along } else { apart }) || vias.iter().any(|(q, r)| dist(*q, p) < r + rv + clearance) {+            crowded += 1;+            continue;+        }+        let hits_copper = copper.iter().any(|t| t.0 != via_net && point_seg(p, t.1, t.2) < t.3 / 2.0 + clearance + rv)+            || pads.iter().any(|pd| pad_distance(p, pd, y_down) < clearance + rv);+        if hits_copper {+            blocked += 1;+            continue;+        }+        placed.push((p, kind, row));+    }+    let edge_count = placed.iter().filter(|p| p.1 == "edge").count();+    Ok(json!({"viaNet": via_net, "nets": fenced, "edge": edge, "vias": placed.len(), "alongTracks": placed.len() - edge_count, "alongEdge": edge_count,+        "candidates": total, "skipped": {"outsideOrKeepout": outside, "tooCloseToAnotherVia": crowded, "tooCloseToCopperOrPads": blocked},+        "pitchMm": r3(pitch), "offsetMm": r3(offset), "edgeInsetMm": r3(inset), "via": {"size": size, "drill": drill}, "clearanceMm": clearance,+        "points": placed.iter().map(|(p, _, _)| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>()}))+}++// ---------------------------------------------------------------------------+// auto_classes+// ---------------------------------------------------------------------------++/// The last part of a hierarchical net name, upper case (`/power/+3V3` is `+3V3`).+fn leaf(net: &str) -> String {+    net.rsplit('/').next().unwrap_or(net).to_ascii_uppercase()+}++fn tokens(n: &str) -> Vec<String> {+    n.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(String::from).collect()+}++/// The class a net's name says it belongs to: GND, Power, Clock or HighSpeed.+pub fn class_of_net(net: &str) -> Option<&'static str> {+    let n = leaf(net);+    let t = tokens(&n);+    let has = |words: &[&str]| t.iter().any(|x| words.contains(&x.as_str()));+    let starts = |pre: &[&str]| t.iter().any(|x| pre.iter().any(|p| x.starts_with(p)));+    if n.starts_with("UNCONNECTED") || n.starts_with("NET-(") {+        return None;+    }+    if has(&["GND", "AGND", "DGND", "PGND", "SGND", "CGND", "GNDA", "GNDD", "GNDPWR", "VSS", "VSSA", "EARTH", "0V"]) || t.iter().any(|x| x.starts_with("GND") && x[3..].chars().all(|c| c.is_ascii_digit())) {+        return Some("GND");+    }+    let volts = |x: &str| {+        // 3V3, 5V, 12V, 1V8, V3V3: a number with a V in it+        let x = x.strip_prefix('V').filter(|r| r.starts_with(|c: char| c.is_ascii_digit())).unwrap_or(x);+        x.starts_with(|c: char| c.is_ascii_digit()) && x.contains('V') && x.chars().all(|c| c.is_ascii_digit() || c == 'V')+    };+    if n.starts_with(['+', '-']) && n[1..].starts_with(|c: char| c.is_ascii_digit()) || t.iter().any(|x| volts(x))+        || starts(&["VCC", "VDD", "VEE", "VBAT", "VIN", "VBUS", "VSYS", "VOUT", "VREG", "VMOT", "VPP", "VIO", "AVDD", "DVDD", "AVCC", "DVCC", "PVDD", "VCORE", "VLED"])+        || has(&["VM", "PWR", "POWER", "VSUP", "VS", "VDC"])+    {+        return Some("Power");+    }+    if has(&["USB", "ETH", "RGMII", "RMII", "MDI", "HDMI", "PCIE", "DDR", "DQS", "MIPI", "CSI", "DSI", "LVDS", "SATA", "SERDES", "TMDS"])+        || starts(&["USB", "ETH", "HDMI", "PCIE", "DDR", "MIPI", "LVDS", "SATA", "RGMII", "TMDS"])+        || t.iter().any(|x| x.starts_with("DQ") && x[2..].chars().all(|c| c.is_ascii_digit()) && x.len() > 2)+    {+        return Some("HighSpeed");+    }+    if has(&["CLK", "SCK", "SCLK", "MCLK", "BCLK", "LRCLK", "XTAL", "XTAL1", "XTAL2", "OSC", "XIN", "XOUT", "XI", "XO", "REFCLK", "HSE", "LSE"])+        || t.iter().any(|x| x.ends_with("CLK") || x.starts_with("CLK") || x.starts_with("OSC") || x.starts_with("XTAL"))+    {+        return Some("Clock");+    }+    None+}++/// The proposal: the nets of each class, the pairs, and what is left.+pub fn auto_classes(nets: &[String], pairs: &[(String, String, String)]) -> Value {+    let mut classes: BTreeMap<&str, Vec<String>> = ["GND", "Power", "Clock", "HighSpeed"].iter().map(|c| (*c, Vec::new())).collect();+    let mut rest = Vec::new();+    let paired = |n: &str| pairs.iter().any(|(_, p, m)| p == n || m == n);+    for n in nets {+        if n.is_empty() || n.to_ascii_lowercase().starts_with("unconnected-") {+            continue;+        }+        // a differential pair the names spell is high speed whatever else its name says (USB's bare D+/D-)+        match class_of_net(n).or_else(|| paired(n).then_some("HighSpeed")) {+            Some(c) => classes.get_mut(c).unwrap().push(n.clone()),+            None => rest.push(n.clone()),+        }+    }+    json!({"classes": classes, "pairs": pairs.iter().map(|(n, p, m)| json!({"name": n, "pos": p, "neg": m})).collect::<Vec<_>>(), "unclassified": rest})+}++// ---------------------------------------------------------------------------+// pin_pad_check+// ---------------------------------------------------------------------------++/// Pads that are expected to have no pin: mounting, shield, exposed and thermal pads.+fn exempt_pad(name: &str) -> bool {+    let u = name.to_ascii_uppercase();+    u.is_empty() || u == "0" || ["MH", "MP", "SH", "EP", "PAD", "TAB"].iter().any(|p| u.starts_with(p))+}++/// Symbol pins (from the netlist: every pin of every symbol, connected or not) against footprint+/// pads (from the board), part by part.+pub fn pin_pad_check(board: &Board, text: &str, nl: &Netlist) -> Value {+    let mut pins: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for c in &nl.components {+        pins.entry(c.reference.clone()).or_default();+    }+    for n in &nl.nets {+        for node in &n.nodes {+            pins.entry(node.reference.clone()).or_default().push(node.pin.clone());+        }+    }+    let mut mismatches = Vec::new();+    let mut matching = 0;+    let mut on_board_only = Vec::new();+    let mut seen: Vec<&str> = Vec::new();+    for fp in &board.footprints {+        let r = fp.reference.as_str();+        if r.is_empty() || seen.contains(&r) {+            continue;+        }+        seen.push(r);+        let Some(p) = pins.get(r) else {+            let board_only = crate::pcb_sync::footprint_span(text, r).map(|(s, e)| text[s..e].contains("board_only")).unwrap_or(false);+            if !board_only {+                on_board_only.push(json!(r));+            }+            continue;+        };+        let mut pads: Vec<String> = fp.pads.iter().map(|p| p.number.clone()).collect();+        pads.sort_by_key(|p| placement::ref_key(p));+        pads.dedup();+        let mut pins_sorted = p.clone();+        pins_sorted.sort_by_key(|p| placement::ref_key(p));+        pins_sorted.dedup();+        let without_pad: Vec<&String> = pins_sorted.iter().filter(|x| !pads.contains(x)).collect();+        let without_pin: Vec<&String> = pads.iter().filter(|x| !pins_sorted.contains(x) && !exempt_pad(x)).collect();+        if without_pad.is_empty() && without_pin.is_empty() {+            matching += 1;+        } else {+            mismatches.push(json!({"ref": r, "footprint": fp.footprint, "pinsWithoutPad": without_pad, "padsWithoutPin": without_pin, "pins": pins_sorted, "pads": pads}));+        }+    }+    let on_sheets_only: Vec<&String> = pins.keys().filter(|r| !seen.contains(&r.as_str())).collect();+    let ok = mismatches.iter().all(|m| m["pinsWithoutPad"].as_array().map_or(true, |a| a.is_empty()));+    json!({"success": true, "ok": ok, "matching": matching, "mismatches": mismatches, "onBoardOnly": on_board_only, "onSheetsOnly": on_sheets_only})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn lengths_add_tracks_and_arcs_by_layer() {+        let m = json!({"nets": ["A", "B", "C"],+            "tracks": [{"net": "A", "layer": "F.Cu", "start": {"x": 0, "y": 0}, "end": {"x": 3, "y": 4}}, {"net": "B", "layer": "B.Cu", "start": [0, 0], "end": [0, 2]}],+            "arcs": [{"net": "A", "layer": "B.Cu", "radius": 2.0, "startAngle": 350.0, "endAngle": 80.0}],+            "vias": [{"net": "A"}]});+        let v = net_lengths(&m, &json!({"groups": [{"nets": ["A", "B"], "toleranceMm": 1.0}]}), &[("AB".into(), "A".into(), "B".into())]);+        let a = &v["nets"][0];+        assert_eq!(a["net"], "A");+        // 5 mm straight plus a quarter circle of radius 2 (sweep 350 -> 80 is 90 degrees)+        assert!((f(&a["lengthMm"]) - (5.0 + std::f64::consts::PI)).abs() < 0.01, "{v}");+        assert_eq!(a["vias"], 1);+        assert_eq!(v["pairs"][0]["skewMm"], r3(5.0 + std::f64::consts::PI - 2.0));+        assert_eq!(v["groups"][0]["withinTolerance"], false);+        assert_eq!(v["groups"][0]["longest"], "A");+        let named = net_lengths(&m, &json!({"nets": ["c", "nope"]}), &[]);+        assert_eq!(named["unroutedNets"], json!(["C"]));+        assert_eq!(named["unknownNets"], json!(["nope"]));+    }++    #[test]+    fn a_turned_resistor_is_suggested_when_it_shortens_the_airline() {+        // R1's pads sit on x; its nets go up and down from them: turned 90 they line up+        let m = json!({"frame": "standard", "components": [{"ref": "R1", "x": 0, "y": 0, "rotation": 0}],+            "pads": [{"ref": "R1", "net": "A", "x": -1, "y": 0}, {"ref": "R1", "net": "B", "x": 1, "y": 0},+                     {"ref": "U1", "net": "A", "x": 0, "y": 10}, {"ref": "U2", "net": "B", "x": 0, "y": -10}]});+        let v = check_orientation(&m, &json!({}));+        assert_eq!(v["checked"], 1);+        let s = &v["suggestions"][0];+        assert!(f(&s["savesMm"]) > 1.0, "{v}");+        assert!(s["suggested"] == json!(90.0) || s["suggested"] == json!(270.0), "{v}");+    }++    #[test]+    fn classes_come_from_the_names() {+        for (n, c) in [("GND", Some("GND")), ("/power/+3V3", Some("Power")), ("VBAT", Some("Power")), ("+12V", Some("Power")), ("USB_D+", Some("HighSpeed")),+                       ("SPI_SCK", Some("Clock")), ("XTAL_IN", Some("Clock")), ("DDR_DQ3", Some("HighSpeed")), ("LED1", None), ("/DRV_SHA", None), ("AGND", Some("GND"))] {+            assert_eq!(class_of_net(n), c, "{n}");+        }+    }++    #[test]+    fn heights_limits_regions_and_collisions() {+        let b = json!({"bodies": [+            {"ref": "J1", "side": "top", "x0": 0, "y0": 0, "x1": 10, "y1": 5, "z0": 0, "z1": 11, "heightMm": 11},+            {"ref": "C1", "side": "top", "x0": 9, "y0": 1, "x1": 12, "y1": 3, "z0": 0, "z1": 2, "heightMm": 2},+            {"ref": "U1", "side": "top", "x0": 20, "y0": 0, "x1": 25, "y1": 5, "z0": 0, "z1": 1, "heightMm": 1}],+            "noBody": [{"ref": "R9", "why": "no 3D model"}]});+        let v = height_check(&b, &json!({"maxTopMm": 10, "regions": [{"rect": [15, -1, 30, 6], "maxMm": 0.5, "name": "lid rib"}], "minGapMm": 0}));+        assert_eq!(v["overLimit"][0]["ref"], "J1");+        assert_eq!(v["regionHits"][0]["ref"], "U1");+        assert_eq!(v["overLimit"][0]["overByMm"], 1.0);+        assert_eq!(v["tallest"]["top"]["ref"], "J1");+        assert_eq!(v["collisions"][0]["parts"], json!(["J1", "C1"]));+        assert_eq!(v["noBody"], json!(["R9"]));+        assert_eq!(v["ok"], false);+        let gap = height_check(&b, &json!({"minGapMm": 9}));+        assert!(gap["tooClose"].as_array().unwrap().iter().any(|c| c["parts"] == json!(["C1", "U1"]) && c["gapMm"] == json!(8.0)), "{gap}");+    }++    #[test]+    fn thermal_grid_fits_the_pad_and_turns_with_it() {+        let m = json!({"frame": "standard", "pads": [{"ref": "U3", "name": "1", "net": "X", "x": 0, "y": 0, "sizeX": 0.3, "sizeY": 0.8, "rotation": 0, "shape": "rect"},+            {"ref": "U3", "name": "17", "net": "GND", "x": 10, "y": 5, "sizeX": 3.3, "sizeY": 2.2, "rotation": 90, "shape": "rect"}], "vias": []});+        let v = thermal_vias(&m, &json!({"ref": "U3", "pitchMm": 1.0}), 0.5, 0.25).unwrap();+        assert_eq!(v["pad"], "17");+        assert_eq!(v["net"], "GND");+        // 3.3 x 2.2 less inset and via: 3 along x, 2 along y; turned 90, x spreads along y+        assert_eq!(v["vias"], 6);+        let pts = v["points"].as_array().unwrap();+        let ys: Vec<f64> = pts.iter().map(|p| f(&p[1])).collect();+        assert!(ys.iter().cloned().fold(f64::MIN, f64::max) - ys.iter().cloned().fold(f64::MAX, f64::min) > 1.9, "{v}");+        assert_eq!(thermal_vias(&m, &json!({"ref": "U3", "pad": "1"}), 0.6, 0.3).unwrap_err().0, "pad_too_small");+    }++    #[test]+    fn a_fence_lines_both_sides_and_the_edge_and_keeps_clear() {+        let m = json!({"frame": "standard", "nets": ["RF", "GND", "X"], "outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+            "tracks": [{"net": "RF", "layer": "F.Cu", "start": {"x": 2, "y": 5}, "end": {"x": 18, "y": 5}, "width": 0.4},+                       {"net": "X", "layer": "B.Cu", "start": {"x": 10, "y": 0.5}, "end": {"x": 10, "y": 9.5}, "width": 0.3}],+            "pads": [], "vias": [], "arcs": []});+        let v = via_fence(&m, &json!({"viaNet": "gnd", "nets": ["RF"], "pitchMm": 2})).unwrap();+        let pts: Vec<(f64, f64)> = v["points"].as_array().unwrap().iter().map(|p| (f(&p[0]), f(&p[1]))).collect();+        assert!(v["vias"].as_u64().unwrap() >= 12, "{v}");+        assert!(pts.iter().any(|p| p.1 > 5.5) && pts.iter().any(|p| p.1 < 4.5), "{v}");+        // none on the crossing net X's track at x = 10+        assert!(pts.iter().all(|p| (p.0 - 10.0).abs() > 0.15 + 0.2 + 0.3 - 1e-9), "{v}");+        assert_eq!(v["viaNet"], "GND");+        let e = via_fence(&m, &json!({"viaNet": "GND", "edge": true, "pitchMm": 2})).unwrap();+        // 60 mm of edge at 2 mm: the corners and the crossing track X take a few spots+        assert!(e["alongEdge"].as_u64().unwrap() >= 18, "{e}");+        let ep: Vec<(f64, f64)> = e["points"].as_array().unwrap().iter().map(|p| (f(&p[0]), f(&p[1]))).collect();+        assert!(ep.iter().all(|p| p.0 >= 0.79 && p.0 <= 19.21 && p.1 >= 0.79 && p.1 <= 9.21), "{e}");+        assert_eq!(via_fence(&m, &json!({"viaNet": "GND"})).unwrap_err().0, "bad_args");+        // lambda/20 in FR-4: 0.89 mm at 8 GHz; at 10 GHz (0.72 mm) 0.6 mm vias no longer keep their clearance+        assert!((via_fence(&m, &json!({"viaNet": "GND", "nets": ["RF"], "freqGHz": 8})).unwrap()["pitchMm"].as_f64().unwrap() - 0.894).abs() < 0.01);+        assert_eq!(via_fence(&m, &json!({"viaNet": "GND", "nets": ["RF"], "freqGHz": 10})).unwrap_err().0, "pitch_too_small");+    }++    #[test]+    fn rect_and_segment_distance() {+        let r = Rect { min_x: 0.0, min_y: 0.0, max_x: 2.0, max_y: 1.0 };+        assert_eq!(seg_rect((-1.0, 0.5), (3.0, 0.5), &r), 0.0);+        assert!((seg_rect((0.0, 3.0), (2.0, 3.0), &r) - 2.0).abs() < 1e-9);+        assert!(inside_polygon((1.0, 1.0), &[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]));+    }+}
rust/crates/kicad-core/src/board_reads.rs+73−4
@@ -105,6 +105,9 @@ fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> { pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {     let r = root(text)?;     let mut parts = Vec::new();+    // the standard's shape beside KiCad's: components, and every silkscreen line segment (footprints' own included)+    let mut components = Vec::new();+    let mut lines = Vec::new();     for fp in r.children().iter().filter(|c| c.head() == Some("footprint")) {         let at = fp.child("at");         let (x, y, rot) = (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0));@@ -132,6 +135,19 @@ pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {             let (ay, cy) = (a[1].as_f64().unwrap_or(0.0), c[1].as_f64().unwrap_or(0.0));             json!([a[0], ay.min(cy), c[0], ay.max(cy)])         });+        for g in fp.children().iter().filter(|c| c.head().map(|h| h.starts_with("fp_")).unwrap_or(false) && c.head() != Some("fp_text") && on_silk(c)) {+            let pts: Vec<(f64, f64)> = segment_points(g).into_iter().map(|p| to_board((x, y), rot, p)).collect();+            push_lines(&mut lines, g, &pts, y_up, "footprint");+        }+        let std_field = |f: Option<&Node>| f.map(|f| {+            let v = field(f, (x, y), rot, y_up);+            json!({"text": v["text"], "x": v["at"][0], "y": v["at"][1], "size": v["heightMm"], "rotation": v["rotation"], "shown": v["shown"]})+        });+        components.push(json!({+            "ref": prop("Reference").and_then(|f| f.arg(1)), "pattern": fp.arg(0), "x": out((x, y), y_up)[0], "y": out((x, y), y_up)[1], "rotation": rot,+            "side": if side.starts_with('B') { "bottom" } else { "top" }, "body": bx,+            "name": std_field(prop("Reference")), "comment": std_field(prop("Value")),+        }));         parts.push(json!({             "ref": prop("Reference").and_then(|f| f.arg(1)),             "side": if side.starts_with('B') { "bottom" } else { "top" },@@ -150,13 +166,17 @@ pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {             Some("gr_text") => {                 let at = c.child("at");                 let size = c.child("effects").and_then(|e| e.child("font")).and_then(|f| f.child("size"));+                let at_v = out(at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0)), y_up);+                let layer = c.child_value("layer");                 texts.push(json!({-                    "id": c.child_value("uuid"), "text": c.arg(0), "layer": c.child_value("layer"),-                    "at": out(at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0)), y_up),-                    "rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0), "heightMm": size.and_then(|s| s.num(0)),+                    "id": c.child_value("uuid"), "text": c.arg(0), "layer": layer,+                    "at": at_v, "rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0), "heightMm": size.and_then(|s| s.num(0)),+                    // the standard's names for the same+                    "side": if layer.as_deref() == Some("B.SilkS") { "bottom" } else { "top" }, "x": at_v[0], "y": at_v[1], "size": size.and_then(|s| s.num(0)),                 }));             }             Some(h) if h.starts_with("gr_") => {+                push_lines(&mut lines, c, &segment_points(c), y_up, "board");                 graphics.push(json!({                     "id": c.child_value("uuid"), "kind": h.trim_start_matches("gr_"), "layer": c.child_value("layer"),                     "points": graphic_points(c).into_iter().map(|p| out(p, y_up)).collect::<Vec<_>>(),@@ -167,7 +187,45 @@ pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {         }     }     Ok(json!({"frame": if y_up { "standard" } else { "kicad" }, "parts": parts, "texts": texts, "graphics": graphics,-              "counts": {"parts": parts.len(), "texts": texts.len(), "graphics": graphics.len()}}))+              "components": components, "lines": lines,+              "counts": {"parts": parts.len(), "texts": texts.len(), "graphics": graphics.len(), "lines": lines.len()}}))+}++/// A silkscreen graphic's outline as a chain of points in its own frame: a line's ends, a rectangle's+/// four corners closed, a polygon's points closed, an arc through start, mid and end, a circle as a+/// 16-gon.+fn segment_points(g: &Node) -> Vec<(f64, f64)> {+    match g.head().unwrap_or("").trim_start_matches("fp_").trim_start_matches("gr_") {+        "line" => ["start", "end"].iter().filter_map(|t| pt(g, t)).collect(),+        "arc" => ["start", "mid", "end"].iter().filter_map(|t| pt(g, t)).collect(),+        "rect" => match (pt(g, "start"), pt(g, "end")) {+            (Some(a), Some(b)) => vec![a, (b.0, a.1), b, (a.0, b.1), a],+            _ => Vec::new(),+        },+        "circle" => match (pt(g, "center"), pt(g, "end")) {+            (Some(c), Some(e)) => {+                let r = ((e.0 - c.0).powi(2) + (e.1 - c.1).powi(2)).sqrt();+                (0..=16).map(|i| { let t = i as f64 * std::f64::consts::PI / 8.0; (c.0 + r * t.cos(), c.1 + r * t.sin()) }).collect()+            }+            _ => Vec::new(),+        },+        "poly" => {+            let mut p: Vec<(f64, f64)> = g.child("pts").map(|pts| pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))).collect()).unwrap_or_default();+            if let Some(first) = p.first().copied() { p.push(first); }+            p+        }+        _ => Vec::new(),+    }+}++/// The standard's silk lines: one {side, x1, y1, x2, y2, width, raw} per segment of `pts` (board frame).+fn push_lines(lines: &mut Vec<Value>, g: &Node, pts: &[(f64, f64)], y_up: bool, raw: &str) {+    let side = if g.child_value("layer").as_deref() == Some("B.SilkS") { "bottom" } else { "top" };+    let width = g.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)).or_else(|| g.child("width").and_then(|w| w.num(0)));+    for w in pts.windows(2) {+        let (a, b) = (out(w[0], y_up), out(w[1], y_up));+        lines.push(json!({"side": side, "x1": a[0], "y1": a[1], "x2": b[0], "y2": b[1], "width": width, "raw": raw}));+    } }  #[cfg(test)]@@ -199,4 +257,15 @@ mod tests {         let u1 = up["parts"].as_array().unwrap().iter().find(|p| p["ref"] == "U1").cloned().unwrap();         assert_eq!(u1["designator"]["at"], json!([20.0, -17.0]));     }++    #[test]+    fn silk_state_also_answers_the_standards_shape() {+        // the standard's silk_state: components with name {text, x, y, size, shown}, lines with a width+        let up = silk_state(&board(), true).unwrap();+        let u1 = up["components"].as_array().unwrap().iter().find(|c| c["ref"] == "U1").cloned().unwrap();+        assert_eq!((u1["name"]["text"].as_str(), u1["name"]["x"].as_f64(), u1["name"]["y"].as_f64()), (Some("U1"), Some(20.0), Some(-17.0)), "{u1}");+        assert!(u1["name"]["size"].as_f64().unwrap() > 0.0 && u1["side"] == "top");+        let lines = up["lines"].as_array().unwrap();+        assert!(lines.iter().all(|l| l["x1"].is_number() && l["y2"].is_number() && (l["side"] == "top" || l["side"] == "bottom")), "{lines:?}");+    } }
rust/crates/kicad-core/src/ipc.rs+35−3
@@ -810,11 +810,43 @@ pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {     let (text, data, revision) = s.snapshot()?;     check_revision(args, &revision)?;     let plan = pcb::plan(&data, args)?;+    commit_checked(ctx, &s, args, &text, &revision, &plan, &format!("Adom route {}", plan.net_name))+}++/// `kicad_thermal_vias` and the other via planners: vias of one net at given KiCad-frame points,+/// with route_net's DRC preflight, dryRun and one native Undo step. `vias: [{at: [x, y], size,+/// drill}]`, `net`.+pub fn place_vias(ctx: &Ctx, args: &Value, message: &str) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let net = pcb::resolve_net(&data, args.get("net")).map_err(|e| RoutingError::new("unknown_net", e["error"].as_str().unwrap_or("no such net").to_string()))?;+    let copper = pcb::copper_layers(&data);+    let (top, bottom) = (copper.first().cloned().unwrap_or_else(|| "F.Cu".into()), copper.last().cloned().unwrap_or_else(|| "B.Cu".into()));+    let mut plan = pcb::Plan { net_name: net.name.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() };+    for v in args["vias"].as_array().into_iter().flatten() {+        let at = (v["at"][0].as_f64().unwrap_or(f64::NAN), v["at"][1].as_f64().unwrap_or(f64::NAN));+        let (size, drill) = (v["size"].as_f64().unwrap_or(pcb::DEFAULT_VIA_SIZE), v["drill"].as_f64().unwrap_or(pcb::DEFAULT_VIA_DRILL));+        if !(at.0.is_finite() && at.1.is_finite()) || drill <= 0.0 || drill >= size {+            return Err(RoutingError::new("bad_via", format!("a via needs a finite at and 0 < drill < size: {v}")).with("mutated", json!(false)));+        }+        plan.blocks.push(pcb::via_sexpr(at, size, drill, (&top, &bottom), &net));+        plan.vias.push(pcb::PlanVia { at: [at.0, at.1], layers: [top.clone(), bottom.clone()], size, drill });+    }+    if plan.vias.is_empty() {+        return Err(RoutingError::new("nothing_to_place", "vias is empty").with("mutated", json!(false)));+    }+    commit_checked(ctx, &s, args, &text, &revision, &plan, message)+}++/// DRC the planned copper against the board as it is (no new errors allowed, unless validate is+/// false), then commit it live as one Undo step: route_net's tail, shared with place_vias.+fn commit_checked(ctx: &Ctx, s: &Session, args: &Value, text: &str, revision: &str, plan: &pcb::Plan, message: &str) -> RResult<Value> {     let mut check = Value::Null;     if args.get("validate") != Some(&json!(false)) {         let source = s.source_path();-        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &text)?;-        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(&text, &plan.blocks))?;+        let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, text)?;+        let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, &pcb::splice(text, &plan.blocks))?;         let added = new_errors(&before, &after)?;         if !added.is_empty() {             return Err(RoutingError::new("drc_rejected", "Candidate route adds DRC errors; no live copper was changed").with("violations", json!(added)).with("mutated", json!(false)));@@ -836,7 +868,7 @@ pub fn route_net(ctx: &Ctx, args: &Value) -> RResult<Value> {     };     let items = build_items(&plan, &net)?;     let count = items.len();-    let created = transaction(&s, &format!("Adom route {}", plan.net_name), || {+    let created = transaction(s, message, || {         let created = s.client.create_editable_items(items, None)?;         if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {             return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));
rust/crates/kicad-core/src/lib.rs+1
@@ -50,6 +50,7 @@ pub mod project; pub mod fpedit; pub mod symedit; pub mod libops;+pub mod board_checks; pub mod board_reads; pub mod panel; pub mod libsearch;
rust/crates/kicad-core/src/pcb_sync.rs+24−2
@@ -137,8 +137,10 @@ pub fn pad_net_changes(board: &Board, nl: &Netlist) -> Vec<Value> {             seen.push(p.number.clone());             let have = p.net_name.clone().unwrap_or_default();             let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();-            // KiCad names an unconnected pin's net "unconnected-(...)": a pad with no net is the same thing-            let same = have == to || (have.is_empty() && to.starts_with("unconnected-")) || (to.is_empty() && have.starts_with("unconnected-"));+            // KiCad names an unconnected pin's net "unconnected-(...)": a pad with no net is the same thing.+            // The board keeps a net name escaped (a label VPP/MCLR is /VPP{slash}MCLR), the netlist does not.+            let have_plain = unescape_net(&have);+            let same = have_plain == to || (have.is_empty() && to.starts_with("unconnected-")) || (to.is_empty() && have.starts_with("unconnected-"));             if !same {                 out.push(json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));             }@@ -147,6 +149,17 @@ pub fn pad_net_changes(board: &Board, nl: &Netlist) -> Vec<Value> {     out } +/// A net name as the board file escapes it, read back plain: KiCad writes characters that mean+/// something in a net path or the file format as {slash}, {backslash}, {lt}, {gt}, {colon},+/// {dblquote}, {space}, {tab}, {return}, {brace}.+pub fn unescape_net(name: &str) -> String {+    if !name.contains('{') {+        return name.to_string();+    }+    [("{slash}", "/"), ("{backslash}", "\\"), ("{lt}", "<"), ("{gt}", ">"), ("{colon}", ":"), ("{dblquote}", "\""), ("{space}", " "), ("{tab}", "\t"), ("{return}", "\n"), ("{brace}", "{")]+        .iter().fold(name.to_string(), |s, (from, to)| s.replace(from, to))+}+ // --------------------------------------------------------------------------- // Text edits inside a footprint block // ---------------------------------------------------------------------------@@ -507,6 +520,15 @@ pub fn apply(text: &str, board: &Board, nl: &Netlist, opts: &ApplyOpts, module: mod tests {     use super::*; +    #[test]+    fn an_escaped_board_net_is_the_netlists_plain_name() {+        // pic_programmer: the label VPP/MCLR is /VPP{slash}MCLR on the board and /VPP/MCLR in the netlist+        assert_eq!(unescape_net("/VPP{slash}MCLR"), "/VPP/MCLR");+        assert_eq!(unescape_net("unconnected-(U5-OSC1{slash}CLKI-Pad16)"), "unconnected-(U5-OSC1/CLKI-Pad16)");+        assert_eq!(unescape_net("GND"), "GND");+        assert_eq!(unescape_net("A{lt}B{gt}{colon}C"), "A<B>:C");+    }+     #[test]     fn a_pad_net_is_set_replaced_and_cleared() {         let block = "(footprint \"L:R\"\n\t\t(layer \"F.Cu\")\n\t\t(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(net \"A\")\n\t\t\t(uuid \"u1\")\n\t\t)\n\t\t(pad \"2\" smd rect\n\t\t\t(at 1 0)\n\t\t\t(uuid \"u2\")\n\t\t)\n\t)";
rust/vendor/adom-eda-checks/Cargo.tomladded+22
@@ -0,0 +1,22 @@+[package]+name = "adom-eda-checks"+version = "0.1.4"+edition = "2021"+description = "The EDA Bridge Standard's checks, computed from the board model JSON: no bridge calls, no tool."+license = "MIT"++[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}` (a `value` with its unit, `"0.1mm"`, works too). Only copper-to-copper clearance counts: a rule whose kind or name names the board edge, mask, silk, paste, holes, a keepout or restrict area, the courtyard or the same net is skipped |+| `silk` | `<prefix>_silk_state` |+| `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` |+| `args` | the check's own arguments, as the standard verb documents them |++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.5+- Standard commit: cfaaac1006c7f7b2186ce7824f2b5390599f739a+- Crate version: 0.1.4+- Copied: 2026-10-27, by kicad-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 `kicad-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+142
@@ -0,0 +1,142 @@+//! 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})+        };+        // each layer once, however many pours of the net it holds (#15)+        let mut pours: Vec<Value> = Vec::new();+        for l in polys.iter().filter(|p| same(&p["net"])).map(|p| p["layer"].clone()) { if !pours.contains(&l) { pours.push(l); } }+        json!({"net": net, "amps": amps, "layers": layers, "shortOn": short, "ok": short.is_empty(), "vias": via, "poursOn": pours})+    }).collect()+}++/// 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"}, {"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+342
@@ -0,0 +1,342 @@+//! 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+}++/// A length written with its unit ("0.1mm", "4 mil", "0.004in", "100um") in mm; None without a known unit.+fn length_mm(text: &str) -> Option<f64> {+    let t = text.trim();+    let num: String = t.chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect();+    let v: f64 = num.parse().ok()?;+    match t[num.len()..].trim().to_ascii_lowercase().as_str() {+        "mm" => Some(v),+        "mil" | "mils" | "th" => Some(v * 0.0254),+        "in" | "inch" => Some(v * 25.4),+        "um" | "\u{b5}m" => Some(v / 1000.0),+        _ => None,+    }+}++/// A clearance rule that names another object than copper of another net: the board edge, solder mask, silkscreen,+/// holes, keepouts, the courtyard, or the same net (Fusion's Solder-Mask Clearance is a mask expansion, often 0.05 mm).+const NOT_COPPER_CLEARANCE: &[&str] = &["dimension", "edge", "board", "mask", "silk", "paste", "hole", "restrict", "keepout", "keep-out",+                                        "courtyard", "component", "body", "same net", "samenet", "same-net"];++/// The smallest copper-to-copper clearance among the board's enabled rules, in mm. A rule gives it as a number+/// (`clearanceMm`, `gapMm` or `minMm`), as a `value` with its unit ("0.1mm": Fusion's form), or in a descriptor the tool+/// wrote ("Gap=10mil", "Gap=0.2mm": Altium's form). Clearance rules about anything but copper of another net are left out.+pub fn min_clearance(rules: &[Value]) -> Option<f64> {+    let text = |r: &Value, k: &str| r[k].as_str().map(|s| s.to_lowercase()).unwrap_or_default();+    rules.iter().filter(|r| r["kind"].as_i64() == Some(0) || text(r, "kind").contains("clearance")+            || r["kindName"].as_str() == Some("Clearance") || text(r, "name").contains("clearance"))+        .filter(|r| !["kind", "name", "kindName"].iter().any(|k| { let t = text(r, k); NOT_COPPER_CLEARANCE.iter().any(|w| t.contains(w)) }))+        .filter(|r| r["enabled"].as_bool() != Some(false))+        .filter_map(|r| {+            if let Some(v) = ["clearanceMm", "gapMm", "minMm"].iter().find_map(|k| r[*k].as_f64()) { return Some(v); }+            if let Some(v) = r["value"].as_str().and_then(length_mm) { 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));+    }++    #[test]+    fn clearance_from_a_value_with_its_unit_and_never_same_net() {+        // Fusion writes rules as a kind name and a value with its unit+        let rules = vec![json!({"kind": "Copper Clearance", "value": "0.15mm"}), json!({"kind": "Copper Clearance", "value": "5mil"}),+                         json!({"kind": "Same Net Clearance", "value": "0.05mm"}), json!({"kind": "Copper Clearance", "value": "0.2"})];+        assert_eq!(min_clearance(&rules), Some(0.127));+        assert_eq!(length_mm("0.004in"), Some(0.1016));+        assert_eq!(length_mm("100um"), Some(0.1));+        assert_eq!(length_mm("0.2"), None, "a bare number has no unit: never guessed");+    }++    #[test]+    fn only_copper_to_copper_clearance_counts() {+        // Fusion's rule set (#13): only Copper Clearance is the clearance between nets+        let fusion = vec![json!({"kind": "Copper Clearance", "value": "0.1mm"}), json!({"kind": "Dimension Clearance", "value": "0.3mm"}),+                          json!({"kind": "Solder-Mask Clearance", "value": "0.05mm"}), json!({"kind": "Restrict Clearance", "value": "0.02mm"}),+                          json!({"kind": "Via Restrict Clearance", "value": "0.02mm"}), json!({"kind": "Keepout Clearance", "value": "0.01mm"})];+        assert_eq!(min_clearance(&fusion), Some(0.1));+        // Altium names its other clearances by object too+        let altium = vec![json!({"kind": 0, "name": "Clearance", "descriptor": "Clearance Constraint (Gap=0.2mm) (All),(All)"}),+                          json!({"kind": 17, "name": "HoleToHoleClearance", "descriptor": "Gap=0.1mm"}),+                          json!({"kind": 20, "name": "SilkToSolderMaskClearance", "descriptor": "Gap=0.05mm"})];+        assert_eq!(min_clearance(&altium), Some(0.2));+    }+}
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); }+}
skills/kicad-board-checks/SKILL.mdadded+76
@@ -0,0 +1,76 @@+---+name: kicad-board-checks+description: >-+  Check a KiCad board the way the EDA Bridge Standard checks every board, and fix what the checks find: return paths over gaps in the reference copper, copper balance (bow and twist), impedance track widths from the stack-up, current capacity (IPC-2221), net lengths and differential-pair skew, matched-length groups, 3D heights and enclosure fit, part orientation, free space for a part, symbols against footprints, net classes from net names, thermal vias in an exposed pad, a via fence for EMC, and one HTML board report with a picture. Works from the .kicad_pcb file without KiCad running, or live from the PCB editor. Trigger words: board review, check my board, design review, return path, ground plane gap, split plane, copper balance, board warp, bow and twist, impedance, 50 ohm trace width, 90 ohm USB pair, trace width for current, how many amps, can jlcpcb make it, fab limits, fab check, design rules check against a fab, IPC-2221, net length, length matching, pair skew, DDR length, height check, will it fit the enclosure, part too tall, 3D collision, rotate parts, find space, where can this part go, pin pad mismatch, symbol footprint mismatch, net classes, auto classes, power class, thermal vias, exposed pad vias, via stitching, via fence, EMC fence, RF fence, board report, report html.+---++# Board checks++Fourteen verbs check a board and, where a fix is mechanical, apply it. They answer the EDA Bridge+Standard's checks tier (adom/eda-bridge-standard), the same questions `altium_*` and `fusion_*` answer, so+an AI that reviews one tool's board reviews them all.++Every check reads the live PCB editor when it serves the board (unsaved edits included) and the file+otherwise; `source:"file"` forces the file. Coordinates are mm with y up (the standard's frame) unless+`frame:"kicad"`. A reply's `revision` is the standard revision: pass it as `expectedRevision` to the edit+verbs.++## Computed by the shared engine (adom-eda-checks)++The standard's own checks engine, vendored unchanged in `rust/vendor/adom-eda-checks`, computes these from+the board model, `kicad_layer_stack` and `kicad_pour_state`:++| Verb | Answers | Fix it with |+|---|---|---|+| `kicad_return_path` | signal tracks over no pour or plane on either neighbouring layer: `nets[] {net, layer, unreferencedMm, firstGapAt}` | `fix` is a ready `kicad_apply_zone_plan` call that pours the reference net under each gap; refill, then check again |+| `kicad_copper_balance` | copper per layer (pours included) and mirrored pairs within 20 points | pour the lighter layer |+| `kicad_impedance {z0, diff?, gapMm?}` | per copper layer the width (and pair width and gap) for the target, microstrip or stripline from the stack-up | `apply:{scope:"A.NetClass == 'USB'"}` adds a ready `kicad_custom_rules` call (track_width, diff_pair_gap per layer at +/-10%) |+| `kicad_fab_check {fab:"jlcpcb"}` | the board's smallest features against the fab's record from adom/pcb-design-rules (`fab.source` and `fab.read` say which page and when), or your `limits`: `fits`, `outsideLimits {limit, limitMm, boardMm, at}` | a footprint change, a wider track, or the fab's advanced service |+| `kicad_current_capacity {nets:{VIN:3}}` | per net and layer the narrowest track against the IPC-2221 width, and vias per layer change | `kicad_set_track_width`, a width rule, or a pour |++A board saved without a stack-up uses KiCad's defaults (35 um copper, FR4 Dk 4.5) and says so in+`assumed`: set Board Setup > Physical Stackup before trusting impedance.++## Computed by the bridge++| Verb | Answers |+|---|---|+| `kicad_net_lengths` | routed length per net by layer, pair skew (pairs KiCad recognises by name, or `pairs`), matched-length `groups` with `shortByMm` |+| `kicad_check_orientation` | two-pad parts whose airline is shorter turned 90/180/270 (advisory, nothing moved) |+| `kicad_find_space {part, near}` | free spots for a part near another, clear of courtyards, that side's copper and the edge; `check:true` lists overlaps and parts off the board |+| `kicad_height_check {maxTopMm, regions}` | heights from the 3D models (`kicad_bodies`), parts over a limit or under a lid feature, bodies that collide |+| `kicad_pin_pad_check` | symbol pins (the schematic's netlist) against footprint pads, part by part |+| `kicad_auto_classes` | GND, Power, Clock and HighSpeed from net names, and the pairs; `apply:true` writes them with `kicad_netclasses` |++## Edits: vias++`kicad_thermal_vias {ref, pad?}` fills a part's exposed pad (its largest pad by default) with a centred,+inset grid on the pad's net. `kicad_via_fence {viaNet, nets?, edge?, freqGHz?}` lines both sides of fast+tracks and/or the board edge with ground vias at lambda/20 (FR-4) or `pitchMm`, each clear of other copper,+pads, vias, via keepouts and the edge.++Both plan first (`dryRun:true` returns `planned.points`), then place the vias:+- **live** when the PCB editor serves the board: one Undo step, after KiCad's DRC (pours refilled) proves+  the vias add no error (`drc_rejected` otherwise, nothing changed);+- **in the closed file** otherwise, after the same DRC check, with a `.adom-bak` backup.++Size and drill default to the project's Default net class via. Afterwards refill the pours+(`kicad_zone_state {refill:true}`) so they join the vias: until then a file DRC shows the old fill+overlapping them, and thermal vias read as dangling until the far side has copper of their net.++## The report++`kicad_board_report {projectPath}` runs DRC, ERC, the board against the schematic, symbols against+footprints, the fab's limits (`fab`, default jlcpcb), return paths, copper balance, impedance widths, 3D+heights, lengths and orientation, renders the top side, and writes one self-contained HTML file beside the+project. `sections[]` say pass, needs work, or information, and name the verb that gives each in full. A+check that could not run is reported as not run, never as a pass.++## Pitfalls++- Pours count as KiCad last filled them: after editing copper, refill before trusting return_path or+  copper_balance on a file read.+- `kicad_height_check` uses each model's bounding box: round parts can be reported colliding at corners.+- `kicad_pin_pad_check` expects mounting, shield, exposed and thermal pads (MH*, MP*, SH*, EP*, PAD*, TAB*,+  unnamed or 0) to have no pin.+- `kicad_auto_classes` reads names only: a clock on a net called SIG7 is not found.
skills/kicad-bridge-dev/SKILL.md+10
@@ -1040,3 +1040,13 @@ Container tests: `PYTHONPATH=<linux pip target of requirements-routing.txt> pyth - **A headless VM hands new windows the foreground by itself.** With no console user, every   background launch measures at z=0; that is the environment, not a focus steal (see the test   skill's victim rule).++## The shared checks engine is vendored, never edited (1.0.67)++`rust/vendor/adom-eda-checks` is the EDA Bridge Standard's checks engine (adom/eda-bridge-standard `engine/`), copied+unchanged because the wiki has no git protocol for Cargo. `VENDOR.md` there records the standard version and commit it+came from and how to update it. Fixes go to the standard as an issue titled `kicad-lane: ...`, never into the copy.+`verbs_checks.rs` feeds it one read of the board (model, stack-up, pours) and rewrites its plans as this bridge's+calls (`kicad_apply_zone_plan`, `kicad_custom_rules`). The checks the engine does not have yet are the bridge's own+(`kicad_core::board_checks`, `verbs_checks_native.rs`): when the engine gains one, switch the verb to it and keep the+reply's shape.
skills/kicad-bridge-test/run_verb_tests.py+48−1
@@ -191,6 +191,9 @@ def matrix(w: dict) -> dict:             "clone_symbol", "rename_symbol", "set_params", "set_visibility", "symbol_prims", "place_on_sch", "doc_params", "sheet_params",             "part_parameters", "layers", "silk_state", "ripup_nets", "compile", "list_installed_libraries", "replicate_layout", "diff_pairs")},         "teardrops": dict(phase=8, scenario=True),+        # ---- phase 10: the checks tier added in 1.0.67, run in order by checks_phase() on a scratch board+        **{v: dict(phase=10, scenario=True) for v in ("fab_check", "copper_balance", "return_path", "impedance", "current_capacity", "net_lengths",+            "check_orientation", "find_space", "height_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),@@ -625,6 +628,8 @@ def main():     ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live"}, timeout=200)     ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/offline"}, timeout=200)     ab(a.target, "send_files", {"filePaths": [live_src], "dest": base + "/live2"}, timeout=200)+    # the checks phase's scratch board: the ESC with its planes, closed, vias written into it+    ab(a.target, "send_files", {"filePaths": [str(REPO / "demo" / "routing" / "esc" / "esc-g431-fable-placed-planes.kicad_pcb")], "dest": base + "/checks"}, timeout=200)     # scratch symbol library for install_library (written locally, pushed over)     import tempfile as _tf     tl = Path(_tf.gettempdir()) / "AdomVerbTest.kicad_sym"@@ -1253,6 +1258,11 @@ def main():         r = E("teardrops", {"filePath": L2}, 240)         r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}         record("teardrops", {"add": r, "remove": r2}, ok(r) and ok(r2) and (r.get("after") or 0) >= (r.get("before") or 0) and r2.get("after") == 0, "teardrops were not added and removed again")+        # 1.0.67: thermal vias in Q1's drain tab, live as one Undo step after a DRC preflight+        r = E("thermal_vias", {"ref": "Q1"}, 300)+        record("thermal_vias_live", r, ok(r) and r.get("source") == "live-editor" and (r.get("applied") or 0) > 0 and r.get("undoSteps") == 1, "thermal vias were not committed live")+        r = E("via_fence", {"viaNet": "GND", "edge": True, "dryRun": True}, 300, chain=False)+        record("via_fence_live_plan", r, ok(r) and (r.get("planned") or {}).get("vias", 0) > 0 and r.get("mutated") is not True, "no live fence plan")         r = E("add_mounting_hole", {"x": round(cx, 2), "y": round(cy, 2), "drill": 2.2, "dryRun": True}, chain=False)         rec("add_mounting_hole", r, ok(r) or refused_cleanly(r), "no answer")         r = E("board_outline", {"shape": "rectangle", "width": 70, "height": 60, "x": 100, "y": 125, "replace": True, "dryRun": True}, chain=False)@@ -1424,7 +1434,7 @@ def main():         r = S("sheet_params", {"projectPath": pro, "designators": ["R1"], "params": {"MPN": "KSTD-R1"}}); own("sheet_params", r, ok(r) and not r.get("notFound"))         r = S("part_parameters", {"projectPath": pro, "name": "MPN"}); own("part_parameters", r, ok(r) and any(v.get("value") == "KSTD-R1" for v in r.get("values") or []))         r = S("layers", {"filePath": B, "source": "file"}); own("layers", r, ok(r) and (r.get("copper") or 0) >= 2)-        r = S("silk_state", {"filePath": B, "source": "file"}); own("silk_state", r, ok(r) and (r.get("counts") or {}).get("parts", 0) > 0)+        r = S("silk_state", {"filePath": B, "source": "file"}); own("silk_state", r, ok(r) and (r.get("counts") or {}).get("parts", 0) > 0 and isinstance(r.get("components"), list) and isinstance(r.get("lines"), list))         ab(a.target, "kicad_close", {"force": True}, timeout=120)         time.sleep(4)         o = ab(a.target, "kicad_open_board", {"filePath": B, "reason": WHY}, timeout=200)@@ -1448,9 +1458,46 @@ def main():             own("replicate_layout", st3, False, "fewer than four front capacitors on the offline board")         r = S("diff_pairs", {"filePath": B, "source": "file"}); own("diff_pairs", r, ok(r) and isinstance(r.get("pairs"), list)) +    def checks_phase():+        """Phase 10: the checks tier (1.0.67): the shared engine's checks and the bridge's own, from the file;+        thermal vias and a via fence written into the closed scratch board after the DRC preflight; the report."""+        if only and not any(o.startswith(("checks", "board_report")) for o in only):+            return+        WHY = "daily verb test: board checks"+        C = lambda verb, args, t=200: ab(a.target, f"kicad_{verb}", dict(args, reason=WHY), timeout=t)+        def own(verb, r, good, why=None):+            record(verb, r, bool(good), None if good else (why or f"{r.get('errorCode')}: {str(r.get('error'))[:160]}"))+        CK = base + "/checks/esc-g431-fable-placed-planes.kicad_pcb"+        fb = {"filePath": CK, "source": "file"}+        print("[10] board checks")+        r = C("fab_check", dict(fb, fab="jlcpcb"), 120)+        own("fab_check", r, ok(r) and isinstance(r.get("outsideLimits"), list) and (r.get("fab") or {}).get("source") and (r.get("measured") or {}).get("copperLayers") == 4 and (r.get("measured") or {}).get("smdPad"), "no fab record read, or nothing measured")+        r = C("copper_balance", fb); own("copper_balance", r, ok(r) and len(r.get("layers") or []) == 4 and "balanced" in r)+        r = C("return_path", fb); own("return_path", r, ok(r) and isinstance(r.get("nets"), list) and r.get("referenceNets"))+        r = C("impedance", dict(fb, z0=50, diff=90, apply={"scope": "A.NetClass == 'USB'"}))+        own("impedance", r, ok(r) and (r.get("layers") or [{}])[0].get("width") and (r.get("apply") or {}).get("verb") == "kicad_custom_rules")+        r = C("current_capacity", dict(fb, nets={"GND": 2})); own("current_capacity", r, ok(r) and (r.get("nets") or [{}])[0].get("net") == "GND")+        r = C("net_lengths", fb); own("net_lengths", r, ok(r) and isinstance(r.get("nets"), list) and isinstance(r.get("pairs"), list))+        r = C("check_orientation", fb); own("check_orientation", r, ok(r) and (r.get("checked") or 0) > 10)+        st = ab(a.target, "kicad_list_designators", fb, timeout=120)+        cap = next((d for d in (st.get("designators") or []) if str(d).startswith("C")), "C1")+        r = C("find_space", dict(fb, part=cap, radiusMm=15)); own("find_space", r, ok(r) and isinstance(r.get("spots"), list))+        r = C("height_check", dict(fb, maxTopMm=20), 240); own("height_check", r, ok(r) and r.get("heights"))+        r = C("pin_pad_check", {"projectPath": w["pro"]}, 240); own("pin_pad_check", r, ok(r) and "matching" in r and isinstance(r.get("mismatches"), list))+        r = C("auto_classes", fb); own("auto_classes", r, ok(r) and ((r.get("proposal") or {}).get("classes") or {}).get("GND"))+        r = C("thermal_vias", dict(fb, ref="Q1"), 300)+        r2 = C("thermal_vias", dict(fb, ref="Q1", dryRun=True)) if ok(r) else {}+        own("thermal_vias", {"apply": r, "again": r2}, ok(r) and (r.get("applied") or 0) > 0 and (r.get("drc") or {}).get("newErrors") == 0 and r2.get("applied") == 0,+            "thermal vias were not written after a clean DRC, or a second run placed more")+        r = C("via_fence", dict(fb, viaNet="GND", edge=True), 300)+        own("via_fence", r, ok(r) and (r.get("applied") or 0) > 0 and (r.get("drc") or {}).get("newErrors") == 0, "no fence written after a clean DRC")+        r = C("board_report", {"filePath": CK}, 600)+        own("board_report", r, ok(r) and r.get("report") and r.get("picture") is True and len(r.get("sections") or []) >= 8, "no report with its picture")+     live_phase()     k161_phase()     std_phase()+    checks_phase()      # phase 7: clean up EVERYTHING the suite created on the box (John 2026-08-14)     if not only and not a.skip_gui:
skills/kicad-schematic-and-board-edits/SKILL.md+1−1
@@ -80,7 +80,7 @@ From 1.0.64, the EDA Bridge Standard's library housekeeping: ## Fab outputs  `export_fab_package` (Gerbers, drill, BOM, CPL, a README, zipped; `fab:"jlcpcb"` for its columns), `export_odb`,-`export_ipc2581`, `export_ipc356`, `export_gencad`, `export_dxf`, `validate_for_fab` (the standard's `fab_check`),+`export_ipc2581`, `export_ipc356`, `export_gencad`, `export_dxf`, `validate_for_fab` (KiCad's DRC against a fab profile; the standard's `fab_check` is `kicad_fab_check`, skill kicad-board-checks), `estimate_cost`.  From 1.0.66:
tools/build_native_release.py+3−1
@@ -209,7 +209,9 @@ def publish_zip(ver, zpath, sha, notes):     from datetime import datetime, timezone     REF = "adom/kicad-bridge"     # 1. The repo must tell the same story as the zip: push every rust source file.-    src = [str(p.relative_to(ROOT)) for p in sorted((RUST).rglob("*")) if p.is_file() and "target" not in p.parts and "dist" not in p.parts]+    # never an editor's or a patch's leftovers (.orig, .rej, .bak): two .orig copies once rode along this way+    src = [str(p.relative_to(ROOT)) for p in sorted((RUST).rglob("*")) if p.is_file() and "target" not in p.parts and "dist" not in p.parts+           and p.suffix not in (".orig", ".rej", ".bak")]     src += ["tools/build_native_release.py", "tools/sign_azure.py", "tools/sync_standard_names.py", "tools/readme_verbs.py", "bridge.json", "README.md"]     msg = f"{ver}: {notes}"     failed = push_chunked(REF, src, msg, cwd=str(ROOT))