← Commit history
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;") }+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">{} &middot; {} pass, {} need work &middot; {}</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("&lt;X&gt;") && !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"