skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
← Commit history
standard 0.20.0: engine 0.17.0 adds review_board and board_report
13 files changed
+430−9
README.md+4@@ -158,6 +158,10 @@ Decisions so far (proposals from the KiCad and Fusion bridges, 2026-10-27; issue ## Status +Version 0.20.0: the engine (0.17.0) adds `review_board` and `board_report` from the Altium bridge, the last of the checks Fusion+asked for. `board_report` runs every engine check on one bundle into a single HTML page, and names the input a section+lacks. On the Bluetooth Sentinel the report builds with ten sections. A package-less supply symbol is no part in+`schematic_review` and `pin_pad_check` (fusion-lane). Version 0.19.0: the engine (0.16.0) adds `schematic_review` and `pin_pad_check` from the Altium bridge, reading the sheet parts and netlist companions. Version 0.18.0: the engine (0.15.0) adds `route_pair` and `length_tune` from the Altium bridge, as plans in route_net arguments.
engine/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-eda-checks"-version = "0.16.0"+version = "0.17.0" dependencies = [ "roxmltree", "serde_json",
engine/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-eda-checks"-version = "0.16.0"+version = "0.17.0" edition = "2021" description = "The EDA Bridge Standard's checks and board-file writers, computed from the board model JSON: no bridge calls, no tool." license = "MIT"
engine/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. 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. `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.0"`), so a bridge reports the engine it runs without writing the version by hand. ## Tests
engine/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),
engine/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)?; }
engine/src/report.rsadded+170@@ -0,0 +1,170 @@+//! 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 {+ Section { title: "Design rules (the tool's DRC)".into(), ok: Some(d["errors"].as_i64() == Some(0) && d["unconnected"].as_i64() == Some(0)),+ summary: format!("{} violations, {} unconnected", txt(&d["errors"]), txt(&d["unconnected"])),+ 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?}}. 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!({}))));+ c["assembly"] = crate::run("assembly_check", &with(json!({})));+ 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!({})));+ 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"));+ }+}
engine/src/review.rsadded+223@@ -0,0 +1,223 @@+//! 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) => "clearance".into(), Some(2) => "width".into(), Some(42) => "hole size".into(), Some(15) => "short circuit".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]>) -> (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, &|| {+ let have: BTreeSet<String> = rules.iter().filter(|r| r["enabled"] != json!(false)).map(rule_class).collect();+ 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?}}.+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 (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());+ 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"));+ }++ #[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");+ }+}
engine/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());+ } }
engine/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)
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "eda-bridge-standard",- "version": "0.19.0",+ "version": "0.20.0", "type": "skill", "title": "EDA Bridge Standard: one way for an AI to drive every PCB tool", "description": "The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.",
verbs/coverage.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.19.0",+ "standard": "0.20.0", "reference": { "bridge": "altium", "version": "0.2.80"
verbs/standard.json+1−1@@ -1,5 +1,5 @@ {- "standard": "0.19.0",+ "standard": "0.20.0", "reference": { "bridge": "altium", "version": "0.2.80"