app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
Merge PR #342: kicad-lane: 1.0.84, review_board, engine 0.17.2
15 files changed
+542−16
bridge.json+2−1rust/Cargo.lock+4−4rust/Cargo.toml+1−1rust/crates/kicad-bridge/src/verbs_checks_native.rs+66rust/crates/kicad-core/src/netlist.rs+2−1rust/vendor/adom-eda-checks/Cargo.toml+1−1rust/vendor/adom-eda-checks/README.md+7−1rust/vendor/adom-eda-checks/VENDOR.md+3−3rust/vendor/adom-eda-checks/src/lib.rs+5−1rust/vendor/adom-eda-checks/src/main.rs+1−1rust/vendor/adom-eda-checks/src/report.rs+180rust/vendor/adom-eda-checks/src/review.rs+247rust/vendor/adom-eda-checks/src/schreview.rs+14rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1skills/kicad-bridge-test/run_verb_tests.py+8−1bridge.json+2−1@@ -315,7 +315,8 @@ "kicad_license_status", "kicad_run_script", "kicad_eval",- "kicad_schematic_review"+ "kicad_schematic_review",+ "kicad_review_board" ], "statusVerb": "kicad_status", "timeouts": {
rust/Cargo.lock+4−4@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adom-eda-checks"-version = "0.16.0"+version = "0.17.2" dependencies = [ "roxmltree", "serde_json",@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.83"+version = "1.0.84" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.83"+version = "1.0.84" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.83"+version = "1.0.84" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.83"+version = "1.0.84" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_checks_native.rs+66@@ -80,6 +80,17 @@ pub static VERBS: &[Verb] = &[ 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_review_board",+ summary: "Design review of the whole board in one read (with the schematic's parts from KiCad's netlist when the project has one): duplicate designators, parts off the board or near its edge, placeholder values on the board and the sheets, one-pin nets, parts without a footprint, missing vendor part numbers and datasheets, and disabled or missing design rules, each finding with its severity.",+ // File: the schematic's parts come from kicad-cli's netlist+ mechanism: Mechanism::File, risk: "read", timeout_sec: 120,+ input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"includeSchematic\"?: true, \"checks\"?: [\"...\"], \"frame\"?: \"standard|kicad\"}",+ example: "kicad_review_board {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",+ hint: "findings [{check, severity, message, ...}] with counts by severity; skipped and notImplemented name what could not be judged. The schematic is the .kicad_sch beside the board (includeSchematic:false leaves it out). The standard's review_board, computed by its engine (engine says which). kicad_run_drc is KiCad's own DRC; kicad_board_report puts every check on one page.",+ related: &["kicad_run_drc", "kicad_schematic_review", "kicad_board_report", "kicad_placement_check"],+ pitfalls: &["The schematic is read saved (kicad-cli): save it in KiCad first."],+ }, 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.",@@ -173,6 +184,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> "kicad_height_check" => height_check(state, args), "kicad_placement_check" => placement_check(state, args), "kicad_plan_route" => plan_route(state, args),+ "kicad_review_board" => review_board(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),@@ -864,6 +876,60 @@ pub(crate) fn plan_fanout(state: &mut State, args: &Value) -> Value { v } +/// kicad_review_board: the board model, its rules and (when a schematic is beside it) the sheet parts from KiCad's+/// netlist, reviewed by the standard's engine.+fn review_board(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 rules = crate::verbs_checks::clearance_rules(&r.file);+ if let Some(w) = class["track_width"].as_f64() {+ rules.push(json!({"kind": "width", "name": "Default net class track width", "preferredMm": w}));+ }+ // KiCad's DRC always checks shorts, and Board Setup always enforces a minimum clearance, track width and hole size+ // (its defaults when the project sets none), so those rule classes are never missing on a KiCad board+ let pro: Value = std::fs::read_to_string(PathBuf::from(&r.file).with_extension("kicad_pro")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+ if let Some(h) = pro.pointer("/board/design_settings/rules/min_through_hole_diameter").and_then(Value::as_f64) {+ rules.push(json!({"kind": "hole_size", "name": "Board Setup minimum through hole", "minMm": h}));+ }+ let mut ea = crate::verbs_checks::engine_args(args);+ let mut always: Vec<Value> = ea["alwaysChecked"].as_array().cloned().unwrap_or_default();+ for k in ["short_circuit", "clearance", "width", "hole_size"] {+ if !always.contains(&json!(k)) {+ always.push(json!(k));+ }+ }+ ea["alwaysChecked"] = json!(always);+ let mut bundle = json!({"board": model, "rules": rules, "args": ea});+ let sch = PathBuf::from(&r.file).with_extension("kicad_sch");+ let mut schematic = Value::Null;+ if args.get("includeSchematic") != Some(&json!(false)) && sch.is_file() {+ let cli = state.kicad_info().primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file());+ match cli.map(|c| kicad_core::netlist::load(&c, &sch)) {+ Some(Ok(nl)) => {+ let (parts, _) = kicad_core::netlist::review_inputs(&nl);+ bundle["sheetParts"] = parts;+ schematic = json!(norm(&sch));+ }+ Some(Err(e)) => schematic = json!({"notRead": format!("{e:?}").chars().take(200).collect::<String>()}),+ None => schematic = json!({"notRead": "kicad-cli not found"}),+ }+ }+ let mut v = adom_eda_checks::run("review_board", &bundle);+ if v["success"] != json!(true) {+ return v;+ }+ v["schematic"] = schematic;+ v["revision"] = model["revision"].clone();+ v["frame"] = json!(if o.y_up { "standard" } else { "kicad" });+ stamp(&mut v, &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");
rust/crates/kicad-core/src/netlist.rs+2−1@@ -311,7 +311,8 @@ pub fn review_inputs(model: &Netlist) -> (serde_json::Value, serde_json::Value) nets.insert(n.name.trim_start_matches('/').to_string(), json!(n.nodes.iter().map(|nd| format!("{}.{}", nd.reference, nd.pin)).collect::<Vec<_>>())); } }- let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| json!({"designator": r, "pins": pins})).collect();+ let value = |r: &str| model.component(r).map(|c| c.value.clone()).unwrap_or_default();+ let comps: Vec<serde_json::Value> = parts.into_iter().map(|(r, pins)| { let v = value(&r); json!({"designator": r, "pins": pins, "value": v, "comment": v}) }).collect(); (json!({"components": comps}), json!({"nets": nets})) }
rust/vendor/adom-eda-checks/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.16.0"+version = "0.17.2" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
rust/vendor/adom-eda-checks/README.md+7−1@@ -13,6 +13,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. |---|---|---| | `assembly_check` | board, stack? | the board against JLCPCB's two PCBA tiers as published (with the page and its read date): size (or `panelMm`), thickness and layers from the stack, one or two sides, the smallest two-pad part and the finest IC pitch, fiducials, and parts without an LCSC number (`lcscMissing` from the bridge's BOM). Pads sharing one centre are one land, and a component with no pads is no part. `service` is the cheaper tier every rule allows. Matches the Altium bridge's answers on its test boards. From its assembly.rs | | `auto_classes` | board | net classes from the names the nets already have (GND, Power, Clock, HighSpeed) and differential pairs from their suffixes (`_P`/`_N`, `DP`/`DN`, `+`/`-`), with `applyArgs`: the net_classes and diff_pairs calls that write them (Power with a `powerWidthMm` width rule). From the Altium bridge's autoclass.rs |+| `board_report` | board, and every companion it can use | every check the engine has, run on this bundle, as one self-contained HTML page (`text`), dark by default: fab limits (with `fab`), JLCPCB assembly, schematic review and symbols against footprints (with `sheetParts` and `netlist`), impedance, return paths and copper balance (with `stack`), 3D heights (with `bodies`), the design review, and the tool's own DRC (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them; `args` carries `lcscMissing`, `panelMm`, `gridMm` and `alwaysChecked` to those checks. A section whose input is missing says which to pass. From the Altium bridge's report.rs | | `check_clearance` | board, bodies? | pairs of parts closer than `thresholdMm`, closest first: `mode: courtyard` (default 0.25) measures part boxes (pads, and 3D bodies when given) on the same side; `mode: pads` (default 0.2) measures pad copper of different parts that shares a layer, by its real outline, skipping same-net pairs unless `includeSameNet`; `refs` keeps pairs involving those parts. A part under a raised one (pads apart, bodies apart in 3D) is listed in `stacked`, not `pairs`, as placement_check lists it. From the KiCad bridge's place_tools | | `check_orientation` | board | advisory: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there, distance to the nearest pad of a plane-like net such as GND); turns saving `minImprovementMm` (0.6) or more are suggested, nothing moves. From the Altium bridge's design.rs | | `copper_balance` | board, stack, pours | copper per layer and how evenly mirrored layers match (bow and twist) |@@ -30,6 +31,7 @@ boards the engine gives the same answers the Altium bridge 0.2.76 gives. | `placement_check` | board, bodies? | courtyard overlaps (pads and bodies, grown by `marginMm`), parts off or over the edge, pads within `edgeMm` of the edge, parts on mounting holes; `stacked` lists plan-view overlaps where one part sits wholly above the other (a part under a raised molecule: pads apart, no bodies meeting in 3D); `bodiesUsed: false` when no bodies were given | | `plan_fanout` | board, stack?, rules? | the fanout of a BGA or QFN/QFP (`ref`): pitch and package from its pads; a BGA's outer ring escapes on the surface and each inner ball takes a dogbone via, or a via in the pad when the dogbone does not fit (`style` auto); a perimeter pad takes an outward stub, with a via at its end for ground or `viaPerimeter`, and an exposed pad a via grid. `calls` are route_net arguments, one per net; `conflicts` lists each via and stub closer than the clearance to another net's pad, track, arc or via anywhere on the board. A pin its net's copper already reaches is skipped (`includeRouted` plans it anyway), the exposed-pad grid keeps clear of holes already there, and `crossesPours` names other nets' pours the plan runs into (repour before DRC). From the KiCad bridge's place_tools | | `plan_route` | board, rules? | a path between two pads of a net (`from`, `to` as Ref.Pad) on any listed copper `layers`, with through vias between them: a bounded orthogonal A* on `gridMm`, width and via from the args, else the board's width and via rules (`planner.widthFrom`, `viaFrom`), keeping `widthMm/2` + the board's strictest board-wide copper clearance rule from other nets' copper (pads by their real outline), inside the outline, no via in a pad; a through via is checked on every copper layer of the board (`planner.viasCheckedOn`), not only the planned ones. `routeNet` is route_net's arguments, unchanged. Pours are not obstacles (repour after), but `crossesPours` names each other net's pour the route runs through, since that can split it; `avoidPours: true` keeps the route out of them (or a list of nets: out of theirs only), and `pourCost` makes a mm inside one cost 1 + pourCost mm, a preference where a hard block finds no path; a pour that holds the route's own end pad is exempt and named (`planner.avoidExempt`); with no path, `ends` names the avoided pours at each end pad and whether they are exempt, and `withoutAvoid` says whether a path exists through them. The grid path is smoothed to straight and 45-degree segments where they keep the same clearance (`smooth: false` keeps it) |+| `review_board` | board, sheetParts?, rules? | small design-review checks, errors first: placeholder values, single-pin nets, duplicate designators, parts with pads off the outline polygon or near the edge (`edgeMm`), parts with no footprint name, off-grid parts (with `gridMm`); from the sheet parts, missing Mouser/DigiKey/LCSC numbers and datasheets and placeholder comments; from the rules, disabled ones, missing clearance/width/hole-size/short-circuit rules and same-priority ties (rules classed from a standard kind, a descriptor, Altium's ordinal or the rule's own name; `alwaysChecked` names classes a tool always checks, like EAGLE's shorts). `skipped` and `notImplemented` say what was not judged. From the Altium bridge's review.rs | | `route_pair` | board, stack?, rules? | a differential pair planned as one centreline (pad midpoints through `via` waypoints) with each net offset half a pitch to its own side, mitred corners and a `fanMm` fan-out; width and gap given, or from `diffOhms` and the stack. `problems` names a crossing or other copper (pads by outline, routed tracks, vias) closer than the clearance; `routeNet` holds both nets' route_net arguments and `widthRule` the pair's add_rule. From the Altium bridge's pairroute.rs | | `schematic_review` | sheetParts, netlist | every placed pin, with its electrical type, against the compiled netlist: power pins on no net and outputs driving each other (errors); unconnected pins, one-pin nets, inputs nothing drives and IC supplies with no capacitor to ground (warnings; a regulator's switch node on an inductor is no missing capacitor). From the Altium bridge's schreview.rs | | `silk_designators` | board, silk | a place for each reference designator: one size (`size`, stepping down to `minSize`), beside its own part, clear of every pad by `padClearance`, of other parts' bodies and of other labels, inside the board. `applyArgs` is apply_silkscreen's arguments; parts with no clear spot are listed, not forced. From the Altium bridge's silkplace.rs |@@ -75,6 +77,10 @@ Every check takes one JSON object: | `silk` | `<prefix>_silk_state` | | `fab` | one record from adom/pcb-design-rules `rules/records/<vendor>.json`, with `vendor` | | `prices` | a price record read from the fab's own page: `{vendor, url, read, currency, pcb: [{layers, setup, perM2, promo?: {maxMm: [w, h], prices: [[qty, price]]}}], thickness?: {standardMm, factor}, assembly?: {setupPerSide, stencilPerSide, perSmdJoint, perThtJoint, perExtendedPart}, excludes?}`. The pcb row used is the one for the fewest copper layers that covers the board |+| `sheetParts` | every sheet's parts as `<prefix>_read_sheet_file` gives them: `[{designator, comment?, params?: [{name, value}], pins: [{number, name, electrical}]}]` (or a reply with `components`). `electrical` is a type name (input, output, io, passive, power, supply, open_collector, hiz, not_connected) or Altium's code; a part whose pins have no numbers (a package-less supply symbol) is no part |+| `netlist` | the compiled netlist: `{nets: {net: ["U1.8", ...]}}` |+| `drc` | board_report only: the tool's own DRC reply (`run_drc`: errors, unconnected, violations) |+| `picture` | board_report only: a base64 PNG of the board, embedded in the page | | `args` | the check's own arguments, as the standard verb documents them | Each part may be the bare model or the bridge's whole reply: the model's keys at the top level, or under `data`.@@ -106,7 +112,7 @@ adom-eda-checks import_svg --svg logo.svg --args '{"at": [10, 10], "widthMm": 20 Replies keep the standard envelope (`success`, `errorCode`, `error`, `_hint`) and name verbs without a prefix (`apply_pours`, `add_rule`, `place_vias`): a bridge adds its own. Every reply also carries `engine`-(`"adom-eda-checks 0.16.0"`), so a bridge reports the engine it runs without writing the version by hand.+(`"adom-eda-checks 0.17.2"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
rust/vendor/adom-eda-checks/VENDOR.md+3−3@@ -3,9 +3,9 @@ The EDA Bridge Standard's shared checks engine, copied unchanged from adom/eda-bridge-standard `engine/` (the wiki has no git protocol, so Cargo cannot fetch it). -- Standard version: 0.19.0-- Standard commit: 1f43002359e5ba5d169ce17766118de8120b25d1-- Crate version: 0.16.0+- Standard version: 0.20.2+- Standard commit: 3c0ef8ff5e5bc11c68ba23f8038dc8d2643eea75+- Crate version: 0.17.2 - Copied: 2026-10-28, by kicad-lane To update: clone the standard fresh (`adom-wiki repo clone adom/eda-bridge-standard`), copy `engine/src`,
rust/vendor/adom-eda-checks/src/lib.rs+5−1@@ -21,6 +21,8 @@ pub mod input; pub mod lengths; pub mod lengthtune; pub mod pairroute;+pub mod report;+pub mod review; pub mod placement; pub mod placetools; pub mod route;@@ -34,7 +36,7 @@ pub mod writers; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The checks this build answers, by standard verb name.-pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "route_pair", "schematic_review", "silk_designators", "thermal_vias", "via_fence"];+pub const CHECKS: &[&str] = &["assembly_check", "auto_classes", "board_report", "check_clearance", "check_orientation", "copper_balance", "current_capacity", "estimate_cost", "fab_check", "find_space", "height_check", "impedance", "length_tune", "net_lengths", "pin_pad_check", "place_decoupling", "placement_check", "plan_fanout", "plan_route", "return_path", "review_board", "route_pair", "schematic_review", "silk_designators", "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 {@@ -48,6 +50,7 @@ fn answer(check: &str, input: &Value) -> Value { match check { "assembly_check" => assembly::assembly_check(input), "auto_classes" => autoclass::auto_classes(input),+ "board_report" => report::board_report(input), "check_clearance" => placetools::check_clearance(input), "check_orientation" => space::check_orientation(input), "copper_balance" => coppermap::copper_balance(input),@@ -65,6 +68,7 @@ fn answer(check: &str, input: &Value) -> Value { "placement_check" => placement::placement_check(input), "plan_route" => route::plan_route(input), "return_path" => coppermap::return_path(input),+ "review_board" => review::review_board(input), "route_pair" => pairroute::route_pair(input), "schematic_review" => schreview::schematic_review(input), "silk_designators" => silkplace::silk_designators(input),
rust/vendor/adom-eda-checks/src/main.rs+1−1@@ -23,7 +23,7 @@ fn bundle(rest: &[String]) -> Result<Value, String> { while let Some(flag) = it.next() { let val = it.next().ok_or_else(|| format!("{flag} needs a value"))?; match flag.as_str() {- "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" => {+ "--board" | "--stack" | "--pours" | "--rules" | "--silk" | "--fab" | "--bodies" | "--prices" | "--sheet-parts" | "--netlist" | "--drc" => { let key = match flag.as_str() { "--sheet-parts" => "sheetParts", f => &f[2..] }; b[key] = read(val)?; }
rust/vendor/adom-eda-checks/src/report.rsadded+180@@ -0,0 +1,180 @@+//! One shareable page per board: every check the engine has, run against one bundle, written as a single self-contained+//! HTML page with the board's picture when the bridge passes one. From the Altium bridge's report.rs.+use serde_json::{json, Value};++fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) }+fn txt(v: &Value) -> String {+ match v {+ Value::String(s) => s.clone(),+ Value::Null => String::new(),+ // lists read as plain text, not JSON+ Value::Array(a) => a.iter().map(txt).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", "),+ other => other.to_string(),+ }+}++/// One section of the page: a title, a verdict (true, false, None = information) and rows of (label, value). Pure.+pub struct Section { pub title: String, pub ok: Option<bool>, pub summary: String, pub rows: Vec<(String, String)> }++/// The sections from the checks' replies (any may be an error reply: it shows as such). Pure.+pub fn sections(c: &Value) -> Vec<Section> {+ let mut out = Vec::new();+ let failed = |r: &Value| r.get("success").and_then(|b| b.as_bool()) == Some(false);+ let err = |title: &str, r: &Value| Section { title: title.into(), ok: None, summary: format!("not run: {}", txt(r.get("error").unwrap_or(&Value::Null))), rows: vec![] };+ // design rules+ let d = &c["drc"];+ out.push(if d.is_null() { Section { title: "Design rules (the tool's DRC)".into(), ok: None, summary: "not run: pass the tool's DRC reply (run_drc) as drc".into(), rows: vec![] } } else if failed(d) { err("Design rules (the tool's DRC)", d) } else {+ // each tool counts in its own words: Altium errors/unconnected, Fusion violationCount/airwires+ let errs = d["errors"].as_i64().or(d["violationCount"].as_i64());+ let open = d["unconnected"].as_i64().or(d["airwires"].as_i64()).or(d["unrouted"].as_i64());+ let shown = |v: Option<i64>| v.map(|n| n.to_string()).unwrap_or_else(|| "?".into());+ Section { title: "Design rules (the tool's DRC)".into(), ok: match (errs, open) { (Some(e), Some(u)) => Some(e == 0 && u == 0), (Some(e), None) if e > 0 => Some(false), _ => None },+ summary: format!("{} violations, {} unconnected", shown(errs), shown(open)),+ rows: d["violations"].as_array().cloned().unwrap_or_default().iter().take(30).map(|v| (txt(&v["rule"]), txt(&v["objects"]))).collect() } });+ let f = &c["fab"];+ // the vendor as each finding's source names it, else the record's own name+ let vendor = f["outsideLimits"][0]["source"]["name"].as_str().or(f["fab"]["vendor"].as_str()).or(f["fab"]["name"].as_str()).unwrap_or("").to_string();+ let fab_title = format!("Fab limits ({})", vendor.chars().take(40).collect::<String>());+ let fab_title = if fab_title == "Fab limits ()" { "Fab limits".to_string() } else { fab_title };+ out.push(if failed(f) { err(&fab_title, f) } else {+ let mut rows: Vec<(String, String)> = f["outsideLimits"].as_array().cloned().unwrap_or_default().iter().map(|o| (txt(&o["limit"]), format!("board {} mm, limit {} mm", txt(&o["boardMm"]), txt(&o["limitMm"])))).collect();+ for k in ["track", "viaDrill", "padHole", "edgeClearance", "silkText", "boardSize"] { if let Some(m) = f["measured"].get(k) { rows.push((format!("measured {k}"), format!("{} mm", txt(&m["mm"])))); } }+ Section { title: fab_title.clone(), ok: f["fits"].as_bool(), summary: format!("{} outside the fab's limits", f["outsideLimits"].as_array().map(|a| a.len()).unwrap_or(0)), rows } });+ let a = &c["assembly"];+ out.push(if failed(a) { err("JLCPCB assembly", a) } else {+ let rows = ["economic", "standard"].iter().flat_map(|t| a[*t]["rules"].as_array().cloned().unwrap_or_default().into_iter().filter(|r| r["ok"] == json!(false)).map(move |r| (format!("{t}: {}", txt(&r["rule"])), txt(&r["detail"])))).collect();+ Section { title: "JLCPCB assembly".into(), ok: Some(!a["service"].is_null()), summary: format!("tier: {}", if a["service"].is_null() { "none passes yet".to_string() } else { txt(&a["service"]) }), rows } });+ let s = &c["schematic"];+ out.push(if failed(s) { err("Schematic review", s) } else {+ let rows = s["errors"].as_array().cloned().unwrap_or_default().iter().chain(s["warnings"].as_array().cloned().unwrap_or_default().iter()).take(30)+ .map(|w| (txt(&w["check"]), [w.get("pin"), w.get("net"), w.get("ics")].iter().flatten().map(|v| txt(v)).collect::<Vec<_>>().join(" "))).collect();+ Section { title: "Schematic review".into(), ok: s["ok"].as_bool(), summary: format!("{} errors, {} warnings, {} pins on {} nets", s["errors"].as_array().map(|a| a.len()).unwrap_or(0), s["warnings"].as_array().map(|a| a.len()).unwrap_or(0), txt(&s["pins"]), txt(&s["nets"])), rows } });+ let p = &c["pinPad"];+ out.push(if failed(p) { err("Symbols against footprints", p) } else {+ Section { title: "Symbols against footprints".into(), ok: p["ok"].as_bool(), summary: format!("{} of {} parts match", txt(&p["matching"]), txt(&p["partsChecked"])),+ rows: p["mismatches"].as_array().cloned().unwrap_or_default().iter().map(|m| (txt(&m["ref"]), format!("pins without pad {} / pads without pin {}", txt(&m["pinsWithoutPad"]), txt(&m["padsWithoutPin"])))).collect() } });+ let i = &c["impedance"];+ out.push(if failed(i) { err("Controlled impedance", i) } else {+ Section { title: "Controlled impedance (50 ohm, 90 ohm pairs)".into(), ok: None, summary: "track widths from the stack-up".into(),+ rows: i["layers"].as_array().cloned().unwrap_or_default().iter().map(|l| (txt(&l["layer"]), format!("{}: 50 ohm {} mm; 90 ohm pair {} / {} mm", txt(&l["model"]), txt(&l["width"]), txt(&l["diffWidth"]), txt(&l["diffGap"])))).collect() } });+ let r = &c["returnPath"];+ out.push(if failed(r) { err("Return paths", r) } else {+ Section { title: "Return paths".into(), ok: r["ok"].as_bool(), summary: format!("{} net-layer runs over a gap in the reference copper", r["nets"].as_array().map(|a| a.len()).unwrap_or(0)),+ rows: r["nets"].as_array().cloned().unwrap_or_default().iter().take(20).map(|n| (format!("{} on {}", txt(&n["net"]), txt(&n["layer"])), format!("{} mm of {} mm ({}%)", txt(&n["unreferencedMm"]), txt(&n["lengthMm"]), txt(&n["percent"])))).collect() } });+ let b = &c["balance"];+ out.push(if failed(b) { err("Copper balance", b) } else {+ Section { title: "Copper balance".into(), ok: b["balanced"].as_bool(), summary: "copper share per layer; mirrored pairs within 20 points".into(),+ rows: b["layers"].as_array().cloned().unwrap_or_default().iter().map(|l| (txt(&l["layer"]), format!("{}% ({} mm2)", txt(&l["percent"]), txt(&l["copperMm2"])))).collect() } });+ let h = &c["heights"];+ out.push(if failed(h) { err("3D heights and clearance", h) } else {+ let mut rows: Vec<(String, String)> = h["heights"].as_array().cloned().unwrap_or_default().iter().take(8).map(|x| (txt(&x["ref"]), format!("{} mm, {}", txt(&x["heightMm"]), txt(&x["side"])))).collect();+ for col in h["collisions"].as_array().cloned().unwrap_or_default() { rows.push(("collision".into(), format!("{} by {} mm", txt(&col["parts"]), txt(&col["overlapMm"])))); }+ Section { title: "3D heights and clearance".into(), ok: Some(h["collisions"].as_array().map(|a| a.is_empty()).unwrap_or(true)), summary: format!("{} bodies; {} parts without a 3D body", txt(&h["bodies"]), h["noBody"].as_array().map(|a| a.len()).unwrap_or(0)), rows } });+ let v = &c["review"];+ out.push(if failed(v) { err("Design review", v) } else {+ Section { title: "Design review".into(), ok: Some(v["counts"]["error"].as_i64() == Some(0)), summary: format!("{} errors, {} warnings, {} notes", txt(&v["counts"]["error"]), txt(&v["counts"]["warning"]), txt(&v["counts"]["info"])),+ rows: v["findings"].as_array().cloned().unwrap_or_default().iter().take(30).map(|x| (format!("{} ({})", txt(&x["check"]), txt(&x["severity"])), txt(&x["message"]))).collect() } });+ out+}++/// The page. Dark by default with a light scheme, tokens on :root. Pure.+pub fn page(board: &str, when: &str, version: &str, shot: Option<&str>, secs: &[Section]) -> String {+ let badge = |ok: Option<bool>| match ok { Some(true) => "<span class=\"b ok\">pass</span>", Some(false) => "<span class=\"b no\">needs work</span>", None => "<span class=\"b info\">info</span>" };+ let mut body = String::new();+ for s in secs {+ body.push_str(&format!("<section><h2>{} {}</h2><p class=\"sum\">{}</p>", esc(&s.title), badge(s.ok), esc(&s.summary)));+ if !s.rows.is_empty() {+ body.push_str("<table>");+ for (k, v) in &s.rows { body.push_str(&format!("<tr><th>{}</th><td>{}</td></tr>", esc(k), esc(v))); }+ body.push_str("</table>");+ }+ body.push_str("</section>");+ }+ let pass = secs.iter().filter(|s| s.ok == Some(true)).count();+ let work = secs.iter().filter(|s| s.ok == Some(false)).count();+ let img = shot.map(|b| format!("<img alt=\"the board\" src=\"data:image/png;base64,{b}\">")).unwrap_or_default();+ format!(r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<meta name="color-scheme" content="dark"><title>Board report</title><style>+:root{{--bg:#101418;--panel:#182028;--ink:#e6edf3;--dim:#9aa7b4;--line:#2a3542;--ok:#3fb950;--no:#f0883e;--info:#58a6ff}}+body{{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif}}+main{{max-width:960px;margin:0 auto;padding:24px 16px}} h1{{font-size:24px;margin:0 0 4px}} .meta{{color:var(--dim);margin:0 0 16px}}+img{{width:100%;border:1px solid var(--line);border-radius:8px;margin:8px 0 16px}}+section{{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin:12px 0}}+h2{{font-size:17px;margin:0;display:flex;gap:8px;align-items:center}} .sum{{color:var(--dim);margin:4px 0 8px}}+.b{{font-size:12px;border-radius:999px;padding:1px 8px;border:1px solid}} .ok{{color:var(--ok)}} .no{{color:var(--no)}} .info{{color:var(--info)}}+table{{width:100%;border-collapse:collapse;font-size:13px}} th,td{{text-align:left;padding:4px 6px;border-top:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}} th{{width:34%;color:var(--dim);font-weight:500}}+</style></head><body><main><h1>{}</h1><p class="meta">{} · {} pass, {} need work · {}</p>{}{}</main></body></html>"#,+ esc(board), esc(when), pass, work, esc(version), img, body)+}++/// board_report: input {board, stack?, pours?, rules?, bodies?, fab?, sheetParts?, netlist?, drc?, picture?, args: {title?,+/// date?, lcscMissing?, panelMm?, gridMm?, alwaysChecked?}}; the last four go to assembly_check and review_board. Every check the engine has, run on this bundle, as one self-contained HTML page (`text`), with the tool's+/// own DRC reply (`drc`) and a base64 PNG of the board (`picture`) when the bridge passes them. A section whose input+/// is missing says so instead of passing.+pub fn board_report(input: &Value) -> Value {+ if let Err(e) = crate::input::board(input) { return e; }+ let with = |args: Value| { let mut i = input.clone(); i["args"] = args; i };+ let need = |k: &str, what: &str| crate::input::part(input, k).is_none().then(|| json!({"success": false, "error": format!("pass {what} as {k}")}));+ let mut c = json!({});+ c["schematic"] = need("sheetParts", "the sheets' parts").or_else(|| need("netlist", "the compiled netlist")).unwrap_or_else(|| crate::run("schematic_review", &with(json!({}))));+ c["pinPad"] = need("sheetParts", "the sheets' parts").unwrap_or_else(|| crate::run("pin_pad_check", &with(json!({}))));+ c["fab"] = need("fab", "a pcb-design-rules record").unwrap_or_else(|| crate::run("fab_check", &with(json!({}))));+ // the bridge's BOM lines without an LCSC number, when it passes them (fusion-lane: without, the tier read economic)+ c["assembly"] = crate::run("assembly_check", &with(json!({"lcscMissing": input["args"]["lcscMissing"], "panelMm": input["args"]["panelMm"]})));+ c["impedance"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("impedance", &with(json!({"z0": 50, "diff": 90}))));+ c["returnPath"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("return_path", &with(json!({}))));+ c["balance"] = need("stack", "the stack-up").unwrap_or_else(|| crate::run("copper_balance", &with(json!({}))));+ c["heights"] = need("bodies", "the 3D bodies").unwrap_or_else(|| crate::run("height_check", &with(json!({}))));+ c["review"] = crate::run("review_board", &with(json!({"gridMm": input["args"]["gridMm"], "alwaysChecked": input["args"]["alwaysChecked"]})));+ c["drc"] = crate::input::part(input, "drc").unwrap_or(Value::Null);+ let secs = sections(&c);+ let args = &input["args"];+ let title = args["title"].as_str().unwrap_or("board");+ let date = args["date"].as_str().map(String::from).unwrap_or_else(|| crate::writers::ipc2581::now().replace('T', " ").replace('Z', " UTC"));+ let picture = input["picture"].as_str();+ let text = page(title, &date, &format!("adom-eda-checks {}", crate::VERSION), picture, &secs);+ json!({"success": true, "text": text, "sections": secs.iter().map(|s| json!({"title": s.title, "ok": s.ok, "summary": s.summary})).collect::<Vec<_>>(),+ "pass": secs.iter().filter(|s| s.ok == Some(true)).count(), "needsWork": secs.iter().filter(|s| s.ok == Some(false)).count(), "picture": picture.is_some(),+ "_hint": "text is a single self-contained HTML page: the bridge writes it beside the project and opens or sends it. Each section's verdict comes from the engine check named in it: run that check for the full reply. A section marked 'not run' names the companion to pass."})+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn sections_read_every_check_and_the_page_escapes() {+ let c = json!({"drc": {"success": true, "errors": 0, "unconnected": 0, "violations": []},+ "fab": {"success": true, "fits": false, "fab": {"vendor": "JLCPCB"}, "outsideLimits": [{"limit": "minTrack", "boardMm": 0.08, "limitMm": 0.1}], "measured": {"track": {"mm": 0.08}}},+ "assembly": {"success": false, "error": "no outline"},+ "schematic": {"success": true, "ok": true, "errors": [], "warnings": [{"check": "single_pin_net", "net": "<X>"}], "pins": 10, "nets": 4}});+ let s = sections(&c);+ assert_eq!(s[0].ok, Some(true));+ assert_eq!((s[1].title.as_str(), s[1].ok), ("Fab limits (JLCPCB)", Some(false)));+ assert!(s[2].summary.starts_with("not run"));+ let html = page("lane", "2026-10-26", "adom-eda-checks", None, &s);+ assert!(html.contains("<X>") && !html.contains("<X>"));+ assert_eq!(txt(&json!(["Pad U2-5", "Pad U3-4"])), "Pad U2-5, Pad U3-4");+ }++ #[test]+ fn the_report_runs_the_engines_checks_and_names_what_is_missing() {+ let st = json!({"outline": [[0, 0], [40, 0], [40, 20], [0, 20]], "components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top", "pattern": "0603"}],+ "pads": [{"ref": "R1", "name": "1", "x": 9.2, "y": 10.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "A", "shape": "rect"},+ {"ref": "R1", "name": "2", "x": 10.8, "y": 10.0, "sizeX": 0.8, "sizeY": 0.9, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": "B", "shape": "rect"}]});+ let r = board_report(&json!({"board": st, "args": {"title": "Test", "date": "2026-10-29 12:00 UTC"}}));+ assert_eq!(r["success"], json!(true), "{r}");+ let text = r["text"].as_str().unwrap();+ assert!(text.starts_with("<!doctype html>") && text.contains("<h1>Test</h1>"));+ let sec = |t: &str| r["sections"].as_array().unwrap().iter().find(|s| s["title"].as_str().unwrap_or("").starts_with(t)).cloned().unwrap();+ assert!(sec("Design rules")["summary"].as_str().unwrap().contains("pass the tool's DRC"));+ assert!(sec("Fab limits")["summary"].as_str().unwrap().contains("not run"));+ assert_eq!(sec("Design review")["ok"], json!(true), "{r}");+ assert!(sec("JLCPCB assembly")["summary"].as_str().unwrap().starts_with("tier"));+ // Fusion's DRC counts in its own words; the bridge's LCSC gaps reach the assembly section+ let f = board_report(&json!({"board": st, "drc": {"violationCount": 3, "airwires": 0}, "args": {"lcscMissing": 2}}));+ let sec = |t: &str| f["sections"].as_array().unwrap().iter().find(|s| s["title"].as_str().unwrap_or("").starts_with(t)).cloned().unwrap();+ assert_eq!((sec("Design rules")["summary"].clone(), sec("Design rules")["ok"].clone()), (json!("3 violations, 0 unconnected"), json!(false)));+ assert_eq!(sec("JLCPCB assembly")["ok"], json!(false), "{f}");+ }+}
rust/vendor/adom-eda-checks/src/review.rsadded+247@@ -0,0 +1,247 @@+//! Design review: small checks, each a pure function over the board model, the sheet parts and the board's rules. From+//! the Altium bridge's review.rs, on the standard model: components off the board are judged against the outline+//! polygon (Altium's read carried only a bounding box), and rules are classed from a standard kind, a descriptor's+//! leading words or Altium's rule ordinal. What the model cannot answer is named in `notImplemented`.+use serde_json::{json, Map, Value};+use std::collections::{BTreeMap, BTreeSet};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn s<'a>(v: &'a Value, k: &str) -> &'a str { v.get(k).and_then(|x| x.as_str()).unwrap_or("") }++const PLACEHOLDER_VALUES: [&str; 12] = ["comment", "value", "*", "?", "n/a", "na", "tbd", "xxx", "component", "designator", "part", "unknown"];++/// Adom house rule: Mouser is the preferred vendor, and every purchased part carries Mouser, DigiKey and LCSC numbers.+const VENDOR_PATTERNS: [(&str, &[&str]); 3] = [("Mouser", &["mouser"]), ("DigiKey", &["digikey", "digi-key", "digi key"]), ("LCSC", &["lcsc", "jlcpcb"])];++/// Designator prefixes that carry no vendor part number.+const NON_PURCHASED: [&str; 8] = ["TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND"];++/// The rules a fab relies on: clearance, width, hole size, short circuit.+const CRITICAL: [&str; 4] = ["clearance", "width", "hole size", "short circuit"];++pub const NOT_IMPLEMENTED: [(&str, &str); 4] = [+ ("unused_mechanical_layers", "the board model carries no mechanical layers"),+ ("unrouted_connections", "needs the ratsnest: routing_drc or the tool's DRC"),+ ("silkscreen_over_pad", "silk_designators reports labels on pads (onPadsBefore)"),+ ("missing_decoupling", "schematic_review checks each IC supply for a capacitor to ground"),+];++fn finding(check: &str, severity: &str, key: (&str, &str), message: String) -> Value {+ let mut v = json!({"check": check, "severity": severity, "message": message});+ if !key.1.is_empty() { v[key.0] = json!(key.1); }+ v+}++fn purchased(designator: &str) -> bool {+ let prefix: String = designator.to_uppercase().chars().take_while(|c| c.is_ascii_alphabetic()).collect();+ !NON_PURCHASED.contains(&prefix.as_str())+}++fn params_of(c: &Value) -> Vec<(String, String)> {+ c["params"].as_array().map(|a| a.iter().filter_map(|p| {+ let name = p["name"].as_str()?.trim().to_lowercase();+ (!name.is_empty()).then(|| (name, p["value"].as_str().unwrap_or("").trim().to_string()))+ }).collect()).unwrap_or_default()+}++/// A rule's class: a standard kind, the descriptor's leading words, or Altium's rule ordinal. Pure.+pub fn rule_class(r: &Value) -> String {+ if let Some(k) = r["kind"].as_str() {+ let k = k.to_lowercase();+ for c in CRITICAL { if k.replace(['_', '-'], " ").contains(c) || (c == "hole size" && k == "hole") || (c == "short circuit" && k == "short") { return c.into(); } }+ return k;+ }+ let d = s(r, "descriptor").to_lowercase().replace('-', " ");+ for (prefix, c) in [("clearance constraint", "clearance"), ("width constraint", "width"), ("hole size constraint", "hole size"), ("short circuit constraint", "short circuit")] {+ if d.starts_with(prefix) { return c.into(); }+ }+ match r["kind"].as_i64() {+ Some(0) => return "clearance".into(), Some(2) => return "width".into(), Some(42) => return "hole size".into(), Some(15) => return "short circuit".into(),+ // any other Altium ordinal is no critical rule: its descriptor names it ("Routing Topology Rule (Shortest)" is+ // no short-circuit rule, "Polygon Connect (Conductor Width ...)" no width rule)+ Some(_) => return d.split('(').next().unwrap_or("").trim().to_string(),+ None => {}+ }+ // a tool's own rule names (Fusion's EAGLE rules: wire-wire, pad-via, minimum width, minimum drill, ...)+ let t = format!("{} {} {}", s(r, "kindName"), s(r, "name"), d).to_lowercase().replace(['_', '-'], " ");+ if t.contains("short circuit") || t.contains("shortcircuit") { return "short circuit".into(); }+ if (t.contains("width") || t.contains("minimum wire")) && !t.contains("annular") && !t.contains("conductor") { return "width".into(); }+ if t.contains("drill") || t.contains("hole size") || t.contains("holesize") { return "hole size".into(); }+ let copper = !["silk", "mask", "paste", "edge", "board", "courtyard", "component", "hole to hole", "plane"].iter().any(|w| t.contains(w));+ if copper && (t.contains("clearance") || t.contains("wire wire") || t.contains("pad via") || t.contains("pad pad")) { return "clearance".into(); }+ d.split('(').next().unwrap_or("").trim().to_string()+}++/// The checks over the board model, the sheet parts (read_sheet_file's components, or none) and the rules. Returns the+/// findings (errors first), the checks run and those skipped with why. Pure.+pub fn run_checks(st: &Value, sheet: Option<&[Value]>, rules: &[Value], grid: f64, edge: f64, wanted: Option<&[String]>, always: &[String]) -> (Vec<Value>, Vec<String>, Map<String, Value>) {+ let comps = st["components"].as_array().cloned().unwrap_or_default();+ let pads = st["pads"].as_array().cloned().unwrap_or_default();+ let ring = crate::coppermap::outline_ring(st);+ let want = |n: &str| wanted.map(|w| w.iter().any(|x| x == n)).unwrap_or(true);+ let (mut out, mut ran, mut skipped) = (Vec::new(), Vec::new(), Map::new());+ let mut run = |name: &str, needs: Option<&str>, out: &mut Vec<Value>, f: &dyn Fn() -> Vec<Value>| {+ if !want(name) { return; }+ if let Some(why) = needs { skipped.insert(name.into(), json!(why)); return; }+ ran.push(name.to_string());+ out.extend(f());+ };+ let placed: Vec<&Value> = comps.iter().filter(|c| pads.iter().any(|p| p["ref"] == c["ref"])).collect();+ let own = |r: &str| -> Vec<(f64, f64)> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| (f(&p["x"]), f(&p["y"]))).collect() };+ run("placeholder_values", None, &mut out, &|| placed.iter().filter_map(|c| {+ let v = c.get("value").or(c.get("comment")).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();+ PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("placeholder_values", "error", ("designator", s(c, "ref")), format!("{} has the placeholder value {v:?}: it will reach the BOM as is.", s(c, "ref"))))+ }).collect());+ run("single_pin_nets", None, &mut out, &|| {+ let mut n: BTreeMap<&str, usize> = BTreeMap::new();+ for p in &pads { if let Some(net) = p["net"].as_str().filter(|x| !x.is_empty()) { *n.entry(net).or_default() += 1; } }+ n.into_iter().filter(|(_, k)| *k == 1).map(|(net, _)| finding("single_pin_nets", "warning", ("net", net), format!("Net {net:?} has a single pad: it connects to nothing."))).collect()+ });+ run("off_grid_components", if grid > 0.0 { None } else { Some("no placement grid: pass gridMm (a 5 mil grid is 0.127)") }, &mut out, &|| placed.iter().filter_map(|c| {+ let off = |v: f64| { let k = v / grid; (k - k.round()).abs() * grid > 0.001 };+ (off(f(&c["x"])) || off(f(&c["y"]))).then(|| finding("off_grid_components", "info", ("designator", s(c, "ref")), format!("{} sits at ({}, {}) mm, off the {grid} mm grid.", s(c, "ref"), f(&c["x"]), f(&c["y"]))))+ }).collect());+ run("duplicate_designators", None, &mut out, &|| {+ let mut v = Vec::new();+ let mut count = |label: &str, names: Vec<String>| {+ let mut seen: BTreeMap<String, u32> = BTreeMap::new();+ for d in names.into_iter().filter(|d| !d.is_empty()) { *seen.entry(d).or_default() += 1; }+ for (d, n) in seen { if n > 1 { v.push(finding("duplicate_designators", "error", ("designator", &d), format!("Designator {d} occurs {n} times on the {label}."))); } }+ };+ count("board", comps.iter().map(|c| s(c, "ref").to_string()).collect());+ // a multi-part chip appears once per part on the sheets: count each designator once per part id+ if let Some(sh) = sheet {+ let mut uniq: BTreeSet<(String, String)> = BTreeSet::new();+ let names: Vec<String> = sh.iter().filter(|c| uniq.insert((s(c, "designator").to_string(), format!("{}{}", s(c, "uniqueId"), c["partId"])))).map(|c| s(c, "designator").to_string()).collect();+ let multi: BTreeSet<String> = sh.iter().filter(|c| c["partCount"].as_i64().unwrap_or(1) > 1).map(|c| s(c, "designator").to_string()).collect();+ count("sheets", names.into_iter().filter(|d| !multi.contains(d)).collect());+ }+ v+ });+ run("components_outside_board_outline", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {+ let ps = own(s(c, "ref"));+ let outside = ps.iter().filter(|p| !crate::route::inside(**p, &ring)).count();+ (outside > 0).then(|| finding("components_outside_board_outline", "error", ("designator", s(c, "ref")),+ format!("{} has {outside} of {} pad(s) outside the board outline.", s(c, "ref"), ps.len())))+ }).collect());+ run("components_near_board_edge", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {+ let ps = own(s(c, "ref"));+ if ps.iter().any(|p| !crate::route::inside(*p, &ring)) { return None; }+ let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()+ };+ let d = ps.iter().map(|p| (0..ring.len()).map(|i| seg(*p, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)).fold(f64::MAX, f64::min);+ (d < edge).then(|| finding("components_near_board_edge", "warning", ("designator", s(c, "ref")), format!("{} has a pad centre {:.3} mm from the board edge (under {edge} mm).", s(c, "ref"), d)))+ }).collect());+ let no_sheet = sheet.is_none().then_some("no sheet parts: pass sheetParts");+ let sheet_parts: Vec<Value> = sheet.map(|x| x.to_vec()).unwrap_or_default();+ run("vendor_part_numbers", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {+ let blob = params_of(c).iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(" ").to_lowercase();+ let missing: Vec<&str> = VENDOR_PATTERNS.iter().filter(|(_, keys)| !keys.iter().any(|k| blob.contains(k))).map(|(v, _)| *v).collect();+ if missing.is_empty() { return None; }+ let mut x = finding("vendor_part_numbers", "warning", ("designator", s(c, "designator")), format!("{} has no {} part number (house rule: Mouser preferred, all three required).", s(c, "designator"), missing.join(" / ")));+ x["missing"] = json!(missing);+ Some(x)+ }).collect());+ run("missing_datasheets", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {+ let has = params_of(c).iter().any(|(k, _)| k.contains("datasheet") || k == "helpurl");+ (!has).then(|| finding("missing_datasheets", "info", ("designator", s(c, "designator")), format!("{} has no Datasheet parameter.", s(c, "designator"))))+ }).collect());+ run("schematic_placeholder_values", no_sheet, &mut out, &|| sheet_parts.iter().filter_map(|c| {+ let v = s(c, "comment").trim().to_string();+ PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("schematic_placeholder_values", "error", ("designator", s(c, "designator")), format!("{} on the sheet has the placeholder Comment {v:?}.", s(c, "designator"))))+ }).collect());+ let no_rules = rules.is_empty().then_some("no rules: pass read_rules as rules");+ run("disabled_design_rules", no_rules, &mut out, &|| rules.iter().filter(|r| r["enabled"] == json!(false)).map(|r| {+ let c = rule_class(r);+ let sev = if CRITICAL.contains(&c.as_str()) { "error" } else { "warning" };+ finding("disabled_design_rules", sev, ("rule", s(r, "name")), format!("Design rule {:?} ({c}) is disabled: DRC passes without ever applying it.", s(r, "name")))+ }).collect());+ run("missing_critical_rules", no_rules, &mut out, &|| {+ // a tool that always checks a class (EAGLE's DRC always checks shorts) passes it in alwaysChecked+ let mut have: BTreeSet<String> = rules.iter().filter(|r| r["enabled"] != json!(false)).map(rule_class).collect();+ have.extend(always.iter().map(|a| a.to_lowercase().replace(['_', '-'], " ")));+ CRITICAL.iter().filter(|c| !have.contains(**c)).map(|c| finding("missing_critical_rules", "error", ("rule", c), format!("No enabled {c} rule on this board."))).collect()+ });+ run("duplicate_rule_priority", no_rules, &mut out, &|| {+ let mut seen: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();+ for r in rules.iter().filter(|r| r["enabled"] != json!(false) && !r["priority"].is_null()) { seen.entry((rule_class(r), r["priority"].to_string())).or_default().push(s(r, "name").to_string()); }+ seen.into_iter().filter(|(_, n)| n.len() > 1).map(|((c, p), mut n)| { n.sort(); finding("duplicate_rule_priority", "info", ("rule", &n.join(", ")), format!("{} enabled {c} rules share priority {p} ({}): which one wins is not readable from the board.", n.len(), n.join(", "))) }).collect()+ });+ run("components_without_footprint", None, &mut out, &|| placed.iter().filter(|c| s(c, "pattern").trim().is_empty())+ .map(|c| finding("components_without_footprint", "warning", ("designator", s(c, "ref")), format!("{} has no footprint pattern name: it cannot be matched back to a library.", s(c, "ref")))).collect());+ let order = |x: &Value| match s(x, "severity") { "error" => 0, "warning" => 1, _ => 2 };+ out.sort_by(|a, b| order(a).cmp(&order(b)).then_with(|| s(a, "check").cmp(s(b, "check"))));+ (out, ran, skipped)+}++/// review_board: input {board, sheetParts?, rules?, args: {checks?, gridMm?, edgeMm?, alwaysChecked?: ["short circuit", ...]}}.+pub fn review_board(input: &Value) -> Value {+ let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };+ let args = &input["args"];+ let sheet: Option<Vec<Value>> = crate::input::part(input, "sheetParts").and_then(|p| p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned());+ let wanted: Option<Vec<String>> = args["checks"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect());+ let always: Vec<String> = args["alwaysChecked"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();+ let (findings, ran, skipped) = run_checks(&st, sheet.as_deref(), &crate::input::rules(input), args["gridMm"].as_f64().unwrap_or(0.0), args["edgeMm"].as_f64().unwrap_or(0.5), wanted.as_deref(), &always);+ let mut counts = json!({"error": 0, "warning": 0, "info": 0});+ for x in &findings { let k = s(x, "severity").to_string(); counts[&k] = json!(counts[&k].as_i64().unwrap_or(0) + 1); }+ json!({"success": true, "findings": findings, "counts": counts, "checks": ran, "skipped": skipped,+ "notImplemented": NOT_IMPLEMENTED.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>(),+ "_hint": "Findings are errors first. skipped names the checks a missing companion stopped (pass sheetParts and rules for all of them); notImplemented names what this review does not judge, so silence there is not a clean board."})+}++#[cfg(test)]+mod tests {+ use super::*;++ fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value { json!({"ref": r, "name": n, "x": x, "y": y, "net": net}) }++ #[test]+ fn each_check_finds_its_problem() {+ let st = json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "components": [{"ref": "U1", "x": 5.0, "y": 5.0, "pattern": "SOIC8", "value": "TBD"}, {"ref": "R1", "x": 19.8, "y": 5.0, "pattern": ""}, {"ref": "R1", "x": 10.0, "y": 5.0, "pattern": "0603"}, {"ref": "J1", "x": 25.0, "y": 5.0, "pattern": "HDR"}],+ "pads": [pad("U1", "1", 4.0, 5.0, "A"), pad("U1", "2", 6.0, 5.0, "LONE"), pad("R1", "1", 19.8, 5.0, "A"), pad("J1", "1", 25.0, 5.0, "")]});+ let sheet = vec![json!({"designator": "U1", "comment": "LM358", "params": [{"name": "Mouser Part Number", "value": "595-LM358"}]}), json!({"designator": "TP1", "comment": "Comment"})];+ let rules = vec![json!({"name": "Clearance", "kind": 0, "enabled": true, "priority": 1, "descriptor": "Clearance Constraint (Gap=0.1mm)"}),+ json!({"name": "Short", "kind": 15, "enabled": false, "priority": 1, "descriptor": "Short-Circuit Constraint (Allowed=No)"}),+ json!({"name": "W1", "kind": "width", "enabled": true, "priority": 2}), json!({"name": "W2", "kind": "width", "enabled": true, "priority": 2})];+ let (f, ran, _) = run_checks(&st, Some(&sheet), &rules, 0.127, 0.5, None, &[]);+ let has = |c: &str, key: &str, v: &str| f.iter().any(|x| x["check"] == c && x[key] == v);+ assert!(has("placeholder_values", "designator", "U1"));+ assert!(has("single_pin_nets", "net", "LONE"));+ assert!(has("duplicate_designators", "designator", "R1"));+ assert!(has("components_outside_board_outline", "designator", "J1"));+ assert!(has("components_near_board_edge", "designator", "R1"));+ assert!(has("components_without_footprint", "designator", "R1"));+ assert!(f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "U1" && x["missing"] == json!(["DigiKey", "LCSC"])));+ assert!(!f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "TP1"), "a test point is not purchased");+ assert!(has("schematic_placeholder_values", "designator", "TP1"));+ assert!(f.iter().any(|x| x["check"] == "disabled_design_rules" && x["severity"] == "error"));+ assert!(has("missing_critical_rules", "rule", "hole size") && has("missing_critical_rules", "rule", "short circuit"));+ assert!(f.iter().any(|x| x["check"] == "duplicate_rule_priority"));+ assert_eq!(f[0]["severity"], "error");+ assert_eq!(ran.len(), 13);+ let (_, _, skipped) = run_checks(&st, None, &[], 0.127, 0.5, None, &[]);+ assert!(skipped.contains_key("vendor_part_numbers") && skipped.contains_key("missing_critical_rules"));+ // EAGLE always checks shorts: passed in alwaysChecked, a missing short-circuit rule is no finding+ let (f2, _, _) = run_checks(&st, Some(&sheet), &rules, 0.0, 0.5, Some(&["missing_critical_rules".to_string()]), &["short_circuit".to_string()]);+ assert!(!f2.iter().any(|x| x["rule"] == "short circuit"), "{f2:?}");+ }++ #[test]+ fn rules_are_classed_three_ways() {+ assert_eq!(rule_class(&json!({"kind": 42})), "hole size");+ assert_eq!(rule_class(&json!({"kind": 99, "descriptor": "Width Constraint (Min=0.1mm)"})), "width");+ assert_eq!(rule_class(&json!({"kind": "short_circuit"})), "short circuit");+ // a tool's own names (Fusion's EAGLE rules), and a silk clearance is no copper clearance+ assert_eq!(rule_class(&json!({"name": "Wire-Wire", "value": "0.1mm"})), "clearance");+ assert_eq!(rule_class(&json!({"name": "Minimum Drill"})), "hole size");+ assert_eq!(rule_class(&json!({"name": "Silk Clearance"})), "");+ // Altium's other ordinals stay what their descriptor says, whatever words they hold+ assert_eq!(rule_class(&json!({"kind": 7, "name": "RoutingTopology", "descriptor": "Routing Topology Rule(Topology=Shortest) (All)"})), "routing topology rule");+ assert_eq!(rule_class(&json!({"kind": 6, "name": "PlaneConnect", "descriptor": "Power Plane Connect Rule(Relief Connect )(Conductor Width=0.254mm)"})), "power plane connect rule");+ }+}
rust/vendor/adom-eda-checks/src/schreview.rs+14@@ -48,6 +48,9 @@ pub fn review(parts: &[Value], nets: &Map<String, Value>) -> Value { for c in parts { let des = c["designator"].as_str().unwrap_or("").to_string(); if des.is_empty() || des.contains('?') { continue; }+ // a supply symbol with no package (Fusion's SUPPLY1..) joins its net by pin name and has no pin numbers: it is+ // no part on the board (fusion-lane)+ if c["pins"].as_array().map(|a| !a.is_empty() && a.iter().all(|p| p["number"].as_str().map(|n| n.trim().is_empty()).unwrap_or(true))).unwrap_or(false) { continue; } for p in c["pins"].as_array().cloned().unwrap_or_default() { let num = p["number"].as_str().unwrap_or("").to_string(); pins.entry(format!("{des}.{num}").to_ascii_uppercase()).or_insert((des.clone(), num, p["name"].as_str().unwrap_or("").to_string(), pin_type(&p["electrical"])));@@ -109,6 +112,9 @@ pub fn pin_pad(parts: &[Value], pads: &[Value], board_refs: &[String]) -> Value for c in parts { let des = c["designator"].as_str().unwrap_or("").to_string(); if des.is_empty() || des.contains('?') { continue; }+ // a supply symbol with no package (Fusion's SUPPLY1..) joins its net by pin name and has no pin numbers: it is+ // no part on the board (fusion-lane)+ if c["pins"].as_array().map(|a| !a.is_empty() && a.iter().all(|p| p["number"].as_str().map(|n| n.trim().is_empty()).unwrap_or(true))).unwrap_or(false) { continue; } let e = pins.entry(des).or_default(); for p in c["pins"].as_array().cloned().unwrap_or_default() { if let Some(n) = p["number"].as_str() { e.insert(n.to_ascii_uppercase()); } } }@@ -235,4 +241,12 @@ mod tests { let p = pin_pad_check(&json!({"board": b, "sheetParts": parts})); assert_eq!((p["matching"].clone(), p["ok"].clone()), (json!(2), json!(true)), "{p}"); }++ #[test]+ fn a_supply_symbol_without_pin_numbers_is_no_part() {+ let parts = vec![part("U1", &[("1", "VCC", 7)]), json!({"designator": "SUPPLY1", "pins": [{"number": "", "name": "VCC", "electrical": "supply"}]})];+ let r = review(&parts, json!({"VCC": ["U1.1"]}).as_object().unwrap());+ assert_eq!(r["pins"], json!(1), "{r}");+ assert!(r["errors"].as_array().unwrap().is_empty());+ } }
rust/vendor/adom-eda-checks/src/writers/ipc2581.rs+1−1@@ -321,7 +321,7 @@ pub fn ipc2581_text(st: &Value, input: &Value, title: &str, date: &str) -> (Stri } /// The current UTC time as an xsd:dateTime, for the history record when the caller gives no `date`.-fn now() -> String {+pub(crate) fn now() -> String { let s = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0); let (days, rem) = (s.div_euclid(86400), s.rem_euclid(86400)); // civil from days (Howard Hinnant)
skills/kicad-bridge-test/run_verb_tests.py+8−1@@ -197,7 +197,7 @@ def matrix(w: dict) -> dict: "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", "generate_body", "plan_route", "export_spice", "simulate",- "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review")},+ "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board")}, "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),@@ -1620,6 +1620,13 @@ def main(): rv2 = C("schematic_review", {"filePath": w["sch"]}, 200) own("schematic_review", {"rc": rv1, "rp2040": rv2}, ok(rv1) and rv1.get("ok") is True and ok(rv2) and rv2.get("ok") is False and any(e.get("check") == "unconnected_pin" and e.get("type") == "power" for e in rv2.get("errors") or []), "the RC was not clean, or the unwired sample's power pins were not caught")+ # 1.0.84: the standard's review_board (engine 0.17.2): Board Setup's rules and the sheet go in, no rule class reads as+ # missing on a KiCad board, and the rp2040 sample's 12 PIN** test points are a real duplicate designator+ rb = C("review_board", {"filePath": w["board"]}, 300)+ own("review_board", rb, ok(rb) and isinstance(rb.get("findings"), list) and rb.get("engine")+ and not any(x.get("check") == "missing_critical_rules" for x in rb.get("findings") or [])+ and any(x.get("check") == "duplicate_designators" and x.get("designator") == "PIN**" for x in rb.get("findings") or []),+ "no review, a rule class read as missing, or the duplicate PIN** designator was not found") bad = C("simulate", {"schPath": rc, "analysis": ".dc V9 0 1 0.1"}, 200) own("simulate", {"ac": ac, "bad": bad}, ok(ac) and sig.get("signal") == "V(OUT)" and sig.get("yUnit") == "dB" and str(ac.get("engine") or "").startswith("ngspice ") and f3 is not None and abs(f3 - 1591.5) / 1591.5 < 0.02 and ac.get("csvPath") and bad.get("errorCode") == "simulation_failed", f"the RC's -3 dB point was {f3} Hz, not about 1592, or a failing analysis did not fail")