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.
main
Colby Knox
standard 0.20.2: engine 0.17.2, rule names only for rules without an Altium ordinal; kicad 1.0.83
3c0ef8f
12d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
//! Design review: small checks, each a pure function over the board model, the sheet parts and the board's rules. From
//! the Altium bridge's review.rs, on the standard model: components off the board are judged against the outline
//! polygon (Altium's read carried only a bounding box), and rules are classed from a standard kind, a descriptor's
//! leading words or Altium's rule ordinal. What the model cannot answer is named in `notImplemented`.
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn s<'a>(v: &'a Value, k: &str) -> &'a str { v.get(k).and_then(|x| x.as_str()).unwrap_or("") }
const PLACEHOLDER_VALUES: [&str; 12] = ["comment", "value", "*", "?", "n/a", "na", "tbd", "xxx", "component", "designator", "part", "unknown"];
/// Adom house rule: Mouser is the preferred vendor, and every purchased part carries Mouser, DigiKey and LCSC numbers.
const VENDOR_PATTERNS: [(&str, &[&str]); 3] = [("Mouser", &["mouser"]), ("DigiKey", &["digikey", "digi-key", "digi key"]), ("LCSC", &["lcsc", "jlcpcb"])];
/// Designator prefixes that carry no vendor part number.
const NON_PURCHASED: [&str; 8] = ["TP", "FID", "MH", "H", "LOGO", "NET", "PWR", "GND"];
/// The rules a fab relies on: clearance, width, hole size, short circuit.
const CRITICAL: [&str; 4] = ["clearance", "width", "hole size", "short circuit"];
pub const NOT_IMPLEMENTED: [(&str, &str); 4] = [
("unused_mechanical_layers", "the board model carries no mechanical layers"),
("unrouted_connections", "needs the ratsnest: routing_drc or the tool's DRC"),
("silkscreen_over_pad", "silk_designators reports labels on pads (onPadsBefore)"),
("missing_decoupling", "schematic_review checks each IC supply for a capacitor to ground"),
];
fn finding(check: &str, severity: &str, key: (&str, &str), message: String) -> Value {
let mut v = json!({"check": check, "severity": severity, "message": message});
if !key.1.is_empty() { v[key.0] = json!(key.1); }
v
}
fn purchased(designator: &str) -> bool {
let prefix: String = designator.to_uppercase().chars().take_while(|c| c.is_ascii_alphabetic()).collect();
!NON_PURCHASED.contains(&prefix.as_str())
}
fn params_of(c: &Value) -> Vec<(String, String)> {
c["params"].as_array().map(|a| a.iter().filter_map(|p| {
let name = p["name"].as_str()?.trim().to_lowercase();
(!name.is_empty()).then(|| (name, p["value"].as_str().unwrap_or("").trim().to_string()))
}).collect()).unwrap_or_default()
}
/// A rule's class: a standard kind, the descriptor's leading words, or Altium's rule ordinal. Pure.
pub fn rule_class(r: &Value) -> String {
if let Some(k) = r["kind"].as_str() {
let k = k.to_lowercase();
for c in CRITICAL { if k.replace(['_', '-'], " ").contains(c) || (c == "hole size" && k == "hole") || (c == "short circuit" && k == "short") { return c.into(); } }
return k;
}
let d = s(r, "descriptor").to_lowercase().replace('-', " ");
for (prefix, c) in [("clearance constraint", "clearance"), ("width constraint", "width"), ("hole size constraint", "hole size"), ("short circuit constraint", "short circuit")] {
if d.starts_with(prefix) { return c.into(); }
}
match r["kind"].as_i64() {
Some(0) => return "clearance".into(), Some(2) => return "width".into(), Some(42) => return "hole size".into(), Some(15) => return "short circuit".into(),
// any other Altium ordinal is no critical rule: its descriptor names it ("Routing Topology Rule (Shortest)" is
// no short-circuit rule, "Polygon Connect (Conductor Width ...)" no width rule)
Some(_) => return d.split('(').next().unwrap_or("").trim().to_string(),
None => {}
}
// a tool's own rule names (Fusion's EAGLE rules: wire-wire, pad-via, minimum width, minimum drill, ...)
let t = format!("{} {} {}", s(r, "kindName"), s(r, "name"), d).to_lowercase().replace(['_', '-'], " ");
if t.contains("short circuit") || t.contains("shortcircuit") { return "short circuit".into(); }
if (t.contains("width") || t.contains("minimum wire")) && !t.contains("annular") && !t.contains("conductor") { return "width".into(); }
if t.contains("drill") || t.contains("hole size") || t.contains("holesize") { return "hole size".into(); }
let copper = !["silk", "mask", "paste", "edge", "board", "courtyard", "component", "hole to hole", "plane"].iter().any(|w| t.contains(w));
if copper && (t.contains("clearance") || t.contains("wire wire") || t.contains("pad via") || t.contains("pad pad")) { return "clearance".into(); }
d.split('(').next().unwrap_or("").trim().to_string()
}
/// The checks over the board model, the sheet parts (read_sheet_file's components, or none) and the rules. Returns the
/// findings (errors first), the checks run and those skipped with why. Pure.
pub fn run_checks(st: &Value, sheet: Option<&[Value]>, rules: &[Value], grid: f64, edge: f64, wanted: Option<&[String]>, always: &[String]) -> (Vec<Value>, Vec<String>, Map<String, Value>) {
let comps = st["components"].as_array().cloned().unwrap_or_default();
let pads = st["pads"].as_array().cloned().unwrap_or_default();
let ring = crate::coppermap::outline_ring(st);
let want = |n: &str| wanted.map(|w| w.iter().any(|x| x == n)).unwrap_or(true);
let (mut out, mut ran, mut skipped) = (Vec::new(), Vec::new(), Map::new());
let mut run = |name: &str, needs: Option<&str>, out: &mut Vec<Value>, f: &dyn Fn() -> Vec<Value>| {
if !want(name) { return; }
if let Some(why) = needs { skipped.insert(name.into(), json!(why)); return; }
ran.push(name.to_string());
out.extend(f());
};
let placed: Vec<&Value> = comps.iter().filter(|c| pads.iter().any(|p| p["ref"] == c["ref"])).collect();
let own = |r: &str| -> Vec<(f64, f64)> { pads.iter().filter(|p| p["ref"].as_str() == Some(r)).map(|p| (f(&p["x"]), f(&p["y"]))).collect() };
run("placeholder_values", None, &mut out, &|| placed.iter().filter_map(|c| {
let v = c.get("value").or(c.get("comment")).and_then(|v| v.as_str()).unwrap_or("").trim().to_string();
PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("placeholder_values", "error", ("designator", s(c, "ref")), format!("{} has the placeholder value {v:?}: it will reach the BOM as is.", s(c, "ref"))))
}).collect());
run("single_pin_nets", None, &mut out, &|| {
let mut n: BTreeMap<&str, usize> = BTreeMap::new();
for p in &pads { if let Some(net) = p["net"].as_str().filter(|x| !x.is_empty()) { *n.entry(net).or_default() += 1; } }
n.into_iter().filter(|(_, k)| *k == 1).map(|(net, _)| finding("single_pin_nets", "warning", ("net", net), format!("Net {net:?} has a single pad: it connects to nothing."))).collect()
});
run("off_grid_components", if grid > 0.0 { None } else { Some("no placement grid: pass gridMm (a 5 mil grid is 0.127)") }, &mut out, &|| placed.iter().filter_map(|c| {
let off = |v: f64| { let k = v / grid; (k - k.round()).abs() * grid > 0.001 };
(off(f(&c["x"])) || off(f(&c["y"]))).then(|| finding("off_grid_components", "info", ("designator", s(c, "ref")), format!("{} sits at ({}, {}) mm, off the {grid} mm grid.", s(c, "ref"), f(&c["x"]), f(&c["y"]))))
}).collect());
run("duplicate_designators", None, &mut out, &|| {
let mut v = Vec::new();
let mut count = |label: &str, names: Vec<String>| {
let mut seen: BTreeMap<String, u32> = BTreeMap::new();
for d in names.into_iter().filter(|d| !d.is_empty()) { *seen.entry(d).or_default() += 1; }
for (d, n) in seen { if n > 1 { v.push(finding("duplicate_designators", "error", ("designator", &d), format!("Designator {d} occurs {n} times on the {label}."))); } }
};
count("board", comps.iter().map(|c| s(c, "ref").to_string()).collect());
// a multi-part chip appears once per part on the sheets: count each designator once per part id
if let Some(sh) = sheet {
let mut uniq: BTreeSet<(String, String)> = BTreeSet::new();
let names: Vec<String> = sh.iter().filter(|c| uniq.insert((s(c, "designator").to_string(), format!("{}{}", s(c, "uniqueId"), c["partId"])))).map(|c| s(c, "designator").to_string()).collect();
let multi: BTreeSet<String> = sh.iter().filter(|c| c["partCount"].as_i64().unwrap_or(1) > 1).map(|c| s(c, "designator").to_string()).collect();
count("sheets", names.into_iter().filter(|d| !multi.contains(d)).collect());
}
v
});
run("components_outside_board_outline", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {
let ps = own(s(c, "ref"));
let outside = ps.iter().filter(|p| !crate::route::inside(**p, &ring)).count();
(outside > 0).then(|| finding("components_outside_board_outline", "error", ("designator", s(c, "ref")),
format!("{} has {outside} of {} pad(s) outside the board outline.", s(c, "ref"), ps.len())))
}).collect());
run("components_near_board_edge", if ring.len() < 3 { Some("the board has no outline") } else { None }, &mut out, &|| placed.iter().filter_map(|c| {
let ps = own(s(c, "ref"));
if ps.iter().any(|p| !crate::route::inside(*p, &ring)) { return None; }
let seg = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let l2 = dx * dx + dy * dy;
let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };
((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt()
};
let d = ps.iter().map(|p| (0..ring.len()).map(|i| seg(*p, ring[i], ring[(i + 1) % ring.len()])).fold(f64::MAX, f64::min)).fold(f64::MAX, f64::min);
(d < edge).then(|| finding("components_near_board_edge", "warning", ("designator", s(c, "ref")), format!("{} has a pad centre {:.3} mm from the board edge (under {edge} mm).", s(c, "ref"), d)))
}).collect());
let no_sheet = sheet.is_none().then_some("no sheet parts: pass sheetParts");
let sheet_parts: Vec<Value> = sheet.map(|x| x.to_vec()).unwrap_or_default();
run("vendor_part_numbers", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {
let blob = params_of(c).iter().map(|(k, v)| format!("{k} {v}")).collect::<Vec<_>>().join(" ").to_lowercase();
let missing: Vec<&str> = VENDOR_PATTERNS.iter().filter(|(_, keys)| !keys.iter().any(|k| blob.contains(k))).map(|(v, _)| *v).collect();
if missing.is_empty() { return None; }
let mut x = finding("vendor_part_numbers", "warning", ("designator", s(c, "designator")), format!("{} has no {} part number (house rule: Mouser preferred, all three required).", s(c, "designator"), missing.join(" / ")));
x["missing"] = json!(missing);
Some(x)
}).collect());
run("missing_datasheets", no_sheet, &mut out, &|| sheet_parts.iter().filter(|c| purchased(s(c, "designator"))).filter_map(|c| {
let has = params_of(c).iter().any(|(k, _)| k.contains("datasheet") || k == "helpurl");
(!has).then(|| finding("missing_datasheets", "info", ("designator", s(c, "designator")), format!("{} has no Datasheet parameter.", s(c, "designator"))))
}).collect());
run("schematic_placeholder_values", no_sheet, &mut out, &|| sheet_parts.iter().filter_map(|c| {
let v = s(c, "comment").trim().to_string();
PLACEHOLDER_VALUES.contains(&v.to_lowercase().as_str()).then(|| finding("schematic_placeholder_values", "error", ("designator", s(c, "designator")), format!("{} on the sheet has the placeholder Comment {v:?}.", s(c, "designator"))))
}).collect());
let no_rules = rules.is_empty().then_some("no rules: pass read_rules as rules");
run("disabled_design_rules", no_rules, &mut out, &|| rules.iter().filter(|r| r["enabled"] == json!(false)).map(|r| {
let c = rule_class(r);
let sev = if CRITICAL.contains(&c.as_str()) { "error" } else { "warning" };
finding("disabled_design_rules", sev, ("rule", s(r, "name")), format!("Design rule {:?} ({c}) is disabled: DRC passes without ever applying it.", s(r, "name")))
}).collect());
run("missing_critical_rules", no_rules, &mut out, &|| {
// a tool that always checks a class (EAGLE's DRC always checks shorts) passes it in alwaysChecked
let mut have: BTreeSet<String> = rules.iter().filter(|r| r["enabled"] != json!(false)).map(rule_class).collect();
have.extend(always.iter().map(|a| a.to_lowercase().replace(['_', '-'], " ")));
CRITICAL.iter().filter(|c| !have.contains(**c)).map(|c| finding("missing_critical_rules", "error", ("rule", c), format!("No enabled {c} rule on this board."))).collect()
});
run("duplicate_rule_priority", no_rules, &mut out, &|| {
let mut seen: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
for r in rules.iter().filter(|r| r["enabled"] != json!(false) && !r["priority"].is_null()) { seen.entry((rule_class(r), r["priority"].to_string())).or_default().push(s(r, "name").to_string()); }
seen.into_iter().filter(|(_, n)| n.len() > 1).map(|((c, p), mut n)| { n.sort(); finding("duplicate_rule_priority", "info", ("rule", &n.join(", ")), format!("{} enabled {c} rules share priority {p} ({}): which one wins is not readable from the board.", n.len(), n.join(", "))) }).collect()
});
run("components_without_footprint", None, &mut out, &|| placed.iter().filter(|c| s(c, "pattern").trim().is_empty())
.map(|c| finding("components_without_footprint", "warning", ("designator", s(c, "ref")), format!("{} has no footprint pattern name: it cannot be matched back to a library.", s(c, "ref")))).collect());
let order = |x: &Value| match s(x, "severity") { "error" => 0, "warning" => 1, _ => 2 };
out.sort_by(|a, b| order(a).cmp(&order(b)).then_with(|| s(a, "check").cmp(s(b, "check"))));
(out, ran, skipped)
}
/// review_board: input {board, sheetParts?, rules?, args: {checks?, gridMm?, edgeMm?, alwaysChecked?: ["short circuit", ...]}}.
pub fn review_board(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let sheet: Option<Vec<Value>> = crate::input::part(input, "sheetParts").and_then(|p| p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned());
let wanted: Option<Vec<String>> = args["checks"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect());
let always: Vec<String> = args["alwaysChecked"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default();
let (findings, ran, skipped) = run_checks(&st, sheet.as_deref(), &crate::input::rules(input), args["gridMm"].as_f64().unwrap_or(0.0), args["edgeMm"].as_f64().unwrap_or(0.5), wanted.as_deref(), &always);
let mut counts = json!({"error": 0, "warning": 0, "info": 0});
for x in &findings { let k = s(x, "severity").to_string(); counts[&k] = json!(counts[&k].as_i64().unwrap_or(0) + 1); }
json!({"success": true, "findings": findings, "counts": counts, "checks": ran, "skipped": skipped,
"notImplemented": NOT_IMPLEMENTED.iter().map(|(k, v)| (k.to_string(), json!(v))).collect::<Map<String, Value>>(),
"_hint": "Findings are errors first. skipped names the checks a missing companion stopped (pass sheetParts and rules for all of them); notImplemented names what this review does not judge, so silence there is not a clean board."})
}
#[cfg(test)]
mod tests {
use super::*;
fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value { json!({"ref": r, "name": n, "x": x, "y": y, "net": net}) }
#[test]
fn each_check_finds_its_problem() {
let st = json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],
"components": [{"ref": "U1", "x": 5.0, "y": 5.0, "pattern": "SOIC8", "value": "TBD"}, {"ref": "R1", "x": 19.8, "y": 5.0, "pattern": ""}, {"ref": "R1", "x": 10.0, "y": 5.0, "pattern": "0603"}, {"ref": "J1", "x": 25.0, "y": 5.0, "pattern": "HDR"}],
"pads": [pad("U1", "1", 4.0, 5.0, "A"), pad("U1", "2", 6.0, 5.0, "LONE"), pad("R1", "1", 19.8, 5.0, "A"), pad("J1", "1", 25.0, 5.0, "")]});
let sheet = vec![json!({"designator": "U1", "comment": "LM358", "params": [{"name": "Mouser Part Number", "value": "595-LM358"}]}), json!({"designator": "TP1", "comment": "Comment"})];
let rules = vec![json!({"name": "Clearance", "kind": 0, "enabled": true, "priority": 1, "descriptor": "Clearance Constraint (Gap=0.1mm)"}),
json!({"name": "Short", "kind": 15, "enabled": false, "priority": 1, "descriptor": "Short-Circuit Constraint (Allowed=No)"}),
json!({"name": "W1", "kind": "width", "enabled": true, "priority": 2}), json!({"name": "W2", "kind": "width", "enabled": true, "priority": 2})];
let (f, ran, _) = run_checks(&st, Some(&sheet), &rules, 0.127, 0.5, None, &[]);
let has = |c: &str, key: &str, v: &str| f.iter().any(|x| x["check"] == c && x[key] == v);
assert!(has("placeholder_values", "designator", "U1"));
assert!(has("single_pin_nets", "net", "LONE"));
assert!(has("duplicate_designators", "designator", "R1"));
assert!(has("components_outside_board_outline", "designator", "J1"));
assert!(has("components_near_board_edge", "designator", "R1"));
assert!(has("components_without_footprint", "designator", "R1"));
assert!(f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "U1" && x["missing"] == json!(["DigiKey", "LCSC"])));
assert!(!f.iter().any(|x| x["check"] == "vendor_part_numbers" && x["designator"] == "TP1"), "a test point is not purchased");
assert!(has("schematic_placeholder_values", "designator", "TP1"));
assert!(f.iter().any(|x| x["check"] == "disabled_design_rules" && x["severity"] == "error"));
assert!(has("missing_critical_rules", "rule", "hole size") && has("missing_critical_rules", "rule", "short circuit"));
assert!(f.iter().any(|x| x["check"] == "duplicate_rule_priority"));
assert_eq!(f[0]["severity"], "error");
assert_eq!(ran.len(), 13);
let (_, _, skipped) = run_checks(&st, None, &[], 0.127, 0.5, None, &[]);
assert!(skipped.contains_key("vendor_part_numbers") && skipped.contains_key("missing_critical_rules"));
// EAGLE always checks shorts: passed in alwaysChecked, a missing short-circuit rule is no finding
let (f2, _, _) = run_checks(&st, Some(&sheet), &rules, 0.0, 0.5, Some(&["missing_critical_rules".to_string()]), &["short_circuit".to_string()]);
assert!(!f2.iter().any(|x| x["rule"] == "short circuit"), "{f2:?}");
}
#[test]
fn rules_are_classed_three_ways() {
assert_eq!(rule_class(&json!({"kind": 42})), "hole size");
assert_eq!(rule_class(&json!({"kind": 99, "descriptor": "Width Constraint (Min=0.1mm)"})), "width");
assert_eq!(rule_class(&json!({"kind": "short_circuit"})), "short circuit");
// a tool's own names (Fusion's EAGLE rules), and a silk clearance is no copper clearance
assert_eq!(rule_class(&json!({"name": "Wire-Wire", "value": "0.1mm"})), "clearance");
assert_eq!(rule_class(&json!({"name": "Minimum Drill"})), "hole size");
assert_eq!(rule_class(&json!({"name": "Silk Clearance"})), "");
// Altium's other ordinals stay what their descriptor says, whatever words they hold
assert_eq!(rule_class(&json!({"kind": 7, "name": "RoutingTopology", "descriptor": "Routing Topology Rule(Topology=Shortest) (All)"})), "routing topology rule");
assert_eq!(rule_class(&json!({"kind": 6, "name": "PlaneConnect", "descriptor": "Power Plane Connect Rule(Relief Connect )(Conductor Width=0.254mm)"})), "power plane connect rule");
}
}