← All Pull Requests

kicad-lane: 1.0.72, engine 0.4.0 checks and placement_check #33

Merged opened by Colby Knox 2026-10-28

kicad-lane: 1.0.72, four checks from the standard's engine 0.4.0 (standard 0.7.0, #18 and #19).

  • Vendored engine: adom-eda-checks 0.4.0, copied unchanged from adom/eda-bridge-standard e7b8a3e8 (VENDOR.md updated).
    • Replies carry the engine's own engine field; the hand-written ENGINE string is gone.
    • A test ties VENDOR.md's crate version to adom_eda_checks::VERSION.
  • thermal_vias / via_fence: the engine plans in the standard frame (y up), and the bridge turns the points into KiCad's frame and places them as before.
    • Placing is live, one Undo step after a DRC preflight, or into the closed file after the same DRC check.
    • The via is the project's Default net class, passed as the standard's {kind: via} rule. The fence clearance defaults to the board's largest clearance rule.
  • height_check: the engine on kicad_bodies.
  • kicad_placement_check (new): the engine on the board model plus kicad_bodies. It is catalogued File, because it calls kicad_bodies.
  • Removed: kicad_core::board_checks height_check, thermal_vias, via_fence, the walk helper, and their three tests. The engine has its own tests for them.

Tested on DESKTOP-BAR6S0F, ESC board (phase 10):

  • thermal_vias placed 16 vias with no new DRC errors; a second run placed none (16 existing);
  • via_fence placed 118 vias, no new DRC errors; the via came from the board's rule;
  • height_check is complete over 90 parts;
  • placement_check reviewed 149 parts with bodies.

Full battery: see the release record.

