← All Pull Requests

kicad-lane: 1.0.74, generate_body and plan_route from engine 0.6.0 #35

Merged opened by Colby Knox 2026-10-28

kicad-lane: 1.0.74. KiCad's last two standard verbs outside the app tier, from the standard's engine 0.6.0 (standard 0.9.0, e7b68d92, vendored unchanged).

  • kicad_generate_body (std #20). The engine's generate_body writer gives STEP text, and the bridge writes it:
    • to outputPath, outputDir/.step, or .3dshapes/.step beside libraryPath, refusing to overwrite without overwrite:true;
    • with footprintName it attaches the body through kicad_add_body (verbs::call), so the verb is catalogued File.
    • Live check: a SOIC-8 marked LM358 on a land-pattern footprint has bboxMm z 1.75, attached, and kicad_library_views renders it seated at 1.78 mm.
  • kicad_plan_route (std #21). The engine's plan_route runs on the board model with the board's clearance rules.
    • widthMm / viaSizeMm / viaDrillMm default to the project's Default net class.
    • routeNet comes back with filePath, frame and expectedRevision added, ready for kicad_route_net.
    • Live check, ESC board: /DRV_GHC R12.1 to U4.13 is 2.51 mm on F.Cu. kicad_route_net took routeNet unchanged in a dry run with no new DRC error, and unconnected went from 258 to 257.
    • default_via now shares default_class with plan_route.
  • Battery:
    • generate_body (written, attached, rendered at 1.75 mm) and plan_route (found, routeNet ready) in phase 10;
    • with --close-open-kicad the preflight now closes the disposable editors. Before, an editor I'd left open made show_2d_board refuse in phase 3.
  • Docs: skills/kicad-board-checks.

Full battery on DESKTOP-BAR6S0F (KiCad 10.0.7): 303/1/1. The one failure was show_2d_board with my leftover editor; on a clean laptop it is 3/0 for show_schematic, show_2d_board and show_symbol.

Not in this PR: adom-bridge-kicad-manifest-macos.json is Kyle's 0.11.3 (1b4bf0f7), taken from the head.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,410 +1,413 @@⋯ 303 unchanged lines ⋯     "kicad_build_schematic",     "kicad_set_layer_visibility",     "kicad_library_views",-    "kicad_placement_check"+    "kicad_placement_check",+    "kicad_plan_route",+    "kicad_generate_body"   ],   "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.4.1"+version = "0.6.0" dependencies = [  "roxmltree",  "serde_json",⋯ 302 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.73"+version = "1.0.74" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.73"+version = "1.0.74" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.73"+version = "1.0.74" 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.73"+version = "1.0.74" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/verbs_checks_native.rs+++ b/rust/crates/kicad-bridge/src/verbs_checks_native.rs@@ -1,1067 +1,1122 @@⋯ 70 unchanged lines ⋯         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_plan_route",+        summary: "Plan a path for one net between two pads without changing the board: a grid search around the existing copper that keeps the clearance rules, on the allowed copper layers, with vias where they help. Answered as kicad_route_net's own arguments.",+        mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"net\": \"SDA\", \"from\": \"U1.3\", \"to\": \"J2.1\", \"widthMm\"?: (the Default net class), \"layers\"?: [\"F.Cu\", \"B.Cu\"], \"allowVias\"?: true, \"viaSizeMm\"?, \"viaDrillMm\"?, \"gridMm\"?: 0.5, \"viaCost\"?: 5, \"clearanceMm\"?, \"edgeClearanceMm\"?: 0.3, \"maxNodes\"?, \"frame\"?: \"standard|kicad\"}",+        example: "kicad_plan_route {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"net\":\"SDA\",\"from\":\"U1.3\",\"to\":\"J2.1\"}",+        hint: "Read only. found is true with routeNet: pass it to kicad_route_net unchanged (it carries filePath, frame and expectedRevision); run kicad_run_drc after. found false says why (no path within the search, a pad not on the net). The standard's plan_route, computed by its engine (engine says which).",+        related: &["kicad_route_net", "kicad_routing_state", "kicad_run_drc", "kicad_autoroute"],+        pitfalls: &["Between fine-pitch pads the default 0.5 mm grid can miss a path: gridMm 0.1 finds it, slower.", "Pours are not obstacles: refill after routing (kicad_zone_state {refill:true}).", "The plan holds for the revision it read: kicad_route_net refuses it with stale_board once the board changes."],+    },+    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,⋯ 85 unchanged lines ⋯         "kicad_find_space" => model_check(state, args, Check::Space),         "kicad_height_check" => height_check(state, args),         "kicad_placement_check" => placement_check(state, args),+        "kicad_plan_route" => plan_route(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),⋯ 564 unchanged lines ⋯  /// The project's Default net class via (diameter, drill), else the bridge's defaults. fn default_via(board_file: &str) -> (f64, f64) {-    let pro = PathBuf::from(board_file).with_extension("kicad_pro");-    let doc: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);-    let class = doc.pointer("/net_settings/classes").and_then(Value::as_array).and_then(|a| a.iter().find(|c| c["name"] == "Default").cloned()).unwrap_or(Value::Null);+    let class = default_class(board_file);     (class["via_diameter"].as_f64().unwrap_or(pcb::DEFAULT_VIA_SIZE), class["via_drill"].as_f64().unwrap_or(pcb::DEFAULT_VIA_DRILL)) } ⋯ 30 unchanged lines ⋯     v } +/// kicad_plan_route: the standard's engine searches a path on the board model with the board's+/// clearance rules; width and via default to the project's Default net class. routeNet comes back+/// ready for kicad_route_net (filePath, frame, expectedRevision added).+fn plan_route(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 class = default_class(&r.file);+    let mut a = crate::verbs_checks::engine_args(args);+    for (k, ck) in [("widthMm", "track_width"), ("viaSizeMm", "via_diameter"), ("viaDrillMm", "via_drill")] {+        if a.get(k).is_none() {+            if let Some(v) = class[ck].as_f64() {+                a[k] = json!(v);+            }+        }+    }+    let rules = crate::verbs_checks::clearance_rules(&r.file);+    let mut v = adom_eda_checks::run("plan_route", &json!({"board": model, "rules": rules, "args": a}));+    if v["success"] != json!(true) {+        return v;+    }+    let frame = if o.y_up { "standard" } else { "kicad" };+    if v["routeNet"].is_object() {+        v["routeNet"]["filePath"] = json!(r.file);+        v["routeNet"]["frame"] = json!(frame);+        v["routeNet"]["expectedRevision"] = model["revision"].clone();+    }+    v["revision"] = model["revision"].clone();+    v["frame"] = json!(frame);+    stamp(&mut v, &r);+    let hint = v["_hint"].as_str().unwrap_or("").to_string();+    v["_hint"] = json!(format!("{}{hint} In KiCad: route_net is kicad_route_net, and routeNet already carries filePath, frame and expectedRevision.", file_note(&r)));+    v+}++/// The project's Default net class (track_width, via_diameter, via_drill, ...), or null.+fn default_class(board_file: &str) -> Value {+    let pro = PathBuf::from(board_file).with_extension("kicad_pro");+    let doc: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    doc.pointer("/net_settings/classes").and_then(Value::as_array).and_then(|a| a.iter().find(|c| c["name"] == "Default").cloned()).unwrap_or(Value::Null)+}+ #[derive(Clone, Copy, PartialEq)] enum ViaJob {     Thermal,⋯ 288 unchanged lines ⋯         assert!(o["checked"].as_u64().unwrap() > 10, "{o}");     } }+--- a/rust/crates/kicad-bridge/src/verbs_library_views.rs+++ b/rust/crates/kicad-bridge/src/verbs_library_views.rs@@ -1,222 +1,284 @@⋯ 2 unchanged lines ⋯ //! with pad numbers) as PNGs, the 3D body seated on its pads (`pcb render` of a one-part board, //! kicad_core::library_views::view_board), and the device view, which KiCad does not have: the //! symbol's pins mapped to the footprint's pads, as an SVG table. Nothing is opened.+//! Also `kicad_generate_body`, the standard's generate_body: the engine (adom-eda-checks) writes a+//! package body as STEP text, this verb saves it (beside the library, KiCad's <Lib>.3dshapes) and,+//! given a footprint, attaches it through kicad_add_body.  use std::path::{Path, PathBuf}; use std::time::Duration;⋯ 5 unchanged lines ⋯ use kicad_core::{cli, fpedit, library_views, model_check, sch_render, symedit};  pub static VERBS: &[Verb] = &[Verb {+    name: "kicad_generate_body",+    summary: "Make a part's 3D body from its package family and datasheet dimensions (chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, electrolytic can, crystal, pin header and others), to IPC-7351 proportions, optionally with a marking on its top; written as a STEP file and, given a footprint, attached to it.",+    // File: it attaches through kicad_add_body (verbs::call)+    mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+    input: "{\"family\": \"soic\", \"dimensionsMm\"?: {\"A\": 1.75, \"D\": 4.9, \"E\": 6.0, \"E1\": 3.9, \"b\": 0.42, \"e\": 1.27, \"L\": 0.8, \"pins\": 8}, \"marking\"?: \"LM358\", \"markingStyle\"?: \"raised|engraved\", \"name\"?: \"SOIC-8\", \"libraryPath\"?: \"C:/.../Mine.pretty\", \"footprintName\"?: \"SOIC-8_3.9x4.9mm\" (attach to it), \"outputPath\"?: \"C:/.../x.step\" | \"outputDir\"?, \"overwrite\"?: false}",+    example: "kicad_generate_body {\"family\":\"soic\",\"dimensionsMm\":{\"pins\":8},\"marking\":\"LM358\",\"libraryPath\":\"C:/Users/john/kicad/Mine.pretty\",\"footprintName\":\"SOIC-8_3.9x4.9mm\"}",+    hint: "stepPath is the file; with libraryPath it goes in <Lib>.3dshapes beside the library, and footprintName attaches it (attached is kicad_add_body's reply, which replaces the footprint's models). dimensionsMm lists every dimension used, defaults included: compare bboxMm with the datasheet's body before trusting it. An unknown family answers with the families the engine makes. The standard's generate_body, written by its engine (engine says which).",+    related: &["kicad_add_body", "kicad_footprint_from_land_pattern", "kicad_library_views", "kicad_model_check"],+    pitfalls: &["A wrong dimension gives a plausible but wrong body: nothing checks it against a datasheet.", "The body sits in the package's own frame (pin 1 top-left, seated on z = 0): a footprint whose origin is not the package centre needs an offset (kicad_add_body offset).", "An existing STEP file is kept unless overwrite:true."],+}, Verb {     name: "kicad_library_views",     summary: "Pictures of library parts for review, each as an image file: the schematic symbol, the footprint (pads with their numbers), the 3D body seated on the footprint, and the device view (every symbol pin mapped to a pad).",     mechanism: Mechanism::Cli, risk: "write", timeout_sec: 300,⋯ 7 unchanged lines ⋯ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {     match command {         "kicad_library_views" => Some(library_views(state, args)),+        "kicad_generate_body" => Some(generate_body(state, args)),         _ => None,     } }⋯ 187 unchanged lines ⋯         "output": format!("{good} of {} view(s) captured in {}", parts.len() * views.len(), norm(&out_dir)),         "_hint": "Pull the images (ab pull_file) and look at them: pin names on the symbol, pad numbers on the footprint, the body over its pads; the device entry lists any pin without a pad."}) }++/// kicad_generate_body: the engine's STEP text written to outputPath, outputDir/<name>.step or the+/// library's <Lib>.3dshapes/<name>.step, then attached to footprintName through kicad_add_body.+fn generate_body(state: &mut State, args: &Value) -> Value {+    let mut v = adom_eda_checks::run("generate_body", &json!({"args": crate::verbs_checks::engine_args(args)}));+    if v["success"] != json!(true) {+        v["_hint"] = json!("family is one of the families listed; dimensionsMm takes the package drawing's JEDEC letters (A height, D length, E lead span, E1 body width, b lead width, e pitch, L lead length) and pins.");+        return v;+    }+    let text = v.as_object_mut().and_then(|m| m.remove("text")).and_then(|t| t.as_str().map(String::from)).unwrap_or_default();+    let name = arg_str(args, "name").or_else(|| arg_str(args, "footprintName")).map(String::from).unwrap_or_else(|| v["family"].as_str().unwrap_or("body").to_string());+    let lib = arg_str(args, "libraryPath").map(|s| PathBuf::from(s.trim_end_matches(['/', '\\'])));+    let path = if let Some(p) = arg_str(args, "outputPath") {+        PathBuf::from(p)+    } else if let Some(d) = arg_str(args, "outputDir") {+        PathBuf::from(d).join(format!("{}.step", safe(&name)))+    } else if let Some(l) = &lib {+        let stem = l.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "library".into());+        l.parent().unwrap_or(Path::new(".")).join(format!("{stem}.3dshapes")).join(format!("{}.step", safe(&name)))+    } else {+        return fail("missing_arg", "where to write the body: outputPath, outputDir or libraryPath", "libraryPath (a .pretty folder) puts it in <Lib>.3dshapes beside it; add footprintName to attach it.");+    };+    if path.exists() && args.get("overwrite") != Some(&json!(true)) {+        return fail("file_exists", format!("{} already exists", norm(&path)), "Pass overwrite:true to replace it, or another name.");+    }+    if let Some(d) = path.parent() {+        let _ = std::fs::create_dir_all(d);+    }+    if let Err(e) = kicad_core::libraries::write_atomic(&path, text.as_bytes()) {+        return fail("write_failed", format!("{}: {e}", norm(&path)), "Pass an outputPath the desktop user can write.");+    }+    v["stepPath"] = json!(norm(&path));+    v["bytes"] = json!(text.len());+    if let (Some(l), Some(fp)) = (&lib, arg_str(args, "footprintName")) {+        let a = json!({"libraryPath": norm(l), "footprintName": fp, "step": norm(&path), "reason": args.get("reason").cloned().unwrap_or(json!("attach the generated body"))});+        let att = crate::verbs::call(state, "kicad_add_body", &a);+        let ok = att["success"] == json!(true);+        v["attached"] = att;+        if !ok {+            v["success"] = json!(false);+            v["errorCode"] = json!("attach_failed");+            v["error"] = json!(format!("the body was written to {} but kicad_add_body refused it", norm(&path)));+        }+    }+    v["_hint"] = json!(format!("{} Look at it with kicad_library_views {{libraryPath, parts:[footprint], views:[\"3d\"]}} or kicad_show_3d_chip.", v["_hint"].as_str().unwrap_or("")));+    v+}+--- 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.4.1"+version = "0.6.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,102 +1,105 @@⋯ 17 unchanged lines ⋯ | `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; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given |+| `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, keeping `widthMm/2` + the board's clearance from other nets' copper (pads by their real outline), inside the outline, no via in a pad. `routeNet` is route_net's arguments, unchanged; pours are not obstacles (repour after) | | `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 | ⋯ 1 unchanged line ⋯ 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.+designators, panels, the board report, schematic review, and the planners estimate_cost, check_clearance, place_decoupling and plan_fanout)+come over a module at a time.  ## Writers ⋯ 5 unchanged lines ⋯ |---|---|---| | `export_gencad` | board | GenCAD 1.4 text, in inches: outline, pads and padstacks, shapes, components (a bottom part `MIRRORX FLIP`, as KiCad writes it), devices, signals, routes | | `export_dxf` | board, pours? | one DXF (R12) per layer in `args.layers`: Edge.Cuts as lines and arcs or one closed polyline (`contours`), copper as tracks, arcs, pads, vias and pours. With `pours` a copper layer shows the copper actually poured; without, `poursAsDrawn` names the layers that show the pour as drawn |+| `generate_body` | args only | a part's 3D body as STEP AP214 text from its package family and JEDEC letters (`family`, `dimensionsMm` with `pins` the total pin count and `D2`/`E2` an exposed pad, `marking?`), plus its box in the bodies companion's form; families chip, soic, qfn, qfp, sot23, dfn, bga, melf, ecap, crystal, header (#20) | | `import_svg` | svg (text) | `regions` in board mm (y up) for a layer: each shape with its holes as one contour reaching the holes through zero-width slits |  ## Input⋯ 44 unchanged lines ⋯  Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.4.1"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.6.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.7.1-- Standard commit: 7e8005c53c56c44075c081c1b12c3fdce28810e6-- Crate version: 0.4.1+- Standard version: 0.9.0+- Standard commit: e7b68d92a32b0a650fe88e15d9eefb2d1ea583b5+- Crate version: 0.6.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/lib.rs+++ b/rust/vendor/adom-eda-checks/src/lib.rs@@ -1,59 +1,62 @@⋯ 15 unchanged lines ⋯ pub mod impedance; pub mod input; pub mod placement;+pub mod route; 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⋯ 1 unchanged line ⋯ 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", "height_check", "impedance", "placement_check", "return_path", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["copper_balance", "current_capacity", "fab_check", "height_check", "impedance", "placement_check", "plan_route", "return_path", "thermal_vias", "via_fence"];  /// Run one check or writer by its standard name. The reply carries `engine`. pub fn run(check: &str, input: &Value) -> Value {⋯ 11 unchanged lines ⋯         "height_check" => bodies::height_check(input),         "impedance" => impedance::impedance(input),         "placement_check" => placement::placement_check(input),+        "plan_route" => route::plan_route(input),         "return_path" => coppermap::return_path(input),         "thermal_vias" => features::thermal_vias(input),         "via_fence" => features::via_fence(input),⋯ 10 unchanged lines ⋯         assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]);     } }+--- a/rust/vendor/adom-eda-checks/src/route.rs+++ b/rust/vendor/adom-eda-checks/src/route.rs@@ -0,0 +1,324 @@+//! A route between two pads of a net, planned on the board model and answered as route_net's arguments: a bounded+//! orthogonal A* over a grid on any copper layers the caller lists, with through vias between them. Every step keeps+//! `width/2 + clearance` from copper of other nets on its layer (pads by their real outline, tracks, arcs, vias), stays+//! inside the outline by the edge clearance, and no via lands in a pad. From the Fusion bridge's routing_planner.py+//! (fusion-lane, #12), which searched two layers of an EAGLE board; this one reads the standard board model. A plan+//! only: nothing changes until the bridge runs route_net with `routeNet`.+use serde_json::{json, Value};+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap};++type P = (f64, f64);++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++fn seg_point(p: P, a: P, b: P) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };+    ((a.0 + t * dx - p.0).powi(2) + (a.1 + t * dy - p.1).powi(2)).sqrt()+}++/// The distance between two segments (0 when they cross).+fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+    let cross = |p: P, q: P, r: P| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    if cross(a, b, c) * cross(a, b, d) < 0.0 && cross(c, d, a) * cross(c, d, b) < 0.0 { return 0.0; }+    seg_point(a, c, d).min(seg_point(b, c, d)).min(seg_point(c, a, b)).min(seg_point(d, a, b))+}++fn inside(p: P, ring: &[P]) -> bool {+    let mut c = false;+    for i in 0..ring.len() {+        let (a, b) = (ring[i], ring[(i + 1) % ring.len()]);+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; }+    }+    c+}++/// Copper as a shape: a capsule (a segment with a half-width: tracks, round pads, vias) or a polygon (rect pads, a+/// custom pad's copperPoints).+#[derive(Clone)]+enum Shape { Cap(P, P, f64), Poly(Vec<P>) }++impl Shape {+    /// Edge-to-edge distance from the segment a..b (its centreline) to this copper.+    fn dist(&self, a: P, b: P) -> f64 {+        match self {+            Shape::Cap(c, d, r) => seg_seg(a, b, *c, *d) - r,+            Shape::Poly(ring) => {+                if inside(a, ring) || inside(b, ring) { return 0.0; }+                (0..ring.len()).map(|i| seg_seg(a, b, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)+            }+        }+    }+    fn bounds(&self) -> (f64, f64, f64, f64) {+        match self {+            Shape::Cap(c, d, r) => (c.0.min(d.0) - r, c.1.min(d.1) - r, c.0.max(d.0) + r, c.1.max(d.1) + r),+            Shape::Poly(ring) => ring.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1))),+        }+    }+}++/// A pad's copper: copperPoints when the bridge sent them, else its shape turned with it.+fn pad_shape(p: &Value) -> Shape {+    let (x, y, sx, sy, rot) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]), f(&p["sizeY"]), f(&p["rotation"]).to_radians());+    let pts: Vec<P> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();+    if pts.len() >= 3 { return Shape::Poly(pts); }+    let (c, s) = (rot.cos(), rot.sin());+    match p["shape"].as_str() {+        Some("round") | Some("circle") => {+            let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy) / 2.0);+            let (ux, uy) = if sx >= sy { (c * l, s * l) } else { (-s * l, c * l) };+            Shape::Cap((x - ux, y - uy), (x + ux, y + uy), w)+        }+        _ => Shape::Poly([(-sx, -sy), (sx, -sy), (sx, sy), (-sx, sy)].iter().map(|(u, v)| (x + u / 2.0 * c - v / 2.0 * s, y + u / 2.0 * s + v / 2.0 * c)).collect()),+    }+}++/// The copper layers a pad is on: every one for a through-hole pad (`*.Cu` or a drill), else its own.+fn pad_layers(p: &Value, all: &[String]) -> Vec<String> {+    let l = p["layer"].as_str().unwrap_or("");+    if l == "*.Cu" || f(&p["drill"]) > 0.0 { all.to_vec() } else { vec![l.to_string()] }+}++/// plan_route: input {board, rules?, args: {net, from, to, widthMm?, layers?, allowVias?, viaSizeMm?, viaDrillMm?, gridMm?,+/// viaCost?, clearanceMm?, edgeClearanceMm?, maxNodes?}}. `from` and `to` are pads as Ref.Pad.+pub fn plan_route(input: &Value) -> Value {+    let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+    let args = &input["args"];+    let num = |k: &str, d: f64| args.get(k).and_then(|v| v.as_f64()).unwrap_or(d);+    let Some(net) = args["net"].as_str() else { return crate::input::refuse("bad_args", "net is required") };+    if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false))).unwrap_or(false) {+        return crate::input::refuse("no_such_net", &format!("{net} is not a net on this board"));+    }+    let pads = st["pads"].as_array().cloned().unwrap_or_default();+    let key = |p: &Value| format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""));+    let mut ends = Vec::new();+    for k in ["from", "to"] {+        let Some(want) = args[k].as_str() else { return crate::input::refuse("bad_args", &format!("{k} is a pad as Ref.Pad (U1.3)")) };+        let Some(p) = pads.iter().find(|p| key(p).eq_ignore_ascii_case(want)) else { return crate::input::refuse("no_such_pad", &format!("{want} is not a pad on this board")) };+        if !p["net"].as_str().map(|n| n.eq_ignore_ascii_case(net)).unwrap_or(false) {+            return json!({"success": true, "found": false, "reason": format!("{want} is on {}, not {net}", p["net"].as_str().unwrap_or("no net"))});+        }+        ends.push(p.clone());+    }+    if key(&ends[0]).eq_ignore_ascii_case(&key(&ends[1])) { return crate::input::refuse("bad_args", "from and to are the same pad"); }+    let layers: Vec<String> = args["layers"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_else(|| vec!["F.Cu".into(), "B.Cu".into()]);+    if layers.is_empty() || layers.iter().any(|l| !l.ends_with(".Cu") || l == "*.Cu") { return crate::input::refuse("bad_args", "layers are copper layers by name: [\"F.Cu\", \"In1.Cu\", \"B.Cu\"]"); }+    let width = num("widthMm", 0.25);+    let (via_size, via_drill) = (num("viaSizeMm", 0.6), num("viaDrillMm", 0.3));+    if via_drill >= via_size { return crate::input::refuse("bad_args", "viaDrillMm must be smaller than viaSizeMm"); }+    let grid = num("gridMm", 0.5);+    if !(0.05..=5.0).contains(&grid) { return crate::input::refuse("bad_args", "gridMm is 0.05 to 5"); }+    let allow_vias = args["allowVias"].as_bool().unwrap_or(true) && layers.len() > 1;+    let via_cost = num("viaCost", 5.0);+    let max_nodes = args["maxNodes"].as_u64().unwrap_or(50_000).clamp(1, 500_000) as usize;+    // the board's own clearance decides; a caller's clearanceMm can only raise it+    let rule = crate::fab::min_clearance(&crate::input::rules(input));+    let clearance = num("clearanceMm", 0.0).max(rule.unwrap_or(0.2));+    let edge = num("edgeClearanceMm", 0.3);+    let ring = crate::coppermap::outline_ring(&st);+    if ring.len() < 3 { return crate::input::refuse("no_outline", "the board has no outline"); }++    // copper of other nets, by layer; every pad for the via-in-pad rule. Net-less tracks and arcs are a hatched pour's+    // pieces (Altium lists 8777 of the Bluetooth Sentinel's 9378 tracks with no net) and pours re-pour round new copper,+    // so neither blocks a route: repour after placing it (the reply says so)+    let same = |v: &Value| v["net"].as_str().map(|n| n.eq_ignore_ascii_case(net) || (n.is_empty() && v.get("start").or(v.get("center")).is_some())).unwrap_or(false);+    let mut ob: Vec<(Shape, Vec<String>, (f64, f64, f64, f64))> = Vec::new();+    let mut push = |s: Shape, ls: Vec<String>| { let b = s.bounds(); ob.push((s, ls, b)); };+    for p in pads.iter().filter(|p| !same(p)) { push(pad_shape(p), pad_layers(p, &layers)); }+    for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| !same(t)) {+        push(Shape::Cap((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0), vec![t["layer"].as_str().unwrap_or("").to_string()]);+    }+    for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| !same(a)) {+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (a1, mut a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+        if a2 <= a1 { a2 += 360.0; }+        let n = ((a2 - a1) / 10.0).ceil().max(1.0) as usize;+        let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+        for i in 0..n { push(Shape::Cap(p(a1 + (a2 - a1) * i as f64 / n as f64), p(a1 + (a2 - a1) * (i + 1) as f64 / n as f64), f(&a["width"]) / 2.0), vec![a["layer"].as_str().unwrap_or("").to_string()]); }+    }+    for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| !same(v)) {+        let c = (f(&v["position"]["x"]), f(&v["position"]["y"]));+        push(Shape::Cap(c, c, f(&v["size"]) / 2.0), layers.clone());+    }+    let all_pads: Vec<Shape> = pads.iter().map(pad_shape).collect();++    // the grid: lines every gridMm over the outline, plus lines through both pad centres+    let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+    let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+    let cells = ((x1 - x0) / grid + 3.0) * ((y1 - y0) / grid + 3.0) * layers.len() as f64;+    if cells > 2_000_000.0 { return crate::input::refuse("search_budget", "the grid has too many cells: use a coarser gridMm"); }+    let axis = |lo: f64, hi: f64, extra: [f64; 2]| -> Vec<f64> {+        let mut v: Vec<f64> = (0..=((hi - lo) / grid).floor() as usize).map(|i| r3(lo + i as f64 * grid)).collect();+        v.extend(extra.iter().map(|e| r3(*e)));+        v.sort_by(|a, b| a.partial_cmp(b).unwrap());+        v.dedup_by(|a, b| (*a - *b).abs() < 1e-9);+        v+    };+    let (sp, gp) = ((f(&ends[0]["x"]), f(&ends[0]["y"])), (f(&ends[1]["x"]), f(&ends[1]["y"])));+    let xs = axis(x0, x1, [sp.0, gp.0]);+    let ys = axis(y0, y1, [sp.1, gp.1]);+    let idx = |v: &[f64], t: f64| v.iter().position(|x| (x - r3(t)).abs() < 1e-9).unwrap();+    let (start, goal) = ((idx(&xs, sp.0), idx(&ys, sp.1)), (idx(&xs, gp.0), idx(&ys, gp.1)));+    let start_layers: Vec<usize> = pad_layers(&ends[0], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+    let goal_layers: Vec<usize> = pad_layers(&ends[1], &layers).iter().filter_map(|l| layers.iter().position(|x| x == l)).collect();+    if start_layers.is_empty() || goal_layers.is_empty() {+        return json!({"success": true, "found": false, "reason": "a pad is on no layer the route may use: add its layer to layers"});+    }+    if !allow_vias && !start_layers.iter().any(|l| goal_layers.contains(l)) {+        return json!({"success": true, "found": false, "reason": "the pads share no layer and allowVias is false"});+    }++    let edge_ok = |a: P, b: P, r: f64| inside(a, &ring) && inside(b, &ring)+        && (0..ring.len()).all(|k| seg_seg(a, b, ring[k], ring[(k + 1) % ring.len()]) >= edge + r - 1e-9);+    let clear_on = |a: P, b: P, r: f64, layer: &str| {+        let (bx0, by0, bx1, by1) = (a.0.min(b.0) - r - clearance, a.1.min(b.1) - r - clearance, a.0.max(b.0) + r + clearance, a.1.max(b.1) + r + clearance);+        ob.iter().filter(|(_, ls, bb)| ls.iter().any(|l| l == layer) && !(bb.2 < bx0 || bb.0 > bx1 || bb.3 < by0 || bb.1 > by1))+            .all(|(s, _, _)| s.dist(a, b) >= r + clearance - 1e-9)+    };+    let mut cache: HashMap<(usize, usize, usize, usize, usize, usize), bool> = HashMap::new();+    let mut step_ok = |u: (usize, usize, usize), v: (usize, usize, usize)| -> bool {+        let k = if u <= v { (u.0, u.1, u.2, v.0, v.1, v.2) } else { (v.0, v.1, v.2, u.0, u.1, u.2) };+        if let Some(b) = cache.get(&k) { return *b; }+        let (a, b) = ((xs[u.0], ys[u.1]), (xs[v.0], ys[v.1]));+        let ok = if u.2 != v.2 {+            // a through via: clear on every layer, inside the outline, in no pad+            let r = via_size / 2.0;+            edge_ok(a, a, r) && layers.iter().all(|l| clear_on(a, a, r, l)) && all_pads.iter().all(|s| s.dist(a, a) >= r + clearance - 1e-9)+        } else {+            edge_ok(a, b, width / 2.0) && clear_on(a, b, width / 2.0, &layers[u.2])+        };+        cache.insert(k, ok);+        ok+    };+    let h = |u: (usize, usize, usize)| (xs[u.0] - gp.0).abs() + (ys[u.1] - gp.1).abs() + if goal_layers.contains(&u.2) { 0.0 } else { via_cost };+    let q = |v: f64| (v * 1e6).round() as i64;+    let mut heap: BinaryHeap<Reverse<(i64, i64, (usize, usize, usize))>> = BinaryHeap::new();+    let mut cost: HashMap<(usize, usize, usize), f64> = HashMap::new();+    let mut parent: HashMap<(usize, usize, usize), (usize, usize, usize)> = HashMap::new();+    for &l in &start_layers { let u = (start.0, start.1, l); cost.insert(u, 0.0); heap.push(Reverse((q(h(u)), 0, u))); }+    let mut seen: std::collections::HashSet<(usize, usize, usize)> = Default::default();+    let mut found = None;+    while let Some(Reverse((_, _, u))) = heap.pop() {+        if !seen.insert(u) { continue; }+        if (u.0, u.1) == goal && goal_layers.contains(&u.2) { found = Some(u); break; }+        if seen.len() >= max_nodes { break; }+        let g = cost[&u];+        let mut nb = Vec::new();+        if u.0 + 1 < xs.len() { nb.push((u.0 + 1, u.1, u.2)); }+        if u.0 > 0 { nb.push((u.0 - 1, u.1, u.2)); }+        if u.1 + 1 < ys.len() { nb.push((u.0, u.1 + 1, u.2)); }+        if u.1 > 0 { nb.push((u.0, u.1 - 1, u.2)); }+        if allow_vias { for l in 0..layers.len() { if l != u.2 { nb.push((u.0, u.1, l)); } } }+        for v in nb {+            if seen.contains(&v) || !step_ok(u, v) { continue; }+            let step = if v.2 != u.2 { via_cost } else { (xs[v.0] - xs[u.0]).abs() + (ys[v.1] - ys[u.1]).abs() };+            let ng = g + step;+            if ng >= cost.get(&v).copied().unwrap_or(f64::MAX) - 1e-9 { continue; }+            cost.insert(v, ng);+            parent.insert(v, u);+            heap.push(Reverse((q(ng + h(v)), q(ng), v)));+        }+    }+    let revision = st["revision"].clone();+    let Some(goal_node) = found else {+        let why = if seen.len() >= max_nodes { "no path within the search limit (maxNodes): try a coarser gridMm or a higher maxNodes" } else { "no legal path on this grid: other copper, the outline or the clearance blocks every way. A fine-pitch pad (0.5 mm pitch) usually needs gridMm 0.1 to escape; more layers give more room" };+        return json!({"success": true, "found": false, "reason": why, "expandedNodes": seen.len(), "revision": revision});+    };+    let mut path = vec![goal_node];+    while let Some(p) = parent.get(path.last().unwrap()) { path.push(*p); }+    path.reverse();+    // keep the corners and the layer changes+    let mut keep = vec![path[0]];+    for w in path.windows(3) {+        let (a, b, c) = (w[0], w[1], w[2]);+        if a.2 == b.2 && b.2 == c.2 && ((a.0 == b.0 && b.0 == c.0) || (a.1 == b.1 && b.1 == c.1)) { continue; }+        keep.push(b);+    }+    if path.len() > 1 { keep.push(*path.last().unwrap()); }+    let mut way = vec![json!({"pad": key(&ends[0])})];+    let mut vias = Vec::new();+    let mut length = 0.0;+    let last = keep.len() - 1;+    for (i, w) in keep.windows(2).enumerate() {+        let (a, b) = (w[0], w[1]);+        let (pa, pb) = ((xs[a.0], ys[a.1]), (xs[b.0], ys[b.1]));+        if a.2 != b.2 { vias.push(json!([pa.0, pa.1])); } else { length += ((pb.0 - pa.0).powi(2) + (pb.1 - pa.1).powi(2)).sqrt(); }+        // every corner and layer change; the goal itself is the closing {pad} waypoint+        if i + 1 < last { way.push(json!({"x": pb.0, "y": pb.1, "layer": layers[b.2]})); }+    }+    way.push(json!({"pad": key(&ends[1])}));+    json!({"success": true, "found": true, "lengthMm": r3(length), "vias": vias.len(), "viaAt": vias,+        "routeNet": {"net": net, "layer": layers[path[0].2], "width": width, "viaSize": via_size, "viaDrill": via_drill, "paths": [way]},+        "planner": {"algorithm": "bounded orthogonal A*", "gridMm": grid, "layers": layers, "clearanceMm": clearance, "edgeClearanceMm": edge, "expandedNodes": seen.len()},+        "revision": revision,+        "_hint": "A plan only: pass routeNet to route_net unchanged (with expectedRevision: revision). A layer change is two waypoints at the same point, where route_net places a via. Pours (and a hatched pour's net-less pieces) are not obstacles: repour after placing the route. The path is orthogonal on the grid: a finer gridMm finds tighter paths and searches longer."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(extra_tracks: Value) -> Value {+        json!({"nets": ["SIG", "GND"], "revision": "r1", "outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+               "outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],+               "pads": [{"ref": "U1", "name": "1", "x": 2.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+                        {"ref": "J1", "name": "1", "x": 18.0, "y": 5.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "F.Cu", "drill": 0.0, "net": "SIG"},+                        {"ref": "J1", "name": "2", "x": 18.0, "y": 2.0, "sizeX": 1.0, "sizeY": 1.0, "rotation": 0.0, "shape": "rect", "layer": "B.Cu", "drill": 0.0, "net": "SIG"}],+               "tracks": extra_tracks, "arcs": [], "vias": []})+    }++    #[test]+    fn a_clear_board_is_one_straight_track() {+        let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 0);+        assert!((r["lengthMm"].as_f64().unwrap() - 16.0).abs() < 1e-9);+        assert_eq!(r["routeNet"]["paths"][0][0], json!({"pad": "U1.1"}));+        assert_eq!(r["routeNet"]["paths"][0].as_array().unwrap().last().unwrap(), &json!({"pad": "J1.1"}));+        assert_eq!(r["routeNet"]["layer"], "F.Cu");+    }++    #[test]+    fn a_wall_on_top_is_crossed_on_the_bottom_or_not_at_all() {+        // a GND track across the whole board on F.Cu+        let wall = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+        let r = plan_route(&json!({"board": board(wall.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 2, "down to B.Cu and back up: {r}");+        // every point of the path clears the wall on F.Cu+        for w in r["routeNet"]["paths"][0].as_array().unwrap() { if w["layer"] == "F.Cu" { assert!((f(&w["x"]) - 10.0).abs() > 0.25 + 0.2 + 0.125 - 1e-9, "{w}"); } }+        let none = plan_route(&json!({"board": board(wall), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "allowVias": false}}));+        assert_eq!(none["found"], json!(false));+    }++    #[test]+    fn pads_on_different_layers_take_a_via_and_errors_are_named() {+        let r = plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U1.1", "to": "J1.2"}}));+        assert_eq!(r["found"], json!(true), "{r}");+        assert_eq!(r["vias"], 1);+        // the via is clear of every pad+        let v = &r["viaAt"][0];+        for (px, py) in [(2.0, 5.0), (18.0, 5.0), (18.0, 2.0)] { assert!(((f(&v[0]) - px).powi(2) + (f(&v[1]) - py).powi(2)).sqrt() > 0.3 + 0.5, "{v}"); }+        assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "SIG", "from": "U9.1", "to": "J1.1"}}))["errorCode"], "no_such_pad");+        assert_eq!(plan_route(&json!({"board": board(json!([])), "args": {"net": "NOPE", "from": "U1.1", "to": "J1.1"}}))["errorCode"], "no_such_net");+        let mut b = board(json!([]));+        b["pads"][1]["net"] = json!("GND");+        assert_eq!(plan_route(&json!({"board": b, "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}))["found"], json!(false));+    }++    #[test]+    fn three_layers_when_asked() {+        // walls on F.Cu and B.Cu: only In1.Cu crosses+        let walls = json!([{"net": "GND", "layer": "F.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}},+                           {"net": "GND", "layer": "B.Cu", "width": 0.5, "start": {"x": 10.0, "y": 0.0}, "end": {"x": 10.0, "y": 10.0}}]);+        let two = plan_route(&json!({"board": board(walls.clone()), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1"}}));+        assert_eq!(two["found"], json!(false));+        let three = plan_route(&json!({"board": board(walls), "args": {"net": "SIG", "from": "U1.1", "to": "J1.1", "layers": ["F.Cu", "In1.Cu", "B.Cu"]}}));+        assert_eq!(three["found"], json!(true), "{three}");+        assert!(three["routeNet"]["paths"][0].as_array().unwrap().iter().any(|w| w["layer"] == "In1.Cu"));+    }+}+--- a/rust/vendor/adom-eda-checks/src/writers/body3d.rs+++ b/rust/vendor/adom-eda-checks/src/writers/body3d.rs@@ -0,0 +1,542 @@+//! generate_body (EDA Bridge Standard #20, from the Altium bridge's body3d.rs): a part's 3D body from its package family and datasheet dimensions, to IPC-7351 proportions, written+//! as a STEP file (AP214 boundary representation, mm, coloured body and leads). Pure Rust: no CAD tool is needed.+//! Bodies are made of prisms (a planar outline swept along an axis), which is all these packages need; a round can or+//! ball is a many-sided prism. The package sits in its own frame: IPC-7351 zero orientation, seated on z = 0. No I/O:+//! the reply carries the STEP text, and the bridge writes the file.++use serde_json::{json, Map, Value};++type P3 = [f64; 3];++/// A solid as its faces, each a planar polygon listed counter-clockwise seen from outside.+#[derive(Clone)]+pub struct Solid { pub faces: Vec<Vec<P3>>, pub lead: bool, pub mark: bool }++/// A prism: `outline` (counter-clockwise in its own u,v plane) swept from w0 to w1, placed by `map(u, v, w)`.+fn prism(outline: &[(f64, f64)], w0: f64, w1: f64, lead: bool, map: impl Fn(f64, f64, f64) -> P3) -> Solid {+    let n = outline.len();+    let bot: Vec<P3> = outline.iter().rev().map(|&(u, v)| map(u, v, w0)).collect();+    let top: Vec<P3> = outline.iter().map(|&(u, v)| map(u, v, w1)).collect();+    let mut faces = vec![bot, top];+    for i in 0..n {+        let (a, b) = (outline[i], outline[(i + 1) % n]);+        faces.push(vec![map(a.0, a.1, w0), map(b.0, b.1, w0), map(b.0, b.1, w1), map(a.0, a.1, w1)]);+    }+    let mut s = Solid { faces, lead, mark: false };+    // a map that mirrors (an odd number of axis flips) turns every face inside out: put them right+    if signed_volume(&s) < 0.0 { for f in &mut s.faces { f.reverse(); } }+    s+}++fn signed_volume(s: &Solid) -> f64 {+    s.faces.iter().map(|f| (1..f.len() - 1).map(|i| { let (a, b, c) = (f[0], f[i], f[i + 1]); a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0]) }).sum::<f64>()).sum::<f64>() / 6.0+}++fn rect(w: f64, h: f64) -> Vec<(f64, f64)> { vec![(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)] }+fn ngon(r: f64, n: usize) -> Vec<(f64, f64)> { (0..n).map(|i| { let a = 2.0 * std::f64::consts::PI * (i as f64 + 0.5) / n as f64; (r * a.cos(), r * a.sin()) }).collect() }++/// A box from (x0, y0, z0) to (x1, y1, z1).+fn bx(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64, lead: bool) -> Solid {+    let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+    prism(&rect((x1 - x0).abs(), (y1 - y0).abs()), z0, z1, lead, |u, v, w| [cx + u, cy + v, w])+}++/// A gull-wing lead running outward along +x from the body edge at x = x0 to the tip at x = x1, centred on y, width b,+/// thickness t, shoulder at height zs, foot of length l on the seating plane.+fn gull(x0: f64, x1: f64, y: f64, b: f64, t: f64, zs: f64, l: f64, sign: f64) -> Solid {+    let knee = (x1 - l).max(x0 + t * 1.5);+    let mid = x0 + (knee - x0) * 0.5;+    // the profile in (x, z), counter-clockwise: under the shoulder, down the leg, along the foot, and back over+    let prof = vec![(x0, zs), (mid, zs), (knee, 0.0), (x1, 0.0), (x1, t), (knee + t * 0.4, t), (mid + t * 0.4, zs + t), (x0, zs + t)];+    prism(&prof, y - b / 2.0, y + b / 2.0, true, |u, v, w| [sign * u, w, v])+}++fn d(dims: &Map<String, Value>, k: &str, def: f64) -> f64 { dims.get(k).and_then(|v| v.as_f64()).unwrap_or(def) }++/// The families and their default dimensions (JEDEC letters, mm), from common parts. `pins` is the total pin count in+/// every family that has a choice; D2 and E2 are a QFN or DFN's exposed pad, drawn when both are given: an 0603 chip, a SOIC-8,+/// a QFN-16 4x4, a TQFP-32 7x7, a SOT-23, a DFN-8 3x3, a BGA-16 0.8 mm, a MiniMELF, a 6.3 mm can, a 3.2x2.5 crystal,+/// a 1x4 2.54 mm header.+pub fn families() -> Vec<(&'static str, Value)> {+    vec![+        ("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3})),+        ("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.15, "b": 0.42, "e": 1.27, "L": 0.6, "c": 0.2})),+        ("qfn", json!({"pins": 16, "D": 4.0, "E": 4.0, "A": 0.9, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.1, "E2": 2.1})),+        ("qfp", json!({"pins": 32, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.2, "A1": 0.1, "b": 0.37, "e": 0.8, "L": 0.6, "c": 0.15})),+        ("sot23", json!({"pins": 3, "D": 2.9, "E": 2.4, "E1": 1.3, "A": 1.0, "A1": 0.05, "b": 0.4, "e": 0.95, "L": 0.4, "c": 0.12})),+        ("dfn", json!({"pins": 8, "D": 3.0, "E": 3.0, "A": 0.85, "b": 0.3, "e": 0.65, "L": 0.4, "D2": 2.4, "E2": 1.6})),+        ("bga", json!({"rows": 4, "cols": 4, "D": 4.0, "E": 4.0, "A": 1.2, "A1": 0.25, "b": 0.4, "e": 0.8})),+        ("melf", json!({"D": 3.5, "E": 1.4, "L": 0.45})),+        ("ecap", json!({"D": 6.3, "A": 5.4, "E": 6.6})),+        ("crystal", json!({"D": 3.2, "E": 2.5, "A": 0.8, "b": 1.2, "L": 1.0})),+        ("header", json!({"pins": 4, "rows": 1, "e": 2.54, "A": 2.5, "L": 3.0, "L1": 6.0, "b": 0.64})),+    ]+}++/// The solids of a package: its body (and leads) in the IPC-7351 zero orientation, pin 1 top-left.+pub fn package(family: &str, dims: &Map<String, Value>) -> Option<Vec<Solid>> {+    let mut s = Vec::new();+    let pins = |def: f64| d(dims, "pins", def).round().max(1.0) as usize;+    match family {+        "chip" => {+            let (len, w, h, l) = (d(dims, "D", 1.6), d(dims, "E", 0.8), d(dims, "A", 0.45), d(dims, "L", 0.3));+            s.push(bx(-len / 2.0 + l, -w / 2.0, 0.0, len / 2.0 - l, w / 2.0, h, false));+            s.push(bx(-len / 2.0, -w / 2.0, 0.0, -len / 2.0 + l, w / 2.0, h, true));+            s.push(bx(len / 2.0 - l, -w / 2.0, 0.0, len / 2.0, w / 2.0, h, true));+        }+        "soic" | "sot23" | "qfp" => {+            let n = pins(if family == "sot23" { 3.0 } else { 8.0 });+            let (a, a1, b, e, l, c) = (d(dims, "A", 1.75), d(dims, "A1", 0.1), d(dims, "b", 0.42), d(dims, "e", 1.27), d(dims, "L", 0.6), d(dims, "c", 0.2));+            let zs = a1 + (a - a1) * 0.4;+            if family == "qfp" {+                let (dd, ee, d1, e1) = (d(dims, "D", 9.0), d(dims, "E", 9.0), d(dims, "D1", 7.0), d(dims, "E1", 7.0));+                s.push(bx(-e1 / 2.0, -d1 / 2.0, a1, e1 / 2.0, d1 / 2.0, a, false));+                let per = (n / 4).max(1);+                let span = (per - 1) as f64 * e;+                for i in 0..per {+                    let off = span / 2.0 - i as f64 * e;+                    // left (pins 1..), bottom, right, top: counter-clockwise from pin 1 top-left+                    s.push(gull(e1 / 2.0, ee / 2.0, off, b, c, zs, l, -1.0));+                    let mut bt = gull(d1 / 2.0, dd / 2.0, -off, b, c, zs, l, 1.0);+                    for f in &mut bt.faces { for p in f.iter_mut() { *p = [-p[1], -p[0], p[2]]; } f.reverse(); }+                    s.push(bt);+                    s.push(gull(e1 / 2.0, ee / 2.0, -off, b, c, zs, l, 1.0));+                    let mut tp = gull(d1 / 2.0, dd / 2.0, off, b, c, zs, l, 1.0);+                    for f in &mut tp.faces { for p in f.iter_mut() { *p = [p[1], p[0], p[2]]; } f.reverse(); }+                    s.push(tp);+                }+            } else {+                let (dd, ee, e1) = (d(dims, "D", 4.9), d(dims, "E", 6.0), d(dims, "E1", 3.9));+                s.push(bx(-e1 / 2.0, -dd / 2.0, a1, e1 / 2.0, dd / 2.0, a, false));+                if family == "sot23" && n == 3 {+                    s.push(gull(e1 / 2.0, ee / 2.0, e, b, c, zs, l, -1.0));+                    s.push(gull(e1 / 2.0, ee / 2.0, -e, b, c, zs, l, -1.0));+                    s.push(gull(e1 / 2.0, ee / 2.0, 0.0, b, c, zs, l, 1.0));+                } else {+                    let per = n.div_ceil(2);+                    let span = (per - 1) as f64 * e;+                    for i in 0..per { s.push(gull(e1 / 2.0, ee / 2.0, span / 2.0 - i as f64 * e, b, c, zs, l, -1.0)); }+                    for i in 0..(n - per) { s.push(gull(e1 / 2.0, ee / 2.0, -span / 2.0 + i as f64 * e, b, c, zs, l, 1.0)); }+                }+            }+        }+        "qfn" | "dfn" => {+            let n = pins(if family == "dfn" { 8.0 } else { 16.0 });+            let (dd, ee, a, b, e, l) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 0.9), d(dims, "b", 0.3), d(dims, "e", 0.65), d(dims, "L", 0.4));+            let t = 0.02;+            s.push(bx(-ee / 2.0, -dd / 2.0, t, ee / 2.0, dd / 2.0, a, false));+            let sides = if family == "dfn" { 2 } else { 4 };+            let per = (n / sides).max(1);+            let span = (per - 1) as f64 * e;+            for i in 0..per {+                let off = span / 2.0 - i as f64 * e;+                s.push(bx(-ee / 2.0, off - b / 2.0, 0.0, -ee / 2.0 + l, off + b / 2.0, t, true));+                s.push(bx(ee / 2.0 - l, -off - b / 2.0, 0.0, ee / 2.0, -off + b / 2.0, t, true));+                if sides == 4 {+                    s.push(bx(-off - b / 2.0, -dd / 2.0, 0.0, -off + b / 2.0, -dd / 2.0 + l, t, true));+                    s.push(bx(off - b / 2.0, dd / 2.0 - l, 0.0, off + b / 2.0, dd / 2.0, t, true));+                }+            }+            let (d2, e2) = (d(dims, "D2", 0.0), d(dims, "E2", 0.0));+            if d2 > 0.0 && e2 > 0.0 { s.push(bx(-e2 / 2.0, -d2 / 2.0, 0.0, e2 / 2.0, d2 / 2.0, t, true)); }+        }+        "bga" => {+            // rows x cols, or `pins` (the total, as every family takes it) as the smallest square grid that holds them+            let side = dims.get("pins").and_then(|v| v.as_f64()).map(|n| n.max(1.0).sqrt().ceil()).unwrap_or(4.0);+            let (rows, cols) = (d(dims, "rows", side).round().max(1.0) as usize, d(dims, "cols", side).round().max(1.0) as usize);+            let (dd, ee, a, a1, b, e) = (d(dims, "D", 4.0), d(dims, "E", 4.0), d(dims, "A", 1.2), d(dims, "A1", 0.25), d(dims, "b", 0.4), d(dims, "e", 0.8));+            s.push(bx(-ee / 2.0, -dd / 2.0, a1, ee / 2.0, dd / 2.0, a, false));+            for r in 0..rows {+                for c in 0..cols {+                    let (x, y) = ((c as f64 - (cols - 1) as f64 / 2.0) * e, ((rows - 1) as f64 / 2.0 - r as f64) * e);+                    s.push(prism(&ngon(b / 2.0, 12), 0.0, a1, true, |u, v, w| [x + u, y + v, w]));+                }+            }+        }+        "melf" => {+            let (len, dia, l) = (d(dims, "D", 3.5), d(dims, "E", 1.4), d(dims, "L", 0.45));+            let r = dia / 2.0;+            // a cylinder lying along x, resting on z = 0+            s.push(prism(&ngon(r * 0.97, 24), -len / 2.0 + l, len / 2.0 - l, false, |u, v, w| [w, u, r + v]));+            s.push(prism(&ngon(r, 24), -len / 2.0, -len / 2.0 + l, true, |u, v, w| [w, u, r + v]));+            s.push(prism(&ngon(r, 24), len / 2.0 - l, len / 2.0, true, |u, v, w| [w, u, r + v]));+        }+        "ecap" => {+            let (dia, h, base) = (d(dims, "D", 6.3), d(dims, "A", 5.4), d(dims, "E", 6.6));+            s.push(bx(-base / 2.0, -base / 2.0, 0.0, base / 2.0, base / 2.0, 0.6, false));+            s.push(prism(&ngon(dia / 2.0, 32), 0.6, h, true, |u, v, w| [u, v, w]));+        }+        "crystal" => {+            let (dd, ee, a, b, l) = (d(dims, "D", 3.2), d(dims, "E", 2.5), d(dims, "A", 0.8), d(dims, "b", 1.2), d(dims, "L", 1.0));+            s.push(bx(-dd / 2.0, -ee / 2.0, 0.05, dd / 2.0, ee / 2.0, a, false));+            for (sx, sy) in [(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)] {+                let (x, y) = (sx * (dd / 2.0 - l / 2.0), sy * (ee / 2.0 - b / 2.0));+                s.push(bx(x - l / 2.0, y - b / 2.0, 0.0, x + l / 2.0, y + b / 2.0, 0.05, true));+            }+        }+        "header" => {+            let (n, rows, e, a, l, l1, b) = (pins(4.0), d(dims, "rows", 1.0).round().max(1.0) as usize, d(dims, "e", 2.54), d(dims, "A", 2.5), d(dims, "L", 3.0), d(dims, "L1", 6.0), d(dims, "b", 0.64));+            let per = n.div_ceil(rows);+            let (w, h) = (rows as f64 * e, per as f64 * e);+            // pin 1 at the origin, the rest down the column (and across the rows)+            s.push(bx(-e / 2.0, -h + e / 2.0, 0.0, w - e / 2.0, e / 2.0, a, false));+            for i in 0..n {+                let (x, y) = ((i / per) as f64 * e, -((i % per) as f64) * e);+                s.push(bx(x - b / 2.0, y - b / 2.0, -l, x + b / 2.0, y + b / 2.0, a + l1, true));+            }+        }+        _ => return None,+    }+    Some(s)+}++/// STEP AP214 text of the solids: one product, every solid a MANIFOLD_SOLID_BREP of planar faces, coloured.+pub fn step(solids: &[Solid], name: &str) -> String {+    let mut out = Vec::<String>::new();+    let mut id = 0usize;+    let mut add = |out: &mut Vec<String>, s: String| -> usize { id += 1; out.push(format!("#{id}={s};")); id };+    let r = |v: f64| { let s = format!("{:.6}", if v.abs() < 5e-7 { 0.0 } else { v }); s };+    let app = add(&mut out, "APPLICATION_CONTEXT('core data for automotive mechanical design processes')".into());+    add(&mut out, format!("APPLICATION_PROTOCOL_DEFINITION('international standard','automotive_design',2000,#{app})"));+    let pc = add(&mut out, format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));+    let prod = add(&mut out, format!("PRODUCT('{name}','{name}','',(#{pc}))"));+    let pdf = add(&mut out, format!("PRODUCT_DEFINITION_FORMATION('','',#{prod})"));+    let pdc = add(&mut out, format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));+    let pd = add(&mut out, format!("PRODUCT_DEFINITION('design','',#{pdf},#{pdc})"));+    let pds = add(&mut out, format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));+    add(&mut out, format!("PRODUCT_RELATED_PRODUCT_CATEGORY('part',$,(#{prod}))"));+    let lu = add(&mut out, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))".into());+    let au = add(&mut out, "(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))".into());+    let su = add(&mut out, "(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())".into());+    let unc = add(&mut out, format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06),#{lu},'distance_accuracy_value','confusion accuracy')"));+    let ctx = add(&mut out, format!("(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))REPRESENTATION_CONTEXT('Context #1','3D Context with UNIT and UNCERTAINTY'))"));+    let o = add(&mut out, "CARTESIAN_POINT('',(0.,0.,0.))".into());+    let z = add(&mut out, "DIRECTION('',(0.,0.,1.))".into());+    let x = add(&mut out, "DIRECTION('',(1.,0.,0.))".into());+    let ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{o},#{z},#{x})"));+    let mut breps = Vec::new();+    let mut styled = Vec::new();+    // colours: a dark moulded body, bright leads+    let mut style = |out: &mut Vec<String>, rgb: (f64, f64, f64)| -> usize {+        let c = add(out, format!("COLOUR_RGB('',{},{},{})", r(rgb.0), r(rgb.1), r(rgb.2)));+        let fc = add(out, format!("FILL_AREA_STYLE_COLOUR('',#{c})"));+        let fa = add(out, format!("FILL_AREA_STYLE('',(#{fc}))"));+        let sf = add(out, format!("SURFACE_STYLE_FILL_AREA(#{fa})"));+        let ss = add(out, format!("SURFACE_SIDE_STYLE('',(#{sf}))"));+        let su = add(out, format!("SURFACE_STYLE_USAGE(.BOTH.,#{ss})"));+        add(out, format!("PRESENTATION_STYLE_ASSIGNMENT((#{su}))"))+    };+    let body_style = style(&mut out, (0.15, 0.15, 0.16));+    let lead_style = style(&mut out, (0.80, 0.80, 0.82));+    let mark_style = style(&mut out, (0.92, 0.92, 0.90));+    for sol in solids {+        // shared vertices and edges, so the shell is closed+        let mut verts: Vec<(P3, usize)> = Vec::new();+        let mut edges: Vec<((usize, usize), usize)> = Vec::new();+        let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];+        let mut faces = Vec::new();+        for f in &sol.faces {+            let mut vids = Vec::new();+            for p in f {+                let k = key(p);+                let vid = match verts.iter().find(|(q, _)| key(q) == k) {+                    Some((_, v)) => *v,+                    None => {+                        let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(p[0]), r(p[1]), r(p[2])));+                        let v = add(&mut out, format!("VERTEX_POINT('',#{cp})"));+                        verts.push((*p, v));+                        v+                    }+                };+                vids.push((vid, *p));+            }+            let mut oes = Vec::new();+            for i in 0..vids.len() {+                let (a, b) = (vids[i], vids[(i + 1) % vids.len()]);+                let (lo, hi) = if a.0 < b.0 { (a.0, b.0) } else { (b.0, a.0) };+                let eid = match edges.iter().find(|(k, _)| *k == (lo, hi)) {+                    Some((_, e)) => *e,+                    None => {+                        // the edge runs from the lower vertex id to the higher+                        let (pa, pb) = if a.0 == lo { (a.1, b.1) } else { (b.1, a.1) };+                        let len = ((pb[0] - pa[0]).powi(2) + (pb[1] - pa[1]).powi(2) + (pb[2] - pa[2]).powi(2)).sqrt().max(1e-12);+                        let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(pa[0]), r(pa[1]), r(pa[2])));+                        let dir = add(&mut out, format!("DIRECTION('',({},{},{}))", r((pb[0] - pa[0]) / len), r((pb[1] - pa[1]) / len), r((pb[2] - pa[2]) / len)));+                        let vec = add(&mut out, format!("VECTOR('',#{dir},{})", r(len)));+                        let ln = add(&mut out, format!("LINE('',#{cp},#{vec})"));+                        let e = add(&mut out, format!("EDGE_CURVE('',#{lo},#{hi},#{ln},.T.)"));+                        edges.push(((lo, hi), e));+                        e+                    }+                };+                oes.push(add(&mut out, format!("ORIENTED_EDGE('',*,*,#{eid},{})", if a.0 == lo { ".T." } else { ".F." })));+            }+            let loop_ = add(&mut out, format!("EDGE_LOOP('',({}))", oes.iter().map(|e| format!("#{e}")).collect::<Vec<_>>().join(",")));+            let bound = add(&mut out, format!("FACE_OUTER_BOUND('',#{loop_},.T.)"));+            // the face's plane: Newell's normal, a reference direction along its first edge+            let mut nrm = [0.0f64; 3];+            for i in 0..f.len() { let (a, b) = (f[i], f[(i + 1) % f.len()]); nrm[0] += (a[1] - b[1]) * (a[2] + b[2]); nrm[1] += (a[2] - b[2]) * (a[0] + b[0]); nrm[2] += (a[0] - b[0]) * (a[1] + b[1]); }+            let nl = (nrm[0] * nrm[0] + nrm[1] * nrm[1] + nrm[2] * nrm[2]).sqrt().max(1e-12);+            let nn = [nrm[0] / nl, nrm[1] / nl, nrm[2] / nl];+            let e0 = [f[1][0] - f[0][0], f[1][1] - f[0][1], f[1][2] - f[0][2]];+            let el = (e0[0] * e0[0] + e0[1] * e0[1] + e0[2] * e0[2]).sqrt().max(1e-12);+            let cp = add(&mut out, format!("CARTESIAN_POINT('',({},{},{}))", r(f[0][0]), r(f[0][1]), r(f[0][2])));+            let dn = add(&mut out, format!("DIRECTION('',({},{},{}))", r(nn[0]), r(nn[1]), r(nn[2])));+            let dr = add(&mut out, format!("DIRECTION('',({},{},{}))", r(e0[0] / el), r(e0[1] / el), r(e0[2] / el)));+            let pl_ax = add(&mut out, format!("AXIS2_PLACEMENT_3D('',#{cp},#{dn},#{dr})"));+            let plane = add(&mut out, format!("PLANE('',#{pl_ax})"));+            faces.push(add(&mut out, format!("ADVANCED_FACE('',(#{bound}),#{plane},.T.)")));+        }+        let shell = add(&mut out, format!("CLOSED_SHELL('',({}))", faces.iter().map(|f| format!("#{f}")).collect::<Vec<_>>().join(",")));+        let brep = add(&mut out, format!("MANIFOLD_SOLID_BREP('{}',#{shell})", if sol.mark { "marking" } else if sol.lead { "lead" } else { "body" }));+        breps.push(brep);+        styled.push(add(&mut out, format!("STYLED_ITEM('color',(#{}),#{brep})", if sol.mark { mark_style } else if sol.lead { lead_style } else { body_style })));+    }+    let rep = add(&mut out, format!("ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',(#{ax},{}),#{ctx})", breps.iter().map(|b| format!("#{b}")).collect::<Vec<_>>().join(",")));+    add(&mut out, format!("SHAPE_DEFINITION_REPRESENTATION(#{pds},#{rep})"));+    add(&mut out, format!("MECHANICAL_DESIGN_GEOMETRIC_PRESENTATION_REPRESENTATION('',({}),#{ctx})", styled.iter().map(|s| format!("#{s}")).collect::<Vec<_>>().join(",")));+    format!("ISO-10303-21;\nHEADER;\nFILE_DESCRIPTION(('altium-bridge generate_body'),'2;1');\nFILE_NAME('{name}.step','2026-01-01T00:00:00',(''),(''),'altium-bridge','altium-bridge','');\nFILE_SCHEMA(('AUTOMOTIVE_DESIGN {{ 1 0 10303 214 1 1 1 1 }}'));\nENDSEC;\nDATA;\n{}\nENDSEC;\nEND-ISO-10303-21;\n", out.join("\n"))+}++/// A segment font for markings: each character is strokes between the points of a 2 x 2 cell (x 0..1, y 0..2), as+/// a 16-segment display draws them plus two diagonals for V. Part markings use capitals, digits and a few signs.+fn glyph(c: char) -> Option<&'static [((f64, f64), (f64, f64))]> {+    const TL: (f64, f64) = (0.0, 2.0); const TM: (f64, f64) = (0.5, 2.0); const TR: (f64, f64) = (1.0, 2.0);+    const ML: (f64, f64) = (0.0, 1.0); const MM: (f64, f64) = (0.5, 1.0); const MR: (f64, f64) = (1.0, 1.0);+    const BL: (f64, f64) = (0.0, 0.0); const BM: (f64, f64) = (0.5, 0.0); const BR: (f64, f64) = (1.0, 0.0);+    const A: ((f64, f64), (f64, f64)) = (TL, TR); const B: ((f64, f64), (f64, f64)) = (TR, MR); const C: ((f64, f64), (f64, f64)) = (MR, BR);+    const D: ((f64, f64), (f64, f64)) = (BL, BR); const E: ((f64, f64), (f64, f64)) = (ML, BL); const F: ((f64, f64), (f64, f64)) = (TL, ML);+    const G1: ((f64, f64), (f64, f64)) = (ML, MM); const G2: ((f64, f64), (f64, f64)) = (MM, MR); const G: ((f64, f64), (f64, f64)) = (ML, MR);+    const VT: ((f64, f64), (f64, f64)) = (TM, MM); const VB: ((f64, f64), (f64, f64)) = (MM, BM); const V: ((f64, f64), (f64, f64)) = (TM, BM);+    const DTL: ((f64, f64), (f64, f64)) = (TL, MM); const DTR: ((f64, f64), (f64, f64)) = (TR, MM);+    const DBL: ((f64, f64), (f64, f64)) = (MM, BL); const DBR: ((f64, f64), (f64, f64)) = (MM, BR);+    const LB: ((f64, f64), (f64, f64)) = (ML, BM); const RB: ((f64, f64), (f64, f64)) = (MR, BM);+    const DOT: ((f64, f64), (f64, f64)) = ((0.4, 0.0), (0.6, 0.0));+    // D and B: the left side full and the right corners cut, so D does not read as I and O side by side (it was a+    // box with a centre bar: "ADOM" read "AIOM" on a QFN in Altium, 2026-10-28) and B is not an 8+    const KT: (f64, f64) = (0.75, 2.0); const KM: (f64, f64) = (0.75, 1.0); const KB: (f64, f64) = (0.75, 0.0);+    const RT: (f64, f64) = (1.0, 1.75); const RU: (f64, f64) = (1.0, 1.25); const RL: (f64, f64) = (1.0, 0.75); const RB0: (f64, f64) = (1.0, 0.25);+    const DG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RB0), (RB0, KB), (BL, KB)];+    const BG: &[((f64, f64), (f64, f64))] = &[F, E, (TL, KT), (KT, RT), (RT, RU), (RU, KM), (ML, KM), (KM, RL), (RL, RB0), (RB0, KB), (BL, KB)];+    Some(match c.to_ascii_uppercase() {+        '0' => &[A, B, C, D, E, F, DTR, DBL], '1' => &[V], '2' => &[A, B, G, E, D], '3' => &[A, B, G2, C, D],+        '4' => &[F, G, B, C], '5' => &[A, F, G, C, D], '6' => &[A, F, E, D, C, G], '7' => &[A, B, C], '8' => &[A, B, C, D, E, F, G],+        '9' => &[A, B, C, D, F, G], 'A' => &[E, F, A, B, C, G], 'B' => BG, 'C' => &[A, F, E, D], 'D' => DG,+        'E' => &[A, F, E, D, G1], 'F' => &[A, F, E, G1], 'G' => &[A, F, E, D, C, G2], 'H' => &[F, E, B, C, G], 'I' => &[A, V, D],+        'J' => &[B, C, D, E], 'K' => &[F, E, G1, DTR, DBR], 'L' => &[F, E, D], 'M' => &[E, F, B, C, DTL, DTR], 'N' => &[E, F, B, C, DTL, DBR],+        'O' => &[A, B, C, D, E, F], 'P' => &[A, B, F, E, G], 'Q' => &[A, B, C, D, E, F, DBR], 'R' => &[A, B, F, E, G, DBR],+        'S' => &[A, F, G, C, D], 'T' => &[A, V], 'U' => &[F, E, D, C, B], 'V' => &[F, B, LB, RB], 'W' => &[E, F, B, C, DBL, DBR],+        'X' => &[DTL, DTR, DBL, DBR], 'Y' => &[DTL, DTR, VB], 'Z' => &[A, DTR, DBL, D], '-' => &[G], '+' => &[G, VT, VB],+        '/' => &[DTR, DBL], '.' => &[DOT], ' ' => &[],+        _ => return None,+    })+}++/// The marking as thin bars on the top face of `top` (the body's top), centred and sized to fit it, lines stacked.+/// Raised stands 30 um proud; engraved is a 5 um layer flush on the top (the body is not cut). Returns the solids and+/// the characters the font does not draw.+fn marking(text: &str, top: &Solid, engraved: bool) -> (Vec<Solid>, Vec<char>) {+    let b = bbox(std::slice::from_ref(top));+    let (w, d, z) = (b[3] - b[0], b[4] - b[1], b[5]);+    let (cx, cy) = ((b[0] + b[3]) / 2.0, (b[1] + b[4]) / 2.0);+    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();+    let mut skipped = Vec::new();+    if lines.is_empty() { return (Vec::new(), skipped); }+    // a character cell is 0.6 h wide with 0.25 h between characters; lines are 1.5 h apart+    let longest = lines.iter().map(|l| l.chars().count()).max().unwrap_or(1) as f64;+    let h = (0.85 * w / (longest * 0.85 - 0.25).max(0.6)).min(0.8 * d / (lines.len() as f64 * 1.5 - 0.5)).min(1.5);+    let stroke = (h * 0.12).max(0.03);+    let (z0, z1) = if engraved { (z, z + 0.005) } else { (z, z + 0.03) };+    let mut out = Vec::new();+    for (li, line) in lines.iter().enumerate() {+        let n = line.chars().count() as f64;+        let x0 = cx - (n * 0.85 - 0.25) * h / 2.0;+        let y0 = cy + (lines.len() as f64 * 1.5 - 0.5) * h / 2.0 - h - li as f64 * 1.5 * h;+        for (ci, ch) in line.chars().enumerate() {+            let Some(strokes) = glyph(ch) else { skipped.push(ch); continue };+            let ox = x0 + ci as f64 * 0.85 * h;+            for ((u0, v0), (u1, v1)) in strokes {+                // a stroke of the 0..1 x 0..2 cell, scaled to 0.6 h x h, as a bar `stroke` wide+                let (ax, ay, bx2, by) = (ox + u0 * 0.6 * h, y0 + v0 * 0.5 * h, ox + u1 * 0.6 * h, y0 + v1 * 0.5 * h);+                let len = (bx2 - ax).hypot(by - ay).max(1e-9);+                let (nx, ny) = (-(by - ay) / len * stroke / 2.0, (bx2 - ax) / len * stroke / 2.0);+                let (ex, ey) = ((bx2 - ax) / len * stroke / 2.0, (by - ay) / len * stroke / 2.0);+                let quad = vec![(ax - ex - nx, ay - ey - ny), (bx2 + ex - nx, by + ey - ny), (bx2 + ex + nx, by + ey + ny), (ax - ex + nx, ay - ey + ny)];+                let mut s = prism(&quad, z0, z1, false, |u, v, w| [u, v, w]);+                s.mark = true;+                out.push(s);+            }+        }+    }+    (out, skipped)+}++fn bbox(solids: &[Solid]) -> [f64; 6] {+    solids.iter().flat_map(|s| s.faces.iter().flatten()).fold([f64::MAX, f64::MAX, f64::MAX, f64::MIN, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].min(p[2]), b[3].max(p[0]), b[4].max(p[1]), b[5].max(p[2])])+}++/// generate_body: input `{args: {family, dimensionsMm?, marking?, markingStyle?, name?}}`. The package's body as STEP+/// text (`text`) and its box in the bodies companion's form (`bodies`, one box for the whole body; a raised marking's+/// 30 um is not part of it). No I/O: the bridge writes the file and attaches it (add_body).+pub fn generate_body(input: &Value) -> Value {+    let args = &input["args"];+    let fams = families();+    let family = args.get("family").and_then(|v| v.as_str()).unwrap_or("").to_lowercase().replace(['-', '_', ' '], "");+    let family = match family.as_str() { "sot23" | "sot233" => "sot23".to_string(), "tqfp" | "lqfp" => "qfp".into(), "son" | "wson" => "dfn".into(), "minimelf" => "melf".into(), "electrolytic" | "can" => "ecap".into(), f => f.to_string() };+    let Some((name, defaults)) = fams.iter().find(|(n, _)| *n == family) else {+        return json!({"success": false, "errorCode": "unknown_family", "error": format!("family {:?} is not one the engine makes", args.get("family")),+            "families": fams.iter().map(|(n, _)| *n).collect::<Vec<_>>()});+    };+    let mut dims = defaults.as_object().cloned().unwrap_or_default();+    if let Some(given) = args.get("dimensionsMm").and_then(|v| v.as_object()) {+        for (k, v) in given { if v.is_number() { dims.insert(k.clone(), v.clone()); } }+    }+    let Some(mut solids) = package(name, &dims) else { return json!({"success": false, "errorCode": "unknown_family"}) };+    // the marking sits on the body's top (an electrolytic can's top for ecap)+    let marking_text = args.get("marking").and_then(|v| v.as_str()).map(|t| t.replace("\\n", "\n")).filter(|t| !t.trim().is_empty());+    let engraved = args.get("markingStyle").and_then(|v| v.as_str()) == Some("engraved");+    let mut unmarked: Vec<char> = Vec::new();+    if let Some(t) = &marking_text {+        let top = solids[if *name == "ecap" { 1 } else { 0 }].clone();+        let (marks, skipped) = marking(t, &top, engraved);+        unmarked = skipped;+        solids.extend(marks);+    }+    let stem = args.get("name").and_then(|v| v.as_str()).unwrap_or(name).replace('\'', "");+    let r3 = |v: f64| (v * 1000.0).round() / 1000.0;+    // the box is the package's: a raised marking's 30 um is not part of the body's height+    let body: Vec<Solid> = solids.iter().filter(|s| !s.mark).cloned().collect();+    let b = bbox(&body);+    json!({"success": true, "text": step(&solids, &stem), "family": name, "dimensionsMm": dims, "solids": solids.len(),+        "bboxMm": {"x": r3(b[3] - b[0]), "y": r3(b[4] - b[1]), "z": r3(b[5] - b[2])},+        "bodies": [{"x0": r3(b[0]), "y0": r3(b[1]), "x1": r3(b[3]), "y1": r3(b[4]), "z0": r3(b[2]), "z1": r3(b[5])}],+        "marking": marking_text,+        "markingStyle": if marking_text.is_some() { json!(if engraved { "engraved (a 5 um layer flush on the top; the body is not cut)" } else { "raised 30 um" }) } else { Value::Null },+        "notApplied": if unmarked.is_empty() { json!([]) } else { json!([format!("marking characters the font does not draw: {}", unmarked.iter().collect::<String>())]) },+        "writtenFrom": "package dimensions",+        "_hint": "The body is in the package's own frame (IPC-7351 zero orientation, pin 1 top-left, seated on z = 0). Write text to a .step file and attach it (add_body). Compare bboxMm with the datasheet's body size before attaching it."})+}++#[cfg(test)]+mod tests {+    use super::*;++    fn closed(s: &Solid) -> bool {+        // every edge is used once each way: a closed, consistently oriented shell+        let key = |p: &P3| [(p[0] * 1e5).round() as i64, (p[1] * 1e5).round() as i64, (p[2] * 1e5).round() as i64];+        let mut e: Vec<([i64; 3], [i64; 3])> = Vec::new();+        for f in &s.faces { for i in 0..f.len() { e.push((key(&f[i]), key(&f[(i + 1) % f.len()]))); } }+        e.iter().all(|(a, b)| e.iter().filter(|x| x == &&(*b, *a)).count() == 1)+    }++    #[test]+    fn every_family_is_closed_solids_with_outward_faces() {+        for (name, def) in families() {+            let solids = package(name, def.as_object().unwrap()).unwrap();+            for s in &solids {+                assert!(closed(s), "{name}: a shell is open");+                assert!(signed_volume(s) > 0.0, "{name}: a solid is inside out");+            }+        }+    }++    #[test]+    fn a_soic8_has_its_lead_span_and_height() {+        let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;+        let s = package("soic", dims.as_object().unwrap()).unwrap();+        assert_eq!(s.len(), 9);+        let b = bbox(&s);+        assert!((b[3] - b[0] - 6.0).abs() < 1e-9 && (b[5] - 1.75).abs() < 1e-9 && b[2].abs() < 1e-9, "{b:?}");+        // pin 1 is the top-left lead+        let lead1 = bbox(&s[1..2]);+        assert!(lead1[0] < 0.0 && lead1[4] > 1.9, "{lead1:?}");+    }++    /// STEP_DUMP=<dir>: write every family's default body there, for a STEP reader to check+    #[test]+    fn dump_for_a_reader() {+        let Ok(dir) = std::env::var("STEP_DUMP") else { return };+        for (name, def) in families() {+            std::fs::write(format!("{dir}/{name}.step"), step(&package(name, def.as_object().unwrap()).unwrap(), name)).unwrap();+        }+        for (name, text, idx) in [("soic", "NE555\nTI 2X4", 0), ("qfn", "STM32\nG031", 0), ("ecap", "100UF\n25V", 1)] {+            let def = families().into_iter().find(|f| f.0 == name).unwrap().1;+            let mut s = package(name, def.as_object().unwrap()).unwrap();+            let top = s[idx].clone();+            s.extend(marking(text, &top, false).0);+            std::fs::write(format!("{dir}/{name}-marked.step"), step(&s, name)).unwrap();+        }+    }++    #[test]+    fn the_step_text_is_well_formed() {+        let s = package("chip", families()[0].1.as_object().unwrap()).unwrap();+        let t = step(&s, "R0603");+        assert!(t.starts_with("ISO-10303-21;") && t.trim_end().ends_with("END-ISO-10303-21;"));+        assert_eq!(t.matches("MANIFOLD_SOLID_BREP").count(), 3);+        assert_eq!(t.matches("ADVANCED_FACE").count(), 18);+        // every referenced entity exists+        let ids: Vec<usize> = t.lines().filter_map(|l| l.strip_prefix('#').and_then(|r| r.split('=').next()).and_then(|n| n.parse().ok())).collect();+        let max = *ids.iter().max().unwrap();+        for cap in t.split('#').skip(1) { let n: String = cap.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(n) = n.parse::<usize>() { assert!(n <= max); } }+    }++    #[test]+    fn a_marking_sits_on_the_body_top_and_fits_it() {+        let dims = families().into_iter().find(|f| f.0 == "soic").unwrap().1;+        let s = package("soic", dims.as_object().unwrap()).unwrap();+        let (m, skipped) = marking("NE555\nTI 2X", &s[0], false);+        assert!(skipped.is_empty());+        assert!(!m.is_empty());+        let (body, mb) = (bbox(&s[..1]), bbox(&m));+        assert!((mb[2] - 1.75).abs() < 1e-9 && (mb[5] - 1.78).abs() < 1e-9, "{mb:?}");+        assert!(mb[0] > body[0] && mb[3] < body[3] && mb[1] > body[1] && mb[4] < body[4], "inside the top: {mb:?} {body:?}");+        for x in &m { assert!(closed(x) && signed_volume(x) > 0.0); }+        assert_eq!(marking("A~B", &s[0], true).1, vec!['~']);+    }+    #[test]+    fn the_writer_answers_step_text_and_the_bodys_box() {+        let r = generate_body(&json!({"args": {"family": "SOIC", "marking": "NE555", "name": "U1"}}));+        assert_eq!(r["success"], json!(true));+        assert!(r["text"].as_str().unwrap().starts_with("ISO-10303-21;"));+        assert_eq!(r["bodies"][0]["z1"], json!(1.75), "the marking is not in the box");+        assert_eq!(r["bboxMm"]["x"], json!(6.0));+        assert_eq!(r["writtenFrom"], "package dimensions");+        assert_eq!(generate_body(&json!({"args": {"family": "nope"}}))["errorCode"], "unknown_family");+    }+    #[test]+    fn fusion_lanes_reference_parts_match() {+        // fusion_generate_package 1.13.3's 0603, SOIC-8, QFN-32 (3.3 mm exposed pad) and QFP-48 (#20): the same box and+        // the same number of solids+        let cases = [+            ("chip", json!({"D": 1.6, "E": 0.8, "A": 0.45, "L": 0.3}), [1.6, 0.8, 0.45], 3),+            ("soic", json!({"pins": 8, "D": 4.9, "E": 6.0, "E1": 3.9, "A": 1.75, "A1": 0.1, "b": 0.41, "e": 1.27, "L": 0.84}), [6.0, 4.9, 1.75], 9),+            ("qfn", json!({"pins": 32, "D": 5.0, "E": 5.0, "A": 0.9, "b": 0.25, "e": 0.5, "L": 0.4, "D2": 3.3, "E2": 3.3}), [5.0, 5.0, 0.9], 34),+            ("qfp", json!({"pins": 48, "D": 9.0, "E": 9.0, "D1": 7.0, "E1": 7.0, "A": 1.6, "A1": 0.1, "b": 0.22, "e": 0.5, "L": 0.6}), [9.0, 9.0, 1.6], 49),+        ];+        for (family, dims, size, solids) in cases {+            let r = generate_body(&json!({"args": {"family": family, "dimensionsMm": dims}}));+            assert_eq!(r["solids"], json!(solids), "{family}");+            assert_eq!([r["bboxMm"]["x"].as_f64().unwrap(), r["bboxMm"]["y"].as_f64().unwrap(), r["bboxMm"]["z"].as_f64().unwrap()], size, "{family}");+        }+        // the exposed pad is drawn only when given+        let mut no_ep = json!({"pins": 32, "D": 5.0, "E": 5.0});+        no_ep["D2"] = json!(0.0);+        assert_eq!(generate_body(&json!({"args": {"family": "qfn", "dimensionsMm": no_ep}}))["solids"], json!(33));+        // pins is the total in a BGA too: 64 balls are an 8 x 8 grid+        assert_eq!(package("bga", json!({"pins": 64}).as_object().unwrap()).unwrap().len(), 65);+    }++    #[test]+    fn d_and_b_are_not_i_o_or_8() {+        // D was a box with a centre bar and read as "IO"; B was an 8 with a bar+        assert_ne!(glyph('D'), glyph('O'));+        assert_ne!(glyph('B'), glyph('8'));+        for c in ['D', 'B'] {+            let g = glyph(c).unwrap();+            assert!(g.iter().all(|s| s.0 .0 != 0.5 || s.1 .0 != 0.5), "{c} has no centre bar");+            assert!(!g.iter().any(|s| (s.0 == (1.0, 2.0)) || (s.1 == (1.0, 2.0))), "{c}'s top-right corner is cut");+        }+    }+}+--- a/rust/vendor/adom-eda-checks/src/writers/mod.rs+++ b/rust/vendor/adom-eda-checks/src/writers/mod.rs@@ -1,26 +1,29 @@ //! Board-file writers for a bridge whose tool has no native exporter (EDA Bridge Standard #16): GenCAD and DXF from the-//! board model, and SVG artwork as regions for a layer. Like the checks, no I/O and no tool: each returns the file text+//! board model, SVG artwork as regions for a layer, and a part's 3D body as STEP from its package dimensions (#20). Like the checks, no I/O and no tool: each returns the file text //! or the regions as JSON, says it was written from the board model (`writtenFrom`), and the bridge writes the files or //! places the regions. A bridge whose tool has a native exporter keeps it. use serde_json::Value; +pub mod body3d; pub mod dxf; pub mod gencad; pub mod svgart;  /// The verbs these writers answer, by standard name.-pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "import_svg"];+pub const WRITERS: &[&str] = &["export_dxf", "export_gencad", "generate_body", "import_svg"];  pub use dxf::dxf_layers; pub use gencad::gencad;⋯ 4 unchanged lines ⋯     Some(match verb {         "export_dxf" => dxf_layers(input),         "export_gencad" => gencad(input),+        "generate_body" => body3d::generate_body(input),         "import_svg" => svg_to_regions(input),         _ => return None,     }) }+--- a/skills/kicad-board-checks/SKILL.md+++ b/skills/kicad-board-checks/SKILL.md@@ -1,148 +1,156 @@ --- 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, 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.+  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, generate a 3D body, make a STEP model, plan a route, path between two pads, symbol and footprint images, review a library part, pin to pad map, hide a layer, layer visibility. ---  # Board checks -Fifteen verbs check a board and, where a fix is mechanical, apply it. They answer the EDA Bridge+Sixteen 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. ⋯ 16 unchanged lines ⋯ | `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_plan_route {net, from: "U1.3", to: "J2.1", layers?, gridMm?}` | a path for one net between two pads, around the copper and inside the clearance rules, vias where they help; nothing changes | `routeNet` is `kicad_route_net`'s own arguments (filePath, frame, expectedRevision included): pass it unchanged, then `kicad_run_drc`. Fine-pitch pads need `gridMm: 0.1`; pours are not obstacles, so refill after | | `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⋯ 92 unchanged lines ⋯ Footprint field; from a `.pretty` folder pass `symbolLibraryPath` for the symbol. A footprint without a model, or whose model file does not resolve, gets `ok:false` for 3d, never an empty picture. +`kicad_generate_body {family, dimensionsMm?, marking?, libraryPath?, footprintName?}` makes a part's 3D body+from its package family (chip, SOIC, QFN, QFP, BGA, SOT-23, DFN, MELF, can, crystal, header) and the datasheet's+JEDEC letters, to IPC-7351 proportions, with an optional marking on top. The standard's engine writes the STEP;+with `libraryPath` it lands in `<Lib>.3dshapes`, and `footprintName` attaches it (kicad_add_body). Compare+`bboxMm` with the datasheet before trusting it, then look with `kicad_library_views {views:["3d"]}`.+ `kicad_set_layer_visibility {layer | layerId, displayed}` shows or hides one layer in the open PCB editor and reads it back. It is view state the user keeps seeing: restore what you hid. ⋯ 13 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,1680 +1,1698 @@⋯ 195 unchanged lines ⋯             "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")},+            "library_views", "generate_body", "plan_route")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),⋯ 413 unchanged lines ⋯             "totals": {"pass": 0, "fail": 0, "skip": 0}, "fatal": f"KiCad editors open: {others}",             "failures": [], "coverage_gap": [], "results": {}}, indent=2))         return 2+    if others:+        # --close-open-kicad: they are known to be disposable, so close them now; an editor left open (one board+        # served by the API while another matches by title) made show_2d_board refuse in phase 3 (1.0.74 run)+        print(f"[preflight] closing disposable KiCad editors: {others}")+        ab(a.target, "kicad_close", {"force": True, "reason": "battery --close-open-kicad: the open editors are known to be disposable"}, timeout=120)+        time.sleep(4)      # phase 0: push fixtures     print(f"[0] pushing rp2040 fixture to {base} ...")⋯ 952 unchanged lines ⋯         own("library_views", {"stock": r, "nobody": nb}, ok(r) and all((cap.get(v) or {}).get("ok") for v in ("symbol", "footprint", "3d", "device")) and (cap["device"].get("matches") is True)             and (nbc.get("footprint") or {}).get("ok") and (nbc.get("3d") or {}).get("ok") is False and "no 3D body" in str((nbc.get("3d") or {}).get("error")),             "not every view of a stock part was captured, or a body-less footprint drew a 3d view")+        # 1.0.74: a body made by the standard's engine (generate_body), attached and pictured; a route planned by it (plan_route)+        C("footprint_from_land_pattern", dict(lp, name="SOIC-8_gen", body={"w": 3.9, "l": 4.9}))+        gb = C("generate_body", {"family": "soic", "dimensionsMm": {"pins": 8}, "marking": "LM358", "libraryPath": lp["libraryPath"], "footprintName": "SOIC-8_gen", "overwrite": True})+        gv = C("library_views", {"libraryPath": lp["libraryPath"], "parts": ["SOIC-8_gen"], "views": ["3d"], "outputDir": w["out"] + "/views"}, 300)+        g3 = next((c for c in gv.get("captured") or [] if c.get("view") == "3d"), {})+        own("generate_body", {"body": gb, "view": gv}, ok(gb) and eng(gb) and abs(((gb.get("bboxMm") or {}).get("z") or 0) - 1.75) < 0.01 and (gb.get("attached") or {}).get("success") and g3.get("ok") and abs((g3.get("bodyHeightMm") or 0) - 1.75) < 0.1,+            "no SOIC-8 body written, attached and rendered at its 1.75 mm height")+        pr = C("plan_route", {"filePath": CK, "source": "file", "net": "/DRV_GHC", "from": "R12.1", "to": "U4.13"}, 200)+        rn = pr.get("routeNet") or {}+        own("plan_route", pr, ok(pr) and eng(pr) and pr.get("found") is True and rn.get("expectedRevision") == pr.get("revision") and rn.get("filePath") and rn.get("paths") and rn["paths"][0][0] == {"pad": "R12.1"},+            "no route planned for /DRV_GHC, or routeNet is not ready for kicad_route_net")         r = C("panelize", {"pcbPath": w["board"], "panelPath": w["out"] + "/panel/panel-tabs.kicad_pcb", "rows": 2, "cols": 2, "separation": "tabs", "overwrite": True}, 300)         record("panelize_tabs", r, (ok(r) and (r.get("separation") or {}).get("kind") == "tabs" and (r.get("separation") or {}).get("holes", 0) > 0) or r.get("errorCode") == "not_rectangular",                "no mouse-bite panel")⋯ 98 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+

Comments

No comments yet.

Log in to comment.