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.
← All Pull Requests
kicad-lane: 1.0.84, review_board, engine 0.17.2 #45
Merged opened by Colby Knox 2026-10-29
1.0.84: a whole-board design review, and the standard's engine 0.17.2.
- kicad_review_board (new; the standard's review_board): one read of the board, and of the schematic beside it through KiCad's own netlist, reviewed by the standard's engine.
- Finds 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.
- Board Setup's clearance, track width and minimum through hole go in as the board's rules. KiCad's DRC always checks shorts, clearance, track width and hole size, so no rule class reads as missing on a KiCad board.
- skipped and notImplemented name what it could not judge (a placement grid needs gridMm; decoupling is kicad_schematic_review's job).
- Checked: the rp2040 sample's 12 PIN** test points come back as a duplicate designator, its 20 parts without vendor part numbers as warnings.
- kicad_schematic_review: each part now carries its value and comment too.
- Engine: adom-eda-checks 0.17.2 (EDA Bridge Standard 0.20.2).
Files: bridge.json, rust/Cargo.toml, rust/Cargo.lock, rust/crates/kicad-bridge/src/verbs_checks_native.rs, rust/crates/kicad-core/src/netlist.rs, rust/vendor/adom-eda-checks (0.17.2, vendored unchanged), skills/kicad-bridge-test/run_verb_tests.py.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,424 +1,426 @@⋯ 314 unchanged lines ⋯ "kicad_license_status", "kicad_run_script", "kicad_eval",- "kicad_schematic_review"+ "kicad_schematic_review",+ "kicad_review_board" ], "statusVerb": "kicad_status", "timeouts": {⋯ 100 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.83"+version = "1.0.84" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,1307 +1,1308 @@⋯ 9 unchanged lines ⋯ [[package]] name = "adom-eda-checks"-version = "0.16.0"+version = "0.17.2" dependencies = [ "roxmltree", "serde_json",⋯ 302 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.83"+version = "1.0.84" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.83"+version = "1.0.84" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.83"+version = "1.0.84" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,1751 +1,1759 @@⋯ 196 unchanged lines ⋯ "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),⋯ 1416 unchanged lines ⋯ 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")⋯ 123 unchanged lines ⋯ if __name__ == "__main__": sys.exit(main())+@@ -1,1167 +1,1234 @@⋯ 80 unchanged lines ⋯ 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.", mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,⋯ 86 unchanged lines ⋯ "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),⋯ 685 unchanged lines ⋯ 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");⋯ 295 unchanged lines ⋯ assert!(o["checked"].as_u64().unwrap() > 10, "{o}"); } }+@@ -1,860 +1,862 @@⋯ 310 unchanged lines ⋯ 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})) } ⋯ 540 unchanged lines ⋯ assert!(RAW_FORMATS.windows(2).all(|w| w[0] < w[1]), "RAW_FORMATS is sorted, matching Python's allowed list"); } }+@@ -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.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`, `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: ...`).+@@ -1,120 +1,127 @@⋯ 12 unchanged lines ⋯ |---|---|---| | `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) |⋯ 11 unchanged lines ⋯ | `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 |⋯ 39 unchanged lines ⋯ | `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`.⋯ 25 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.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 ⋯ 5 unchanged lines ⋯ ``` Fixtures live on each bridge's own page, not here.+@@ -1,25 +1,26 @@ [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"⋯ 16 unchanged lines ⋯ lto = true codegen-units = 1 strip = true+@@ -1,238 +1,253 @@⋯ 47 unchanged lines ⋯ 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"])));⋯ 55 unchanged lines ⋯ 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()); } } }⋯ 120 unchanged lines ⋯ 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());+ } }+@@ -1,51 +1,52 @@⋯ 22 unchanged lines ⋯ 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)?; }⋯ 19 unchanged lines ⋯ println!("{}", serde_json::to_string_pretty(&out).unwrap()); if out.get("success") == Some(&json!(false)) { std::process::exit(1); } }+@@ -1,85 +1,90 @@⋯ 20 unchanged lines ⋯ pub mod lengths; pub mod lengthtune; pub mod pairroute;+pub mod report;+pub mod review; pub mod placement; pub mod placetools; pub mod route;⋯ 7 unchanged lines ⋯ 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 {⋯ 7 unchanged lines ⋯ 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),⋯ 11 unchanged lines ⋯ "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),⋯ 12 unchanged lines ⋯ assert_eq!(super::run("no_such_check", &serde_json::json!({}))["engine"], r["engine"]); } }+@@ -1,398 +1,399 @@⋯ 320 unchanged lines ⋯ } /// 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)⋯ 68 unchanged lines ⋯ assert!(d.starts_with("20") && d.ends_with('Z') && &d[10..11] == "T", "{d}"); } }+@@ -0,0 +1,181 @@+//! 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}");+ }+}+@@ -0,0 +1,248 @@+//! 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");+ }+}+
Comments
Log in to comment.
No comments yet.