Note: crates/kicad-core/tests/schematic_edit.rs fails 7 tests locally because tour-pack/template/tour-demo.kicad_sch is not in the repo. This predates this PR, which does not touch it.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,409 +1,411 @@⋯ 302 unchanged lines ⋯     "kicad_component_classes",     "kicad_build_schematic",     "kicad_set_layer_visibility",-    "kicad_library_views"+    "kicad_library_views",+    "kicad_placement_check"   ],   "statusVerb": "kicad_status",   "timeouts": {⋯ 97 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯  [[package]] name = "adom-eda-checks"-version = "0.2.1"+version = "0.4.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.71"+version = "1.0.72" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.71"+version = "1.0.72" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.71"+version = "1.0.72" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.71"+version = "1.0.72" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/verbs_checks.rs+++ b/rust/crates/kicad-bridge/src/verbs_checks.rs@@ -1,313 +1,318 @@⋯ 14 unchanged lines ⋯ use crate::verbs_standard::{file_note, opts, read, stamp}; use kicad_core::board_model; -const ENGINE: &str = "adom-eda-checks 0.2.1 (EDA Bridge Standard 0.5.1, vendored)";- pub static VERBS: &[Verb] = &[     Verb {         name: "kicad_fab_check",⋯ 98 unchanged lines ⋯     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});+    let mut bundle = json!({"board": board, "stack": stack, "pours": pours, "args": engine_args(args)});     if check == "fab_check" {         bundle["silk"] = kicad_core::board_reads::silk_state(text, o.y_up)?;         bundle["rules"] = json!(clearance_rules(file));⋯ 10 unchanged lines ⋯     }     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.");     }⋯ 26 unchanged lines ⋯     } } +/// The verb's arguments as the engine's `args`: without this bridge's own keys (which board, which+/// frame, how to apply).+pub(crate) fn engine_args(args: &Value) -> Value {+    let mut a = args.clone();+    if let Some(m) = a.as_object_mut() {+        for k in ["filePath", "source", "socketPath", "frame", "reason", "dryRun", "expectedRevision", "save", "backup", "validate"] {+            m.remove(k);+        }+    }+    a+}+ /// 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> {+pub(crate) 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()⋯ 41 unchanged lines ⋯     use super::*;      #[test]-    fn the_engine_string_names_the_vendored_copy() {-        // VENDOR.md records what was copied; the replies must say the same (it drifted once)+    fn the_vendored_engine_is_the_one_vendor_md_records() {+        // VENDOR.md records what was copied; the replies carry the engine's own version (it drifted once when written by hand)         let vendor = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../vendor/adom-eda-checks/VENDOR.md")).unwrap();         let field = |k: &str| vendor.lines().find_map(|l| l.strip_prefix(&format!("- {k}: "))).unwrap().trim().to_string();-        assert_eq!(ENGINE, format!("adom-eda-checks {} (EDA Bridge Standard {}, vendored)", field("Crate version"), field("Standard version")));+        assert_eq!(adom_eda_checks::VERSION, field("Crate version"));+        assert_eq!(adom_eda_checks::run("copper_balance", &json!({}))["engine"], format!("adom-eda-checks {}", field("Crate version")));     }      fn board(rel: &str) -> (String, String) {⋯ 73 unchanged lines ⋯         }     } }+--- a/rust/crates/kicad-bridge/src/verbs_checks_native.rs+++ b/rust/crates/kicad-bridge/src/verbs_checks_native.rs@@ -1,1010 +1,1068 @@⋯ 59 unchanged lines ⋯         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_placement_check",+        summary: "A placement review before routing: parts whose pads and 3D bodies come closer than a margin on the same side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a mounting hole.",+        // File: it reads the bodies through kicad_bodies (verbs::call)+        mechanism: Mechanism::File, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"marginMm\"?: 0.1, \"edgeMm\"?: 0.5, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_placement_check {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+        hint: "ok is true when nothing overlaps, nothing is off the board and nothing sits on a mounting hole. courtyardOverlaps: parts closer than twice marginMm (pads and bodies): move them apart (kicad_find_space, kicad_move_footprint). nearEdge pads risk the router or a V-cut. bodiesUsed is false when kicad_bodies could not read the models: a part's box is then its pads only. The standard's placement_check, computed by its engine (engine says which).",+        related: &["kicad_bodies", "kicad_find_space", "kicad_move_footprint", "kicad_placement_validate"],+        pitfalls: &["Bodies are their models' bounding boxes: round parts can overlap at their corners.", "kicad_placement_validate is KiCad's own courtyard DRC; this review adds the bodies, the edge and the mounting holes."],+    },+    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.",+        hint: "planned {part, pad, net, padMm, vias, existingVias, skippedNearExisting, pitchMm, via {sizeMm, drillMm, from}, points} (points in the reply's frame), planned by the standard's engine (engine says which). 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)."],+        pitfalls: &["Live: refused (drc_rejected) when the vias add DRC errors, e.g. a hole too close to another; nothing changes.", "A point near a via already in the pad is skipped (skippedNearExisting); a pad already full answers applied 0.", "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",⋯ 1 unchanged line ⋯         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.",+        hint: "planned {vias, candidates, via {sizeMm, drillMm, from}, pitchMm, offsetMm, points}, planned by the standard's engine (engine says which); candidates - vias are spots too close to other copper, a pad or the edge. 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."],+        pitfalls: &["Spots too close to other copper, pads, vias or the edge are skipped, not forced.", "Size and drill default to the project's Default net class via; clearance to the board's largest clearance rule."],     },     Verb {         name: "kicad_silk_designators",⋯ 66 unchanged lines ⋯         "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_placement_check" => placement_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),⋯ 68 unchanged lines ⋯     if bodies["success"] != json!(true) {         return bodies;     }-    let mut v = board_checks::height_check(&bodies, args);+    let mut v = adom_eda_checks::run("height_check", &json!({"bodies": bodies, "args": crate::verbs_checks::engine_args(args)}));+    if v["success"] != json!(true) {+        return v;+    }     for k in ["source", "file", "kicadVersion", "live"] {         if !bodies[k].is_null() {             v[k] = bodies[k].clone();⋯ 492 unchanged lines ⋯     (class["via_diameter"].as_f64().unwrap_or(pcb::DEFAULT_VIA_SIZE), class["via_drill"].as_f64().unwrap_or(pcb::DEFAULT_VIA_DRILL)) } +/// kicad_placement_check: the standard's engine on the board model and the bodies from kicad_bodies+/// (both in the frame asked); without readable bodies a part's box is its pads.+fn placement_check(state: &mut State, args: &Value) -> 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 mut a = json!({});+    for k in ["filePath", "source", "socketPath", "frame"] {+        if let Some(v) = args.get(k) {+            a[k] = v.clone();+        }+    }+    let bodies = call(state, "kicad_bodies", &a);+    let mut bundle = json!({"board": model, "args": crate::verbs_checks::engine_args(args)});+    if bodies["success"] == json!(true) {+        bundle["bodies"] = bodies.clone();+    }+    let mut v = adom_eda_checks::run("placement_check", &bundle);+    if v["success"] != json!(true) {+        return v;+    }+    v["revision"] = model["revision"].clone();+    v["frame"] = json!(if o.y_up { "standard" } else { "kicad" });+    v["noBody"] = bodies["noBody"].clone();+    stamp(&mut v, &r);+    let hint = v["_hint"].as_str().unwrap_or("").to_string();+    v["_hint"] = json!(format!("{}{hint} In KiCad: move_parts is kicad_move_footprint.", file_note(&r)));+    v+}+ #[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.+/// kicad_thermal_vias and kicad_via_fence: the standard's engine plans in its frame (y up), the+/// points are turned into KiCad's for the edit and reported in the caller's, then the vias are placed+/// 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) {+    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 (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.");+    if let (Some(size), Some(drill)) = (args.get("size").and_then(Value::as_f64), args.get("drill").and_then(Value::as_f64)) {+        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)+    // the board's via (the project's Default net class) as the standard's via rule, and the fence's+    // clearance from its largest clearance rule+    let (dsize, ddrill) = default_via(&r.file);+    let clearances = crate::verbs_checks::clearance_rules(&r.file);+    let mut engine_args = crate::verbs_checks::engine_args(args);+    if job == ViaJob::Fence && engine_args.get("clearance").is_none() {+        if let Some(c) = clearances.iter().filter_map(|c| c["clearanceMm"].as_f64()).reduce(f64::max) {+            engine_args["clearance"] = json!(c);         }-    };-    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 bundle = json!({"board": model, "rules": [{"kind": "via", "name": "Default net class via", "sizeMm": dsize, "drillMm": ddrill}], "args": engine_args});+    let plan = adom_eda_checks::run(if job == ViaJob::Thermal { "thermal_vias" } else { "via_fence" }, &bundle);+    let engine = plan["engine"].clone();+    if plan["success"] != json!(true) {+        let mut v = plan;+        v["_hint"] = json!(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." });+        return v;+    }+    let planned = &plan["planned"];+    let (size, drill) = (planned["via"]["sizeMm"].as_f64().unwrap_or(dsize), planned["via"]["drillMm"].as_f64().unwrap_or(ddrill));+    let std_points: Vec<Value> = planned["points"].as_array().cloned().unwrap_or_default();+    let kicad_points: Vec<Value> = std_points.iter().map(|p| json!([p[0], -p[1].as_f64().unwrap_or(0.0) + 0.0])).collect();+    let mut shown = planned.clone();+    if !o.y_up {+        shown["points"] = json!(kicad_points);+    }     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});+    let mut out = json!({"success": true, "planned": shown, "frame": if o.y_up { "standard" } else { "kicad" }, "revision": standard_rev, "engine": engine});+    let plan = planned.clone();     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.",+            ViaJob::Thermal => "No via fits that is not already there: the pad holds its vias (existingVias, skippedNearExisting) or is too small for the pitch.",+            ViaJob::Fence => "No spot is clear of other copper and pads, other vias and the edge. A smaller via or clearance, or offsetMm, may find room.",         });         return out;     }⋯ 7 unchanged lines ⋯     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()),+        ViaJob::Fence => (args["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 {⋯ 2 unchanged lines ⋯             v["applied"] = plan["vias"].clone();             v["failed"] = json!(0);             v["frame"] = out["frame"].clone();+            v["engine"] = out["engine"].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("")),⋯ 209 unchanged lines ⋯         assert!(o["checked"].as_u64().unwrap() > 10, "{o}");     } }+--- a/rust/crates/kicad-core/src/board_checks.rs+++ b/rust/crates/kicad-core/src/board_checks.rs@@ -1,1271 +1,959 @@ //! 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.+//! check_orientation, find_space, silk_designators, length_tune, auto_classes (the proposal) and+//! pin_pad_check (height_check, thermal_vias and via_fence come from the standard's engine).+//! 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⋯ 400 unchanged lines ⋯ }  // ----------------------------------------------------------------------------// 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+// pad distance // ---------------------------------------------------------------------------  /// Distance from `p` to a pad's copper: its rectangle (turned by its rotation) or circle.⋯ 12 unchanged lines ⋯     (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<_>>()}))-}- // --------------------------------------------------------------------------- // silk_designators // ---------------------------------------------------------------------------⋯ 457 unchanged lines ⋯     }      #[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 designators_land_beside_their_part_and_off_every_pad() {         // R1 is a 2-pad part at the origin; C1 sits right above it, so R1's label goes below         let silk = json!({"parts": [⋯ 59 unchanged lines ⋯         assert!(inside_polygon((1.0, 1.0), &[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]));     } }+--- a/rust/vendor/adom-eda-checks/Cargo.toml+++ b/rust/vendor/adom-eda-checks/Cargo.toml@@ -1,25 +1,26 @@ [package] name = "adom-eda-checks"-version = "0.2.1"+version = "0.4.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+--- a/rust/vendor/adom-eda-checks/README.md+++ b/rust/vendor/adom-eda-checks/README.md@@ -1,92 +1,103 @@⋯ 15 unchanged lines ⋯ | `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 |+| `height_check` | bodies, board? | each part's height on its side, parts over `maxTopMm`/`maxBottomMm` or under a lid region (`regions`), body collisions and near misses (`minGapMm`); `noBody` and `complete: false` when a part has no 3D body |+| `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `bodiesUsed: false` when no bodies were given |+| `thermal_vias` | board, rules? | a grid of vias in a part's exposed pad (`ref`, `pad?`), clear of vias already there; `apply` is `place_vias` arguments |+| `via_fence` | board, rules? | vias of `viaNet` both sides of the named nets' tracks and/or round the board edge (`edge`), at `pitchMm` or lambda/20 at `freqGHz`, clear of other copper; `apply` is `place_vias` arguments | -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.+The via plans size their vias from `args.size`/`drill`, else the board's via rule (`rules`: `{kind: "via", sizeMm, drillMm}`,+or Altium's Routing Via descriptor), else 0.6/0.3, and say which in `planned.via.from`. +The other checks-tier verbs (assembly, pair routing, length tuning,+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling, plan_fanout+and plan_route) come over a module at a time.+ ## Writers  For a bridge whose tool has no native exporter (`writers`, from altium-lane, #16). They stay native-tier verbs in the⋯ 19 unchanged lines ⋯ | `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`) |+| `bodies` | the 3D bodies companion, `<prefix>_bodies`: `bodies[{ref, side, x0, y0, x1, y1, z0, z1}]` (one box per body or per part) and `noBody` | | `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` |⋯ 27 unchanged lines ⋯ ```  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix-(`apply_pours`, `add_rule`): a bridge adds its own.+(`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`+(`"adom-eda-checks 0.4.0"`), so a bridge reports the engine it runs without writing the version by hand.  ## Tests ⋯ 5 unchanged lines ⋯ ```  Fixtures live on each bridge's own page, not here.+--- a/rust/vendor/adom-eda-checks/VENDOR.md+++ b/rust/vendor/adom-eda-checks/VENDOR.md@@ -1,13 +1,14 @@⋯ 2 unchanged lines ⋯ 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.5.1-- Standard commit: e8a33ccecec060707f651b3956aeaa5492645290-- Crate version: 0.2.1+- Standard version: 0.7.0+- Standard commit: e7b8a3e85d4f36c801139875af9f496103ec0919+- Crate version: 0.4.0 - Copied: 2026-10-28, 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: ...`).+--- a/rust/vendor/adom-eda-checks/src/bodies.rs+++ b/rust/vendor/adom-eda-checks/src/bodies.rs@@ -0,0 +1,188 @@+//! The board in 3D from its parts' bodies (the bodies companion): how tall each part stands on its side, which ones break+//! an enclosure's height limit (board-wide, or under a lid feature in a region), and which bodies collide or come closer+//! than a gap. Bodies are compared as boxes (each body's footprint rectangle and its standoff-to-top range); a part made+//! of several bodies is several boxes, so L-shapes stay honest; a bridge that gives one box per part (Fusion) is the+//! union. The tool's own component clearance DRC checks the real shapes. From the Altium bridge's bodies.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }++/// One body as a box in mm: its part, side (top/bottom), x and y extent, and z from the board surface it sits on.+#[derive(Debug, Clone)]+pub struct BodyBox { pub part: String, pub top: bool, pub x0: f64, pub y0: f64, pub x1: f64, pub y1: f64, pub z0: f64, pub z1: f64 }++/// The bodies companion (`bodies[{ref, side, x0, y0, x1, y1, z0, z1}]`, mm) as boxes. Pure.+pub fn boxes(rows: &[Value]) -> Vec<BodyBox> {+    rows.iter().filter(|r| r["ref"].as_str().map(|s| !s.is_empty()).unwrap_or(false)).map(|r| BodyBox {+        part: r["ref"].as_str().unwrap_or("").to_string(), top: r["side"].as_str() != Some("bottom"),+        x0: f(&r["x0"]).min(f(&r["x1"])), y0: f(&r["y0"]).min(f(&r["y1"])), x1: f(&r["x0"]).max(f(&r["x1"])), y1: f(&r["y0"]).max(f(&r["y1"])),+        z0: f(&r["z0"]), z1: f(&r["z1"]),+    }).collect()+}++/// The input's bodies: the companion's `bodies`, or the companion given as a bare list.+pub fn input_boxes(input: &Value) -> Option<Vec<BodyBox>> {+    let b = crate::input::part(input, "bodies")?;+    let rows = b.get("bodies").or(Some(&b)).and_then(|x| x.as_array()).cloned()?;+    Some(boxes(&rows))+}++/// Parts on the board with no body: the companion's `noBody`, and every board part the boxes leave out.+fn no_body(input: &Value, bs: &[BodyBox]) -> Vec<Value> {+    let with: std::collections::BTreeSet<&str> = bs.iter().map(|b| b.part.as_str()).collect();+    let mut out: Vec<String> = crate::input::part(input, "bodies").and_then(|b| b["noBody"].as_array().cloned()).unwrap_or_default()+        .iter().filter_map(|v| v.as_str().map(String::from)).collect();+    if let Ok(st) = crate::input::board(input) {+        for r in st["components"].as_array().cloned().unwrap_or_default().iter().filter_map(|c| c["ref"].as_str().map(String::from)) {+            if !with.contains(r.as_str()) && !out.contains(&r) { out.push(r); }+        }+    }+    out.into_iter().map(Value::from).collect()+}++/// Each part's height above its side and its footprint in mm, tallest first.+pub fn heights(bs: &[BodyBox]) -> Vec<Value> {+    let mut m: BTreeMap<&str, (bool, f64, f64, f64, f64, f64)> = BTreeMap::new();+    for b in bs {+        let e = m.entry(&b.part).or_insert((b.top, 0.0, f64::MAX, f64::MAX, f64::MIN, f64::MIN));+        e.1 = e.1.max(b.z1);+        e.2 = e.2.min(b.x0); e.3 = e.3.min(b.y0); e.4 = e.4.max(b.x1); e.5 = e.5.max(b.y1);+    }+    let mut v: Vec<Value> = m.into_iter().map(|(p, (top, h, x0, y0, x1, y1))| json!({"ref": p, "side": if top { "top" } else { "bottom" }, "heightMm": r3(h), "rect": [r3(x0), r3(y0), r3(x1), r3(y1)]})).collect();+    v.sort_by(|a, b| b["heightMm"].as_f64().partial_cmp(&a["heightMm"].as_f64()).unwrap());+    v+}++/// Body pairs of different parts on the same side that overlap in 3D (`collisions`), or whose boxes come within+/// `gap` mm of each other in x and y where their heights overlap (`tooClose`). Pure.+pub fn clashes(bs: &[BodyBox], gap: f64) -> (Vec<Value>, Vec<Value>) {+    let mut idx: Vec<usize> = (0..bs.len()).collect();+    idx.sort_by(|a, b| bs[*a].x0.partial_cmp(&bs[*b].x0).unwrap());+    let mut seen: BTreeMap<(String, String), (bool, f64, Value)> = BTreeMap::new();+    for (k, &i) in idx.iter().enumerate() {+        let a = &bs[i];+        for &j in &idx[k + 1..] {+            let b = &bs[j];+            if b.x0 > a.x1 + gap { break; }+            if a.part == b.part || a.top != b.top { continue; }+            let dz = a.z1.min(b.z1) - a.z0.max(b.z0);+            if dz <= 0.0 { continue; } // one sits above the other (a part under a connector's overhang)+            let dx = (b.x0 - a.x1).max(a.x0 - b.x1);+            let dy = (b.y0 - a.y1).max(a.y0 - b.y1);+            let (hit, d) = if dx < 0.0 && dy < 0.0 { (true, -(dx.max(dy))) } else { (false, (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt()) };+            if !hit && d >= gap { continue; }+            let key = if a.part < b.part { (a.part.clone(), b.part.clone()) } else { (b.part.clone(), a.part.clone()) };+            let at = json!([r3((a.x0.max(b.x0) + a.x1.min(b.x1)) / 2.0), r3((a.y0.max(b.y0) + a.y1.min(b.y1)) / 2.0)]);+            // per pair keep the worst: a collision over a near miss, then the deepest or the closest+            let worse = match seen.get(&key) { None => true, Some((h, v, _)) => (hit && !h) || (hit == *h && if hit { d > *v } else { d < *v }) };+            if worse { seen.insert(key, (hit, d, at)); }+        }+    }+    let (mut col, mut near) = (Vec::new(), Vec::new());+    for ((p, q), (hit, d, at)) in seen {+        if hit { col.push(json!({"parts": [p, q], "overlapMm": r3(d), "at": at})); } else { near.push(json!({"parts": [p, q], "gapMm": r3(d), "at": at})); }+    }+    (col, near)+}++/// height_check: input {bodies, board?, args: {maxTopMm?, maxBottomMm?, regions?: [{rect, maxMm, side?, name?}], minGapMm?, limit?}}.+/// `board` names the parts with no body (they have no height, and the reply says the check is not complete).+pub fn height_check(input: &Value) -> Value {+    let args = &input["args"];+    let Some(bs) = input_boxes(input) else { return crate::input::refuse("no_bodies", "the input has no bodies: pass the bodies companion (<prefix>_bodies) as bodies") };+    let hs = heights(&bs);+    let num = |k: &str| args.get(k).and_then(|v| v.as_f64());+    let mut over = Vec::new();+    for (side, lim) in [("top", num("maxTopMm")), ("bottom", num("maxBottomMm"))] {+        let Some(lim) = lim else { continue };+        for h in hs.iter().filter(|h| h["side"] == side) {+            let mm = h["heightMm"].as_f64().unwrap_or(0.0);+            if mm > lim { over.push(json!({"ref": h["ref"], "side": side, "heightMm": mm, "limitMm": lim, "overByMm": r3(mm - lim)})); }+        }+    }+    // a region is a lid feature over part of the board (a boss, a rib, a display): parts under it must stay lower+    let mut region_hits = Vec::new();+    for (i, rg) in args.get("regions").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+        let rc: Vec<f64> = rg["rect"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).collect()).unwrap_or_default();+        let Some(max) = rg["maxMm"].as_f64() else { return json!({"success": false, "errorCode": "bad_args", "error": "each region is {rect:[x1,y1,x2,y2], maxMm, side?, name?}"}) };+        if rc.len() != 4 { return json!({"success": false, "errorCode": "bad_args", "error": "region.rect is [x1, y1, x2, y2] in mm"}); }+        let (rx0, ry0, rx1, ry1) = (rc[0].min(rc[2]), rc[1].min(rc[3]), rc[0].max(rc[2]), rc[1].max(rc[3]));+        let top = rg["side"].as_str() != Some("bottom");+        let name = rg["name"].as_str().map(|s| s.to_string()).unwrap_or(format!("region {}", i + 1));+        let mut worst: BTreeMap<&str, f64> = BTreeMap::new();+        for b in bs.iter().filter(|b| b.top == top && b.x1 > rx0 && b.x0 < rx1 && b.y1 > ry0 && b.y0 < ry1 && b.z1 > max) {+            let e = worst.entry(&b.part).or_insert(0.0);+            *e = e.max(b.z1);+        }+        for (p, h) in worst { region_hits.push(json!({"region": name, "ref": p, "heightMm": r3(h), "maxMm": max, "overByMm": r3(h - max)})); }+    }+    let gap = num("minGapMm").unwrap_or(0.0);+    let (collisions, too_close) = clashes(&bs, gap);+    // parts with no body have no height: say so, a height check that skips them is not a pass+    let no_body = no_body(input, &bs);+    let tallest = |side: &str| hs.iter().find(|h| h["side"] == side).cloned().unwrap_or(Value::Null);+    let ok = over.is_empty() && region_hits.is_empty() && collisions.is_empty() && too_close.is_empty();+    json!({"success": true, "ok": ok, "complete": no_body.is_empty(), "bodies": bs.len(), "parts": hs.len(), "tallest": {"top": tallest("top"), "bottom": tallest("bottom")},+        "overLimit": over, "regionHits": region_hits, "collisions": collisions, "tooClose": too_close, "minGapMm": gap,+        "noBody": no_body, "heights": hs.iter().take(args.get("limit").and_then(|v| v.as_u64()).unwrap_or(25) as usize).collect::<Vec<_>>(),+        "_hint": "Heights are each part's tallest body above the surface it sits on (bottom-side parts below the bottom). collisions and tooClose compare body boxes; the tool's component clearance DRC (run_drc) checks the real shapes. ok covers the parts that have bodies; complete is false while noBody lists parts with no 3D body and so no height: give them one (add_body) before trusting the check."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn bx(p: &str, top: bool, x0: f64, y0: f64, x1: f64, y1: f64, z0: f64, z1: f64) -> BodyBox {+        BodyBox { part: p.into(), top, x0, y0, x1, y1, z0, z1 }+    }++    #[test]+    fn the_companion_becomes_boxes() {+        let b = boxes(&[json!({"ref": "J1", "side": "bottom", "x0": 10.0, "y0": 5.0, "x1": 0.0, "y1": 0.0, "z0": 0.0, "z1": 4.1}), json!({"ref": "", "x0": 1})]);+        assert_eq!(b.len(), 1);+        assert!(!b[0].top && (b[0].z1 - 4.1).abs() < 1e-9 && (b[0].x1 - 10.0).abs() < 1e-9 && b[0].x0 == 0.0);+    }++    #[test]+    fn heights_take_each_parts_tallest_body() {+        let bs = vec![bx("J1", true, 0.0, 0.0, 10.0, 5.0, 0.0, 3.2), bx("J1", true, 0.0, 0.0, 10.0, 5.0, 4.0, 4.1), bx("R1", true, 20.0, 0.0, 21.0, 0.5, 0.0, 0.4)];+        let h = heights(&bs);+        assert_eq!(h[0]["ref"], "J1");+        assert_eq!(h[0]["heightMm"], 4.1);+        assert_eq!(h[1]["rect"], json!([20.0, 0.0, 21.0, 0.5]));+    }++    #[test]+    fn clashes_find_overlaps_near_misses_and_ignore_stacked_and_opposite_sides() {+        let bs = vec![+            bx("U1", true, 0.0, 0.0, 5.0, 5.0, 0.0, 1.0),+            bx("C1", true, 4.0, 1.0, 6.0, 2.0, 0.0, 0.5),   // overlaps U1 by 1 mm in x+            bx("C2", true, 5.2, 3.0, 6.0, 4.0, 0.0, 0.5),   // 0.2 mm from U1+            bx("C3", false, 1.0, 1.0, 2.0, 2.0, 0.0, 0.5),  // under U1, other side+            bx("D1", true, 1.0, 1.0, 2.0, 2.0, 1.5, 2.0),   // above U1's top+        ];+        let (col, near) = clashes(&bs, 0.3);+        assert_eq!(col.len(), 1);+        assert_eq!(col[0]["parts"], json!(["C1", "U1"]));+        assert_eq!(col[0]["overlapMm"], 1.0);+        assert_eq!(near.len(), 1);+        assert_eq!(near[0]["parts"], json!(["C2", "U1"]));+        assert_eq!(near[0]["gapMm"], 0.2);+        assert!(clashes(&bs, 0.0).1.is_empty());+    }++    #[test]+    fn height_check_reads_the_companion_and_names_parts_without_bodies() {+        let input = json!({"board": {"components": [{"ref": "U1"}, {"ref": "R7"}]},+                           "bodies": {"bodies": [{"ref": "U1", "side": "top", "x0": 0.0, "y0": 0.0, "x1": 5.0, "y1": 5.0, "z0": 0.0, "z1": 2.0}], "noBody": []},+                           "args": {"maxTopMm": 1.5}});+        let r = height_check(&input);+        assert_eq!(r["overLimit"][0]["ref"], "U1");+        assert_eq!(r["noBody"], json!(["R7"]));+        assert_eq!(r["complete"], json!(false));+        assert_eq!(height_check(&json!({"args": {}}))["errorCode"], "no_bodies");+    }+}+--- a/rust/vendor/adom-eda-checks/src/features.rs+++ b/rust/vendor/adom-eda-checks/src/features.rs@@ -0,0 +1,412 @@+//! Via plans computed from the board model: thermal vias in a part's exposed pad and a via fence along RF tracks or the+//! board edge (and the stitching grid behind board_features). From the Altium bridge's features.rs. Plans only: each+//! answers the vias as place_vias arguments, and the bridge places them.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn pt(v: &Value) -> (f64, f64) { (f(&v[0]), f(&v[1])) }++fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {+    let mut c = false;+    let n = poly.len();+    for i in 0..n {+        let (a, b) = (poly[i], poly[(i + n - 1) % n]);+        if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+    }+    c+}++fn seg_dist(px: f64, py: 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 { (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0) };+    ((px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2)).sqrt()+}++/// Copper that a stitching via on `net` must keep clear of: (kind, geometry, half-width) as segments and discs.+enum Ob { Seg((f64, f64), (f64, f64), f64), Disc(f64, f64, f64) }++fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) {+    // copper on another net. Lines on mechanical and overlay layers are not copper, and net-less copper tracks are a+    // hatched pour's pieces (Altium lists them with no net) that re-pour round a new via+    let other = |v: &Value| {+        let n = v["net"].as_str().unwrap_or("");+        let copper = v.get("layer").map(|l| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false)).unwrap_or(true);+        let piece = n.is_empty() && v.get("start").or(v.get("center")).is_some();+        copper && !piece && !n.eq_ignore_ascii_case(net)+    };+    let mut ob = Vec::new();+    let mut same_vias = Vec::new();+    for t in state["tracks"].as_array().cloned().unwrap_or_default() {+        if other(&t) { ob.push(Ob::Seg((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)); }+    }+    for a in state["arcs"].as_array().cloned().unwrap_or_default() {+        if !other(&a) { continue; }+        // an arc as short chords+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (mut a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+        let mut span = a2 - a1;+        if span <= 0.0 { span += 360.0; }+        let n = ((span / 10.0).ceil() as usize).max(1);+        let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+        for _ in 0..n { let b = a1 + span / n as f64; ob.push(Ob::Seg(p(a1), p(b), f(&a["width"]) / 2.0)); a1 = b; }+    }+    for v in state["vias"].as_array().cloned().unwrap_or_default() {+        let (x, y) = (f(&v["position"]["x"]), f(&v["position"]["y"]));+        if other(&v) { ob.push(Ob::Disc(x, y, f(&v["size"]) / 2.0)); } else { same_vias.push((x, y)); }+    }+    for p in state["pads"].as_array().cloned().unwrap_or_default() {+        // a pad on the net still gets clearance: a via in a pad is not stitching. Its circumscribed circle keeps it+        // conservative at any rotation.+        let r = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+        ob.push(Ob::Disc(f(&p["x"]), f(&p["y"]), r.max(f(&p["drill"]) / 2.0)));+    }+    (ob, same_vias)+}++/// Where stitching vias for `net` go: a grid at `pitch` inside the outline (or `region`), `edge` mm clear of the+/// board edge, `clearance` mm clear of copper on other nets and of every pad, half a pitch from vias the net already+/// has, at most `max`. Pure, for the tests.+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {+    plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true, &[])+}++/// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with+/// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance).+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,+                           extra: &[(f64, f64, f64)], avoid_parts: bool, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+    let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+    if outline.len() < 3 || pitch <= 0.0 { return vec![]; }+    let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());+    let (mut ob, same) = obstacles(state, net);+    for (x, y, r) in extra { ob.push(Ob::Disc(*x, *y, *r)); }+    // each part's pad box: a via between a chip's pad rows sits under its body+    let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = Default::default();+    if avoid_parts {+        for p in state["pads"].as_array().cloned().unwrap_or_default() {+            let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+            let h = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+            let (x, y) = (f(&p["x"]), f(&p["y"]));+            let b = boxes.entry(r.to_string()).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN));+            *b = (b.0.min(x - h), b.1.min(y - h), b.2.max(x + h), b.3.max(y + h));+        }+    }+    let (x0, x1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let mut cands = Vec::new();+    let mut y = y0 + pitch / 2.0;+    while y < y1 {+        let mut x = x0 + pitch / 2.0;+        while x < x1 { if inside(&area, x, y) { cands.push((x, y)); } x += pitch; }+        y += pitch;+    }+    keep_clear(state, &outline, &cands, pitch / 2.0, size, clearance, edge, max, &ob, &same, &boxes, keepouts)+}++/// The candidate via points that fit: inside the outline, `edge` from it, `clearance` from other copper (`ob`), out of+/// part boxes and keepouts, and `sep` from the net's vias and from each other. Pure.+#[allow(clippy::too_many_arguments)]+fn keep_clear(_state: &Value, outline: &[(f64, f64)], cands: &[(f64, f64)], sep: f64, size: f64, clearance: f64, edge: f64, max: usize, ob: &[Ob],+              same: &[(f64, f64)], boxes: &std::collections::BTreeMap<String, (f64, f64, f64, f64)>, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {+    let r = size / 2.0;+    let mut out: Vec<(f64, f64)> = Vec::new();+    for &(x, y) in cands {+        if out.len() >= max { break; }+        let ok = inside(outline, x, y)+            && (0..outline.len()).all(|k| seg_dist(x, y, outline[k], outline[(k + 1) % outline.len()]) >= edge + r)+            && ob.iter().all(|o| match o {+                Ob::Seg(a, b, hw) => seg_dist(x, y, *a, *b) - hw - r >= clearance,+                Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,+            })+            && same.iter().chain(out.iter()).all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= sep)+            && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3)+            && keepouts.iter().all(|k| x + r <= k.0 || x - r >= k.2 || y + r <= k.1 || y - r >= k.3);+        if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }+    }+    out+}++/// Via fence candidates: both sides of every copper track of `nets`, `offset` from its centreline, every `pitch` along+/// it; and with `edge_inset`, along the outline that far inside it. Pure.+pub fn fence_points(state: &Value, nets: &[String], pitch: f64, offset: f64, edge_inset: Option<f64>) -> Vec<(f64, f64)> {+    let mut v = Vec::new();+    for t in state["tracks"].as_array().cloned().unwrap_or_default() {+        let n = t["net"].as_str().unwrap_or("");+        if !nets.iter().any(|x| x.eq_ignore_ascii_case(n)) || !t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false) { continue; }+        let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));+        let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+        if len <= 0.0 { continue; }+        let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+        let k = (len / pitch).floor() as usize;+        for i in 0..=k {+            let s = if k == 0 { len / 2.0 } else { i as f64 * len / k as f64 };+            for side in [1.0, -1.0] { v.push((a.0 + ux * s - uy * offset * side, a.1 + uy * s + ux * offset * side)); }+        }+    }+    if let Some(inset) = edge_inset {+        let ring = crate::coppermap::outline_ring(state);+        let n = ring.len();+        // inward is left of each edge on a counterclockwise ring+        let area: f64 = (0..n).map(|k| ring[k].0 * ring[(k + 1) % n].1 - ring[(k + 1) % n].0 * ring[k].1).sum();+        let sgn = if area > 0.0 { 1.0 } else { -1.0 };+        let mut carry: f64 = 0.0;+        for k in 0..n {+            let (a, b) = (ring[k], ring[(k + 1) % n]);+            let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();+            if len <= 0.0 { continue; }+            let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);+            // clear of the corners by the inset, where an inward step from one edge lands on the next+            let mut s = carry.max(inset);+            while s <= len - inset { v.push((a.0 + ux * s - uy * inset * sgn, a.1 + uy * s + ux * inset * sgn)); s += pitch; }+            carry = (s - len).max(0.0);+        }+    }+    v+}++/// The board's preferred via (diameter, drill) in mm. A standard rule gives it as `{kind: "via", sizeMm, drillMm}`; Altium's+/// Routing Via descriptor is read too, e.g. "Routing Via (MinHoleWidth=0.3mm) ... (PreferredHoleWidth=0.3mm) ...+/// (PreferedWidth=0.5mm) (All)" (Altium's own spelling). Pure.+pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> {+    if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))+        .find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("via")).unwrap_or(false) && r["sizeMm"].as_f64().is_some() && r["drillMm"].as_f64().is_some()) {+        return Some((f(&r["sizeMm"]), f(&r["drillMm"])));+    }+    let num = |d: &str, key: &str| -> Option<f64> {+        let i = d.find(&format!("({key}="))? + key.len() + 2;+        let s = &d[i..d[i..].find(')')? + i];+        let (v, unit) = s.trim_end_matches(char::is_alphabetic).parse::<f64>().ok().map(|v| (v, &s[s.trim_end_matches(char::is_alphabetic).len()..]))?;+        Some(if unit.eq_ignore_ascii_case("mil") { v * 0.0254 } else { v })+    };+    let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Routing Via")).unwrap_or(false)).collect();+    // the board-wide rule (scope All), highest priority first+    rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));+    let d = rs.first()?["descriptor"].as_str()?;+    Some((num(d, "PreferedWidth").or(num(d, "PreferredWidth"))?, num(d, "PreferredHoleWidth")?))+}+++/// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus+/// the via's radius, centred. Pure.+pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {+    let (ux, uy) = (sx / 2.0 - inset - size / 2.0, sy / 2.0 - inset - size / 2.0);+    if ux < 0.0 || uy < 0.0 { return vec![]; }+    let (nx, ny) = ((2.0 * ux / pitch).floor() as i64 + 1, (2.0 * uy / pitch).floor() as i64 + 1);+    let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());+    let mut out = Vec::new();+    for i in 0..nx { for j in 0..ny {+        let (lx, ly) = ((i as f64 - (nx - 1) as f64 / 2.0) * pitch, (j as f64 - (ny - 1) as f64 / 2.0) * pitch);+        out.push((((x + lx * c - ly * s) * 1000.0).round() / 1000.0, ((y + lx * s + ly * c) * 1000.0).round() / 1000.0));+    } }+    out+}++/// The via each plan uses: `size`/`drill` from args, else the board's via rule (`rules`), else 0.6/0.3; with where it came from.+fn via_of(input: &Value) -> (f64, f64, &'static str) {+    let args = &input["args"];+    let style = via_style(&crate::input::rules(input));+    let from = if args.get("size").is_some() { "size/drill" } else if style.is_some() { "the board's via rule" } else { "default 0.6/0.3" };+    (num(args, "size", style.map(|s| s.0).unwrap_or(0.6)), num(args, "drill", style.map(|s| s.1).unwrap_or(0.3)), from)+}++/// The plan as place_vias arguments.+fn apply_of(pts: &[(f64, f64)], net: &str, size: f64, drill: f64) -> Value {+    json!({"verb": "place_vias", "args": {"vias": pts.iter().map(|(x, y)| json!({"net": net, "x": x, "y": y, "size": size, "drill": drill})).collect::<Vec<_>>()}})+}++/// thermal_vias: input {board, rules?, args: {ref, pad?, pitchMm?, size?, drill?, insetMm?}}. A grid of vias inside the part's+/// exposed pad (the one named, else its largest), clear of vias already there. Plans only: `apply` is place_vias' arguments.+pub fn thermal_vias(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(rf) = args["ref"].as_str() else { return crate::input::refuse("bad_args", "ref names the part (e.g. U3)") };+    let pads: Vec<Value> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(rf)).unwrap_or(false)).collect();+    if pads.is_empty() { return crate::input::refuse("part_not_found", &format!("{rf} has no pads on this board")); }+    let pad = match args["pad"].as_str() {+        Some(n) => pads.iter().find(|p| p["name"].as_str().map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false)).cloned(),+        None => pads.iter().max_by(|a, b| (f(&a["sizeX"]) * f(&a["sizeY"])).partial_cmp(&(f(&b["sizeX"]) * f(&b["sizeY"]))).unwrap()).cloned(),+    };+    let Some(pad) = pad else { return json!({"success": false, "errorCode": "no_such_pad", "error": "that pad is not on the part", "pads": pads.iter().map(|p| p["name"].clone()).collect::<Vec<_>>()}) };+    let net = pad["net"].as_str().unwrap_or("").to_string();+    if net.is_empty() { return crate::input::refuse("pad_has_no_net", &format!("{rf}.{} is on no net: thermal vias would float", pad["name"].as_str().unwrap_or(""))); }+    let (size, drill, from) = via_of(input);+    let pitch = num(args, "pitchMm", (size + 0.5).max(1.0));+    let inset = num(args, "insetMm", 0.1);+    let grid = thermal_grid(f(&pad["x"]), f(&pad["y"]), f(&pad["sizeX"]), f(&pad["sizeY"]), f(&pad["rotation"]), pitch, size, inset);+    // keep clear of vias already there: the Bluetooth Sentinel's U4 pad had 8, and a grid laid over them broke Altium's+    // hole-to-hole rule 8 times (2026-10-26)+    let vias: Vec<(f64, f64, f64)> = st["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]))).collect();+    let half = f(&pad["sizeX"]).max(f(&pad["sizeY"])) / 2.0;+    let existing = vias.iter().filter(|(x, y, _)| (x - f(&pad["x"])).abs() <= half && (y - f(&pad["y"])).abs() <= half).count();+    let pts: Vec<(f64, f64)> = grid.iter().cloned().filter(|(x, y)| vias.iter().all(|(vx, vy, vs)| ((x - vx).powi(2) + (y - vy).powi(2)).sqrt() >= size / 2.0 + vs / 2.0 + 0.2)).collect();+    let planned = json!({"part": rf, "pad": pad["name"], "net": net, "padMm": [pad["sizeX"], pad["sizeY"]], "vias": pts.len(), "existingVias": existing, "skippedNearExisting": grid.len() - pts.len(), "pitchMm": pitch,+                         "via": {"sizeMm": size, "drillMm": drill, "from": from}, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+    if pts.is_empty() {+        return json!({"success": existing > 0, "errorCode": if existing > 0 { "already_has_vias" } else { "pad_too_small" }, "error": if existing > 0 { "the pad's existing vias leave no room for more at this pitch" } else { "no via fits inside the pad at this size and inset" }, "planned": planned});+    }+    json!({"success": true, "planned": planned, "apply": apply_of(&pts, &net, size, drill),+        "_hint": "A plan only: run place_vias with apply.args. Vias inside the pad carry its heat to the other layers' copper: pour the net on the far side (apply_pours) so they have somewhere to go. Open vias in a pad wick solder: ask the fab to tent or plug them, or use 0.3 mm or smaller drills. run_drc afterwards."})+}++/// via_fence: input {board, rules?, args: {viaNet, nets?, edge?, pitchMm?, freqGHz?, offsetMm?, edgeInsetMm?, size?, drill?,+/// clearance?}}. Vias of viaNet both sides of the named nets' tracks and/or round the board edge, clear of other copper.+/// Plans only: `apply` is place_vias' arguments.+pub fn via_fence(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let Some(via_net) = args["viaNet"].as_str() else { return crate::input::refuse("bad_args", "viaNet is the net the fence ties to, usually GND") };+    if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(via_net)).unwrap_or(false))).unwrap_or(false) {+        return json!({"success": false, "errorCode": "no_such_net", "error": format!("{via_net} is not a net on this board"), "nets": st["nets"]});+    }+    let nets: Vec<String> = args.get("nets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();+    let edge_on = args.get("edge").and_then(|b| b.as_bool()).unwrap_or(false);+    if nets.is_empty() && !edge_on { return crate::input::refuse("bad_args", "name nets to fence (e.g. [\"ANT_RF\"]) and/or edge:true for a fence round the board edge"); }+    // the via the board's own via rule prefers, so the fence passes its DRC (0.6/0.3 broke the Bluetooth Sentinel+    // example's 0.5/0.3 rule on every via)+    let (size, drill, from) = via_of(input);+    let clearance = num(args, "clearance", 0.2);+    // lambda/20 in FR-4 (effective er about 3.4) at the highest frequency, else 2 mm+    let pitch = match args.get("freqGHz").and_then(|v| v.as_f64()) { Some(g) if g > 0.0 => (299.79 / g / 3.4f64.sqrt() / 20.0).min(num(args, "pitchMm", f64::MAX)), _ => num(args, "pitchMm", 2.0) };+    let widest = st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| nets.iter().any(|n| t["net"].as_str().map(|s| s.eq_ignore_ascii_case(n)).unwrap_or(false))).map(|t| f(&t["width"])).fold(0.0, f64::max);+    let offset = num(args, "offsetMm", widest / 2.0 + clearance + size / 2.0 + 0.1);+    let inset = if edge_on { Some(num(args, "edgeInsetMm", size / 2.0 + 0.5)) } else { None };+    let cands = fence_points(&st, &nets, pitch, offset, inset);+    let outline: Vec<(f64, f64)> = crate::coppermap::outline_ring(&st);+    let (ob, same) = obstacles(&st, via_net);+    let pts = keep_clear(&st, &outline, &cands, pitch * 0.6, size, clearance, 0.3, 5000, &ob, &same, &Default::default(), &[]);+    let planned = json!({"vias": pts.len(), "candidates": cands.len(), "via": {"sizeMm": size, "drillMm": drill, "from": from}, "pitchMm": (pitch * 1000.0).round() / 1000.0,+                         "offsetMm": (offset * 1000.0).round() / 1000.0, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});+    if pts.is_empty() {+        return json!({"success": false, "errorCode": "no_room", "error": "no fence via fits", "planned": planned, "_hint": "Lower clearance or size, change offsetMm, or check the nets have tracks."});+    }+    json!({"success": true, "planned": planned, "apply": apply_of(&pts, via_net, size, drill),+        "_hint": "A plan only: run place_vias with apply.args, then repour so pours join the vias, then run_drc. candidates - vias are the spots where other copper, a pad or the edge was too close."})+}++/// The four corner positions of the outline's bounding box, `inset` mm in.+pub fn corners(state: &Value, inset: f64) -> Vec<(f64, f64)> {+    let o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+    if o.is_empty() { return vec![]; }+    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)));+    vec![(x0 + inset, y0 + inset), (x1 - inset, y0 + inset), (x1 - inset, y1 - inset), (x0 + inset, y1 - inset)]+}++pub fn region_of(v: Option<&Value>) -> Option<Vec<(f64, f64)>> {+    let a = v?.as_array()?;+    let r: Vec<(f64, f64)> = a.iter().map(pt).collect();+    if r.len() >= 3 { Some(r) } else { None }+}++pub fn summary(points: &[(f64, f64)]) -> Value {+    json!(points.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>())+}++fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(d) }++#[cfg(test)]+mod tests {+    use super::*;++    fn board() -> Value {+        json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+               "tracks": [{"start": {"x": 0.0, "y": 5.0}, "end": {"x": 20.0, "y": 5.0}, "width": 0.25, "net": "SIG"}],+               "arcs": [], "vias": [{"position": {"x": 2.5, "y": 2.5}, "size": 0.6, "net": "GND"}],+               "pads": [{"x": 17.5, "y": 7.5, "sizeX": 1.0, "sizeY": 1.0, "drill": 0.0, "net": "GND"}]})+    }++    #[test]+    fn stitching_keeps_clear_of_other_nets_pads_edges_and_existing_vias() {+        let p = plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 100);+        // the 5 mm grid is 2.5/7.5/12.5/17.5 by 2.5/7.5; (2.5,2.5) has a GND via already, (17.5,7.5) is a pad+        assert_eq!(p, vec![(7.5, 2.5), (12.5, 2.5), (17.5, 2.5), (2.5, 7.5), (7.5, 7.5), (12.5, 7.5)]);+        // a track of another net through the grid row blocks it+        let mut b = board();+        b["tracks"][0]["start"]["y"] = json!(2.5);+        b["tracks"][0]["end"]["y"] = json!(2.5);+        assert!(plan_stitching(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100).iter().all(|p| p.1 != 2.5));+        // a region limits it, max caps it+        let reg = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+        assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, Some(&reg), 100), vec![(7.5, 2.5), (2.5, 7.5), (7.5, 7.5)]);+        assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 2).len(), 2);+    }++    #[test]+    fn stitching_stays_off_holes_and_out_from_under_parts() {+        let mut b = board();+        // a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body+        b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},+                           {"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true, &[(15.0, 0.0, 20.0, 10.0)]);+        assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));+        assert!(p.iter().all(|q| q.0 < 15.0), "the keepout at x 15..20 takes no via");+        let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false, &[]);+        assert!(p.contains(&(7.5, 7.5)));+    }++    #[test]+    fn corner_holes_sit_inset_from_the_outline() {+        assert_eq!(corners(&board(), 3.0), vec![(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (3.0, 7.0)]);+    }+    #[test]+    fn fence_points_line_both_sides_of_a_track_and_the_edge() {+        let st = json!({"tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 0.0, "y": 5.0}, "end": {"x": 10.0, "y": 5.0}}],+                        "outline": [[-5, 0], [15, 0], [15, 10], [-5, 10]],+                        "outlineEdges": [{"kind": "line", "start": [-5.0, 0.0]}, {"kind": "line", "start": [15.0, 0.0]}, {"kind": "line", "start": [15.0, 10.0]}, {"kind": "line", "start": [-5.0, 10.0]}]});+        let p = fence_points(&st, &["RF".to_string()], 2.0, 1.0, None);+        assert_eq!(p.len(), 12);+        assert!(p.contains(&(0.0, 6.0)) && p.contains(&(10.0, 4.0)));+        let e = fence_points(&st, &[], 5.0, 1.0, Some(0.5));+        assert!(e.iter().all(|(x, y)| *x >= -4.5 - 1e-9 && *x <= 14.5 + 1e-9 && *y >= 0.5 - 1e-9 && *y <= 9.5 + 1e-9), "{e:?}");+        assert!(e.len() >= 10, "{e:?}");+    }+    #[test]+    fn via_style_reads_altiums_descriptor() {+        let rules = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (MinHoleWidth=0.3mm) (MaxHoleWidth=0.3mm) (PreferredHoleWidth=0.3mm) (MinWidth=0.5mm) (MaxWidth=0.5mm) (PreferedWidth=0.5mm) (All)"})];+        assert_eq!(via_style(&rules), Some((0.5, 0.3)));+        let mil = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=12mil) (PreferedWidth=24mil) (All)"})];+        let (s, d) = via_style(&mil).unwrap();+        assert!((s - 0.6096).abs() < 1e-9 && (d - 0.3048).abs() < 1e-9);+    }+    #[test]+    fn thermal_grid_fills_an_exposed_pad() {+        // a 3.3 mm QFN pad, 0.6 mm vias, 1.1 mm pitch: a 3 x 3 grid centred on the pad+        let g = thermal_grid(10.0, 10.0, 3.3, 3.3, 0.0, 1.1, 0.6, 0.1);+        assert_eq!(g.len(), 9);+        assert!(g.contains(&(10.0, 10.0)) && g.contains(&(8.9, 11.1)));+        assert!(thermal_grid(0.0, 0.0, 0.5, 0.5, 0.0, 1.0, 0.6, 0.1).is_empty());+        // turned 90 degrees, a long pad's grid turns with it+        let r = thermal_grid(0.0, 0.0, 4.0, 1.5, 90.0, 1.1, 0.6, 0.1);+        assert!(r.iter().all(|(x, _)| x.abs() < 0.5));+    }++    #[test]+    fn via_style_takes_the_standard_rule_first() {+        let rules = vec![json!({"kind": "via", "sizeMm": 0.45, "drillMm": 0.2}),+                         json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"})];+        assert_eq!(via_style(&rules), Some((0.45, 0.2)));+    }++    #[test]+    fn the_verbs_answer_place_vias_plans() {+        let st = json!({"nets": ["GND", "RF"], "outline": [[0, 0], [30, 0], [30, 20], [0, 20]],+            "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "line", "start": [30.0, 20.0]}, {"kind": "line", "start": [0.0, 20.0]}],+            "tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 5.0, "y": 10.0}, "end": {"x": 25.0, "y": 10.0}}], "arcs": [], "vias": [],+            "pads": [{"ref": "U1", "name": "EP", "x": 15.0, "y": 4.0, "sizeX": 3.3, "sizeY": 3.3, "rotation": 0.0, "drill": 0.0, "net": "GND", "layer": "F.Cu"}]});+        let t = thermal_vias(&json!({"board": st, "rules": [{"kind": "via", "sizeMm": 0.6, "drillMm": 0.3}], "args": {"ref": "U1"}}));+        assert_eq!(t["success"], json!(true), "{t}");+        assert_eq!(t["planned"]["vias"], 9);+        assert_eq!(t["apply"]["verb"], "place_vias");+        assert_eq!(t["apply"]["args"]["vias"][0]["net"], "GND");+        assert_eq!(t["planned"]["via"]["from"], "the board's via rule");+        let v = via_fence(&json!({"board": st, "args": {"viaNet": "GND", "nets": ["RF"], "pitchMm": 2.0}}));+        assert_eq!(v["success"], json!(true), "{v}");+        assert!(v["planned"]["vias"].as_u64().unwrap() >= 10);+        assert_eq!(v["apply"]["args"]["vias"].as_array().unwrap().len() as u64, v["planned"]["vias"].as_u64().unwrap());+        assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");+        assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");+    }+}+--- a/rust/vendor/adom-eda-checks/src/placement.rs+++ b/rust/vendor/adom-eda-checks/src/placement.rs@@ -0,0 +1,152 @@+//! A placement review before routing: parts whose courtyards (pads and body, grown by a margin) overlap on the same+//! side, parts off the board or over its edge, pads too close to the edge for assembly and depanelling, and parts on a+//! mounting hole. From the Altium bridge's placement.rs.+use serde_json::{json, Value};+use std::collections::BTreeMap;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+type Rect = (f64, f64, f64, f64);++fn hit(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }+fn grow(r: Rect, d: f64) -> Rect { (r.0 - d, r.1 - d, r.2 + d, r.3 + d) }++/// Each part's box (its pads, and its 3D body's box when it has one) and side. Pure.+pub fn part_boxes(st: &Value, bodies: &BTreeMap<String, Rect>) -> BTreeMap<String, (bool, Rect)> {+    let mut m: BTreeMap<String, (bool, Rect)> = BTreeMap::new();+    let side: BTreeMap<String, bool> = st["components"].as_array().cloned().unwrap_or_default().iter()+        .filter_map(|c| Some((c["ref"].as_str()?.to_string(), c["side"] == "bottom"))).collect();+    for p in st["pads"].as_array().cloned().unwrap_or_default() {+        let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+        let (c, s) = (f(&p["rotation"]).to_radians().cos().abs(), f(&p["rotation"]).to_radians().sin().abs());+        let (hx, hy) = ((f(&p["sizeX"]) * c + f(&p["sizeY"]) * s) / 2.0, (f(&p["sizeX"]) * s + f(&p["sizeY"]) * c) / 2.0);+        let b = (f(&p["x"]) - hx, f(&p["y"]) - hy, f(&p["x"]) + hx, f(&p["y"]) + hy);+        let e = m.entry(r.to_string()).or_insert((side.get(r).copied().unwrap_or(false), b));+        e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3));+    }+    for (r, b) in bodies {+        if let Some(e) = m.get_mut(r) { e.1 = (e.1 .0.min(b.0), e.1 .1.min(b.1), e.1 .2.max(b.2), e.1 .3.max(b.3)); }+    }+    m+}++/// The review. `ring` is the outline (mm); holes are (x, y, radius) of the board's own drilled, part-less pads. Pure.+pub fn review(boxes: &BTreeMap<String, (bool, Rect)>, pads: &[Value], ring: &[(f64, f64)], holes: &[(f64, f64, f64)], margin: f64, edge: f64) -> Value {+    let (bx0, bx1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (by0, by1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let inside = |x: f64, y: f64| {+        let mut c = false;+        for k in 0..ring.len() {+            let (a, b) = (ring[k], ring[(k + 1) % ring.len()]);+            if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+        }+        c+    };+    let dist = |x: f64, y: 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 (mut overlaps, mut off, mut near, mut on_holes) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());+    let list: Vec<(&String, &(bool, Rect))> = boxes.iter().collect();+    for i in 0..list.len() {+        for j in i + 1..list.len() {+            let ((ra, (sa, a)), (rb, (sb, b))) = (list[i], list[j]);+            if sa == sb && hit(grow(*a, margin), grow(*b, margin)) {+                let ov = (a.2.min(b.2) - a.0.max(b.0)).min(a.3.min(b.3) - a.1.max(b.1));+                overlaps.push(json!({"parts": [ra, rb], "side": if *sa { "bottom" } else { "top" }, "overlapMm": r3(ov + 2.0 * margin)}));+            }+        }+    }+    // on or off the board by its pads' centres: a box's corners fall outside a round board's edge while the part sits+    // well inside it (the Bluetooth Sentinel's J1 read as off the board)+    let mut pad_in: BTreeMap<&str, (usize, usize)> = BTreeMap::new();+    for p in pads {+        let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+        let e = pad_in.entry(r).or_insert((0, 0));+        e.0 += 1;+        if inside(f(&p["x"]), f(&p["y"])) { e.1 += 1; }+    }+    let _ = (bx0, bx1, by0, by1);+    for (r, (_, b)) in boxes {+        let (n, n_in) = pad_in.get(r.as_str()).copied().unwrap_or((0, 0));+        if n > 0 && n_in == 0 { off.push(json!({"ref": r, "where": "off the board"})); continue; }+        if n_in < n { off.push(json!({"ref": r, "where": "over the board edge"})); continue; }+        for (x, y, hr) in holes { if hit(*b, (x - hr, y - hr, x + hr, y + hr)) { on_holes.push(json!({"ref": r, "hole": [r3(*x), r3(*y)]})); } }+    }+    // pads near the edge: the closest pad of each part+    let mut closest: BTreeMap<String, f64> = BTreeMap::new();+    for p in pads {+        let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+        // the pad's four corners, turned with it: inside the outline their distance counts, outside it is negative+        let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());+        let (hx, hy) = (f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+        let d = [(-hx, -hy), (hx, -hy), (hx, hy), (-hx, hy)].iter().map(|(u, v)| {+            let (x, y) = (f(&p["x"]) + u * c - v * s, f(&p["y"]) + u * s + v * c);+            if inside(x, y) { dist(x, y) } else { -dist(x, y) }+        }).fold(f64::MAX, f64::min);+        let e = closest.entry(r.to_string()).or_insert(f64::MAX);+        if d < *e { *e = d; }+    }+    for (r, d) in &closest {+        if *d < edge && !off.iter().any(|o| o["ref"] == json!(r)) { near.push(json!({"ref": r, "padToEdgeMm": r3(*d)})); }+    }+    let ok = overlaps.is_empty() && off.is_empty() && on_holes.is_empty();+    json!({"parts": boxes.len(), "ok": ok, "courtyardOverlaps": overlaps, "offOrOverEdge": off, "onMountingHoles": on_holes, "nearEdge": near, "marginMm": margin, "edgeMm": edge})+}++/// placement_check: input {board, bodies?, args: {marginMm?, edgeMm?}}. Without bodies the parts' boxes are their pads+/// only, and the reply says so (`bodiesUsed: false`).+pub fn placement_check(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let ring = crate::coppermap::outline_ring(&st);+    if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); }+    // the parts' 3D bodies (silk_state's 'body' box takes in the designator text and made every neighbour overlap)+    let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();+    let given = crate::bodies::input_boxes(input);+    for b in given.clone().unwrap_or_default() {+        let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));+        *e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));+    }+    let boxes = part_boxes(&st, &bodies);+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let holes: Vec<(f64, f64, f64)> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| r.is_empty()).unwrap_or(true))+        .map(|p| (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]).max(f(&p["drill"])) / 2.0)).collect();+    let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+    let mut out = review(&boxes, &pads, &ring, &holes, num("marginMm", 0.1), num("edgeMm", 0.5));+    out["success"] = json!(true);+    out["bodiesUsed"] = json!(given.is_some());+    out["_hint"] = json!("courtyardOverlaps: parts closer than twice marginMm (their pads and bodies): move them apart (move_parts, find_space). offOrOverEdge parts must come onto the board; nearEdge pads risk the router or the V-cut (JLCPCB: 0.4 mm from a V-cut, 0.2 from a routed edge). onMountingHoles: a screw head lands on the part. Without bodies (bodiesUsed false) a part's box is its pads only: pass the bodies companion to count overhanging bodies.");+    out+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn placement_review_finds_each_problem() {+        let ring = vec![(0.0, 0.0), (40.0, 0.0), (40.0, 20.0), (0.0, 20.0)];+        let mut boxes = BTreeMap::new();+        boxes.insert("U1".to_string(), (false, (5.0, 5.0, 10.0, 10.0)));+        boxes.insert("C1".to_string(), (false, (10.2, 5.0, 11.0, 6.0)));   // 0.2 mm from U1: inside 2 x 0.25+        boxes.insert("C2".to_string(), (true, (10.2, 5.0, 11.0, 6.0)));    // the same spot on the bottom: fine+        boxes.insert("J1".to_string(), (false, (38.0, 8.0, 42.0, 12.0)));  // over the edge+        boxes.insert("R9".to_string(), (false, (50.0, 5.0, 51.0, 6.0)));   // off the board+        boxes.insert("R2".to_string(), (false, (1.5, 15.5, 2.5, 16.5)));   // on the hole at (2, 16)+        let pad = |r: &str, x: f64, y: f64| json!({"ref": r, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6});+        let pads = vec![pad("C1", 10.6, 5.5), pad("U1", 5.2, 0.6), pad("J1", 39.0, 10.0), pad("J1", 41.0, 10.0), pad("R9", 50.5, 5.5)];+        let r = review(&boxes, &pads, &ring, &[(2.0, 16.0, 1.6)], 0.25, 0.5);+        assert_eq!(r["courtyardOverlaps"].as_array().unwrap().len(), 1);+        assert_eq!(r["courtyardOverlaps"][0]["parts"], json!(["C1", "U1"]));+        let off: Vec<String> = r["offOrOverEdge"].as_array().unwrap().iter().map(|o| format!("{} {}", o["ref"].as_str().unwrap(), o["where"].as_str().unwrap())).collect();+        assert!(off.contains(&"J1 over the board edge".to_string()) && off.contains(&"R9 off the board".to_string()));+        assert_eq!(r["onMountingHoles"][0]["ref"], "R2");+        assert_eq!(r["nearEdge"][0]["ref"], "U1");+        assert_eq!(r["ok"], json!(false));+    }+}+--- a/rust/vendor/adom-eda-checks/src/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,32 +1,60 @@⋯ 6 unchanged lines ⋯ //! Every check takes one input bundle, `{board, stack?, pours?, rules?, silk?, fab?, bodies?, sheetParts?, netlist?, args?}` (see `input`). use serde_json::Value; +pub mod bodies; pub mod coppermap; pub mod current; pub mod fab;+pub mod features; pub mod geom; pub mod impedance; pub mod input;+pub mod placement; pub mod writers; +/// This engine's version. Every reply carries it as `engine` ("adom-eda-checks 0.3.0"), so a bridge never writes it by hand+/// (kicad-lane's replies said 0.1.2 for three releases after re-vendoring).+pub const VERSION: &str = env!("CARGO_PKG_VERSION");+ /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "impedance", "return_path"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"]; -/// Run one check or writer by its standard name.+/// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {+    let mut out = answer(check, input);+    if let Some(o) = out.as_object_mut() { o.entry("engine").or_insert(Value::String(format!("adom-eda-checks {VERSION}"))); }+    out+}++fn answer(check: &str, input: &Value) -> Value {     if let Some(out) = writers::run(check, input) { return out; }     match check {         "copper_balance" => coppermap::copper_balance(input),         "current_capacity" => current::current_capacity(input),         "fab_check" => fab::fab_check(input),+        "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),+        "placement_check" => placement::placement_check(input),         "return_path" => coppermap::return_path(input),+        "thermal_vias" => features::thermal_vias(input),+        "via_fence" => features::via_fence(input),         _ => input::refuse("not_supported", &format!("{check} is not a verb this engine answers (checks: {}; writers: {})", CHECKS.join(", "), writers::WRITERS.join(", "))),     } }++#[cfg(test)]+mod tests {+    #[test]+    fn every_reply_names_the_engine_version() {+        let r = super::run("copper_balance", &serde_json::json!({}));+        assert_eq!(r["engine"], format!("adom-eda-checks {}", super::VERSION));+        assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);+    }+}+--- a/skills/kicad-board-checks/SKILL.md+++ b/skills/kicad-board-checks/SKILL.md@@ -1,144 +1,149 @@ --- 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, reference designators placed clear of the pads, the BOM (one line per MPN, JLCPCB, Mouser and DigiKey files), JLCPCB assembly tiers, a JLCPCB order in one call, 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, bom, bill of materials, jlcpcb bom, cpl, pick and place, lcsc numbers, missing mpn, jlcpcb assembly, economic or standard assembly, order from jlcpcb, gerber zip, order the board, designators, silkscreen labels, reference designators on pads, label placement, panelize, mouse bites, tabs, v-score, panel, build a board, test board, read schematic file, length tune, length matching, serpentine, meander, match length, skew, silkscreen prep, silk on pads, component class, room, keep parts together, library views, picture of a part, symbol and footprint images, review a library part, pin to pad map, hide a layer, layer visibility.+  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, a placement review (overlaps, parts off the board, on a mounting hole), 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, reference designators placed clear of the pads, the BOM (one line per MPN, JLCPCB, Mouser and DigiKey files), JLCPCB assembly tiers, a JLCPCB order in one call, 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, placement check, parts overlapping, part off the board, 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, bom, bill of materials, jlcpcb bom, cpl, pick and place, lcsc numbers, missing mpn, jlcpcb assembly, economic or standard assembly, order from jlcpcb, gerber zip, order the board, designators, silkscreen labels, reference designators on pads, label placement, panelize, mouse bites, tabs, v-score, panel, build a board, test board, read schematic file, length tune, length matching, serpentine, meander, match length, skew, silkscreen prep, silk on pads, component class, room, keep parts together, library views, picture of a part, symbol and footprint images, review a library part, pin to pad map, hide a layer, layer visibility. ---  # Board checks -Fourteen verbs check a board and, where a fix is mechanical, apply it. They answer the EDA Bridge+Fifteen 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. ⋯ 5 unchanged lines ⋯ ## 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`:+the board model, `kicad_layer_stack`, `kicad_pour_state` and `kicad_bodies`. Every reply names it in+`engine` ("adom-eda-checks 0.4.0"):  | Verb | Answers | Fix it with | |---|---|---|⋯ 2 unchanged lines ⋯ | `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 |+| `kicad_height_check {maxTopMm, regions}` | heights from the 3D models (`kicad_bodies`), parts over a limit or under a lid feature, bodies that collide or come closer than `minGapMm`; `complete` is false while `noBody` lists parts without a model | a lower part, or a body for each `noBody` part |+| `kicad_placement_check {marginMm?, edgeMm?}` | before routing: parts whose pads and bodies overlap on one side, parts off or over the edge, pads near the edge, parts on a mounting hole (`bodiesUsed` says the bodies were read) | `kicad_find_space`, `kicad_move_footprint` |  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.⋯ 5 unchanged lines ⋯ | `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` | ⋯ 4 unchanged lines ⋯ 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:+The engine plans both (`dryRun:true` returns `planned.points`, and `planned.via.from` says where the via+size came from), then the bridge places 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+Size and drill default to the project's Default net class via; the fence's clearance to the board's+largest clearance rule. 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. ⋯ 81 unchanged lines ⋯ - `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.+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,1657 +1,1663 @@⋯ 192 unchanged lines ⋯         "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",+            "check_orientation", "find_space", "height_check", "placement_check", "pin_pad_check", "auto_classes", "thermal_vias", "via_fence", "board_report",             "bom", "assembly_check", "jlcpcb_order", "footprint_from_land_pattern", "silk_designators",             "read_sheet_file", "build_board", "test_board", "length_tune", "silkscreen_prep", "component_classes", "build_schematic",             "library_views")},⋯ 1307 unchanged lines ⋯         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"))+        # 1.0.72: height_check, placement_check, thermal_vias and via_fence answered by the standard's engine, which names itself+        eng = lambda r: str(r.get("engine") or "").startswith("adom-eda-checks ")+        r = C("height_check", dict(fb, maxTopMm=20), 240); own("height_check", r, ok(r) and r.get("heights") and eng(r), "no heights, or not from the engine")+        r = C("placement_check", fb, 240)+        own("placement_check", r, ok(r) and isinstance(r.get("courtyardOverlaps"), list) and r.get("bodiesUsed") is True and (r.get("parts") or 0) > 10 and eng(r),+            "no placement review with the bodies, or not from the engine")         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,+        own("thermal_vias", {"apply": r, "again": r2}, ok(r) and eng(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")+        own("via_fence", r, ok(r) and eng(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")         # 1.0.68: ordering, land patterns, designators⋯ 133 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+

Comments

No comments yet.

Log in to comment.