app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #366: kicad-lane: 1.0.95, KiCad on any drive (#137), cross_check from engine 0.24.0, eleven standard verbs by…
97fb57b
11d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
//! The bill of materials, from the sheets' parts and the board: fitted parts grouped one line per manufacturer part
//! number, checked for what an order needs, and the order files as text (generic, JLCPCB BOM + CPL, Mouser and DigiKey
//! uploads). Ported from the Altium bridge's bom.rs so every bridge answers `bom` the same way; the bridge reads the
//! parts and writes the files. Also `cross_check`: the sheets' designators against the board's.
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
/// Parameter names that mean the same thing across libraries and tools (Altium templates, the Adom part import, the
/// wiki's Cloud Library, Altium 365 supplier links, KiCad fields, EAGLE attributes).
const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "MANUFACTURER_PN", "Part Number", "PartNumber", "Part_Number"];
const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER", "MANUFACTURER_NAME"];
const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];
const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];
const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #", "JLCPCB"];
const VALUE: &[&str] = &["Value", "VALUE"];
const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];
const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];
const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];
const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];
fn st(v: &Value) -> String { match v { Value::String(s) => s.clone(), Value::Null => String::new(), o => o.to_string() } }
/// R1 < R2 < R10 < R10A: the letters, then the number, then the rest.
pub fn natural_key(d: &str) -> (String, u64, String) {
let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();
let rest = &d[pre.len()..];
let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
(pre.to_ascii_uppercase(), num.parse().unwrap_or(0), rest[num.len()..].to_string())
}
fn truthy(v: &str) -> bool { matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x") }
/// One part, merged from its sheet (parameters) and the board (footprint, placement).
#[derive(Debug, Clone, Default)]
pub struct Part {
pub designator: String,
pub comment: String,
pub description: String,
pub lib_ref: String,
/// out of the BOM by its kind (Altium's graphical, net tie and standard-no-BOM kinds; KiCad's exclude from BOM)
pub no_bom: bool,
pub params: Vec<(String, String)>,
pub footprint: String,
pub placed: Option<(f64, f64, f64, String)>,
pub dnp: bool,
pub alternate: String,
pub managed: bool,
pub source_lib: String,
}
impl Part {
fn get(&self, names: &[&str]) -> String {
for n in names {
if let Some((_, v)) = self.params.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty() && !v.trim().starts_with('=')) { return v.trim().to_string(); }
}
String::new()
}
fn mpn(&self) -> String { self.get(MPN) }
fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }
/// Copper test pads, fiducials and the like carry an MPN or maker that says nothing is bought ("none (PCB copper)"):
/// they stay in the BOM, marked, and out of the ordering and assembly files.
fn not_purchased(&self) -> bool {
[self.mpn(), self.get(MFR)].iter().any(|v| {
let m = v.trim().to_ascii_lowercase();
m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")
})
}
}
/// The parts from the sheetParts companion (units of one part merged by designator), with the board's footprint and
/// placement. Returns (parts, designators on the board but on no sheet).
pub fn parts(sheet: &[Value], board: Option<&Value>) -> (Vec<Part>, Vec<String>) {
let mut by: BTreeMap<String, Part> = BTreeMap::new();
for c in sheet {
let d = st(&c["designator"]);
if d.is_empty() || d.ends_with('?') { continue; }
// params: [{name, value}] or {name: value}
let params: Vec<(String, String)> = match &c["params"] {
Value::Array(a) => a.iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect(),
Value::Object(o) => o.iter().map(|(k, v)| (k.clone(), st(v))).collect(),
_ => Vec::new(),
};
let kind = c["kind"].as_i64().unwrap_or(0);
let no_bom = matches!(kind, 2 | 4 | 5) || c["inBom"] == false || c["excludeFromBom"] == true;
let e = by.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {
designator: d.clone(), comment: [&c["comment"], &c["value"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),
description: st(&c["description"]), lib_ref: [&c["libRef"], &c["libReference"], &c["libId"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(),
no_bom, managed: !st(&c["itemGuid"]).is_empty(), source_lib: st(&c["sourceLibrary"]), dnp: c["dnp"] == true, ..Default::default() });
// a multi-unit part appears once per unit: keep the first, add what it lacks
for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }
if c["dnp"] == true { e.dnp = true; }
if e.footprint.is_empty() { e.footprint = [&c["footprint"], &c["pattern"]].iter().map(|v| st(v)).find(|s| !s.is_empty()).unwrap_or_default(); }
}
let mut board_only = Vec::new();
if let Some(b) = board {
for c in b["components"].as_array().cloned().unwrap_or_default() {
let d = st(&c["ref"]);
if d.is_empty() { continue; }
let placed = Some((c["x"].as_f64().unwrap_or(0.0), c["y"].as_f64().unwrap_or(0.0), c["rotation"].as_f64().unwrap_or(0.0), if c["side"] == "bottom" { "bottom".to_string() } else { "top".to_string() }));
match by.get_mut(&d.to_ascii_uppercase()) {
Some(p) => { let pat = st(&c["pattern"]); if !pat.is_empty() { p.footprint = pat; } p.placed = placed; }
None => board_only.push(d),
}
}
}
for p in by.values_mut() {
if truthy(&p.get(DNP)) || p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }
}
board_only.sort_by_key(|d| natural_key(d));
(by.into_values().collect(), board_only)
}
/// Group the fitted parts: one line per manufacturer part number, else per value + footprint. Returns (lines, checks).
pub fn group(parts: &[Part]) -> (Vec<Value>, Value) {
let mut groups: BTreeMap<String, Vec<&Part>> = BTreeMap::new();
for p in parts.iter().filter(|p| !p.dnp && !p.no_bom) {
let m = p.mpn();
let key = if m.is_empty() { format!("~{}|{}", p.value().to_ascii_uppercase(), p.footprint.to_ascii_uppercase()) } else { format!("{}|{}", m.to_ascii_uppercase(), p.get(MFR).to_ascii_uppercase()) };
groups.entry(key).or_default().push(p);
}
let mut lines = Vec::new();
let mut inconsistent = Vec::new();
for (_, mut g) in groups {
g.sort_by_key(|p| natural_key(&p.designator));
let refs: Vec<String> = g.iter().map(|p| p.designator.clone()).collect();
let mut line = Map::new();
line.insert("quantity".into(), json!(g.len()));
line.insert("designators".into(), json!(refs));
for (name, names) in FIELDS {
let mut distinct: Vec<String> = g.iter().map(|p| p.get(names)).filter(|v| !v.is_empty()).collect();
distinct.sort(); distinct.dedup();
if distinct.len() > 1 { inconsistent.push(json!({"field": name, "designators": refs, "values": distinct})); }
line.insert((*name).into(), json!(distinct.first().cloned().unwrap_or_default()));
}
let first = g[0];
let mut fps: Vec<&str> = g.iter().map(|p| p.footprint.as_str()).filter(|f| !f.is_empty()).collect();
fps.sort(); fps.dedup();
if fps.len() > 1 { inconsistent.push(json!({"field": "footprint", "designators": refs, "values": fps})); }
line.insert("value".into(), json!(first.value()));
line.insert("comment".into(), json!(first.comment));
line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));
line.insert("sourceLibrary".into(), json!(first.source_lib));
line.insert("managed".into(), json!(first.managed));
line.insert("footprint".into(), json!(fps.first().copied().unwrap_or("")));
line.insert("package".into(), json!(first.get(PACKAGE)));
line.insert("libReference".into(), json!(first.lib_ref));
line.insert("purchase".into(), json!(!first.not_purchased()));
lines.push(Value::Object(line));
}
lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));
let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };
let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased()).collect();
let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());
let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && st(&l["lcsc"]).is_empty())
.map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();
let checks = json!({
"missingMpn": missing(MPN), "missingManufacturer": missing(MFR),
"missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},
"inconsistent": inconsistent,
"dnp": sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect()),
"excludedKinds": sorted(parts.iter().filter(|p| !p.dnp && p.no_bom).map(|p| p.designator.clone()).collect()),
"notPurchased": sorted(parts.iter().filter(|p| !p.dnp && !p.no_bom && p.not_purchased()).map(|p| p.designator.clone()).collect()),
"jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},
});
(lines, checks)
}
fn csv(rows: &[Vec<String>]) -> String {
let cell = |s: &str| if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() };
rows.iter().map(|r| r.iter().map(|c| cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"
}
/// The order files as (name, text). `boards` multiplies the quantities of the distributor uploads.
pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, String)> {
let refs = |l: &Value| l["designators"].as_array().map(|a| a.iter().map(st).collect::<Vec<_>>().join(",")).unwrap_or_default();
let mut out = Vec::new();
let mut rows = vec![["Line", "Quantity", "Designators", "Value", "Comment", "Description", "Footprint", "Package", "MPN", "Manufacturer", "Mouser Part #", "DigiKey Part #", "LCSC Part #"].map(String::from).to_vec()];
for (i, l) in lines.iter().enumerate() {
rows.push(vec![(i + 1).to_string(), st(&l["quantity"]), refs(l), st(&l["value"]), st(&l["comment"]), st(&l["description"]), st(&l["footprint"]), st(&l["package"]), st(&l["mpn"]), st(&l["manufacturer"]), st(&l["mouser"]), st(&l["digikey"]), st(&l["lcsc"])]);
}
out.push(("bom.csv".to_string(), csv(&rows)));
// JLCPCB assembly: their BOM and CPL columns, mm, Top/Bottom
let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];
for l in lines.iter().filter(|l| l["purchase"] != json!(false)) { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }
out.push(("bom-jlcpcb.csv".to_string(), csv(&j)));
let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];
let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.no_bom && !p.not_purchased() && p.placed.is_some()).collect();
placed.sort_by_key(|p| natural_key(&p.designator));
for p in placed {
let (x, y, r, side) = p.placed.clone().unwrap();
c.push(vec![p.designator.clone(), format!("{x:.4}mm"), format!("{y:.4}mm"), if side == "bottom" { "Bottom".into() } else { "Top".into() }, format!("{}", (r.rem_euclid(360.0) * 1000.0).round() / 1000.0)]);
}
out.push(("cpl-jlcpcb.csv".to_string(), csv(&c)));
// the distributors' BOM tools (Mouser BOM Tool, DigiKey myLists) map these headers directly
for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] {
let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];
for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) {
m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]);
}
out.push((name.to_string(), csv(&m)));
}
out
}
fn sheet_list(input: &Value) -> Option<Vec<Value>> {
let p = crate::input::part(input, "sheetParts")?;
p.get("components").or(Some(&p)).and_then(|x| x.as_array()).cloned()
}
/// bom: input {sheetParts, board?, args: {variant? (the standard `variants` row: {name, notFitted: [ref], alternates:
/// [{designator, part | fields}]}), boards? (1)}}. Parts carry designator, comment or value, description, libRef,
/// footprint?, kind? (Altium) or inBom/excludeFromBom (KiCad), dnp?, params ([{name, value}] or {name: value}).
pub fn bom(input: &Value) -> Value {
let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts (every sheet, with their parameters) as sheetParts") };
let board = crate::input::part(input, "board");
let (mut list, board_only) = parts(&sheet, board.as_ref());
let args = &input["args"];
let variant = &args["variant"];
let mut alternates = Vec::new();
if variant.is_object() {
let nf: BTreeSet<String> = variant["notFitted"].as_array().map(|a| a.iter().map(|x| st(x).to_ascii_uppercase()).collect()).unwrap_or_default();
let alts: BTreeMap<String, String> = match &variant["alternates"] {
Value::Array(a) => a.iter().map(|x| (st(&x["designator"]).to_ascii_uppercase(), if x["part"].is_null() { st(&x["fields"]) } else { st(&x["part"]) })).collect(),
Value::Object(o) => o.iter().map(|(k, v)| (k.to_ascii_uppercase(), st(v))).collect(),
_ => BTreeMap::new(),
};
for p in list.iter_mut() {
let k = p.designator.to_ascii_uppercase();
if nf.contains(&k) { p.dnp = true; }
if let Some(a) = alts.get(&k) { p.alternate = a.clone(); alternates.push(json!({"designator": p.designator, "alternatePart": a})); }
}
}
let (lines, mut checks) = group(&list);
let mut not_on_board: Vec<String> = if board.is_some() { list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect() } else { Vec::new() };
not_on_board.sort_by_key(|d| natural_key(d));
checks["sheetNotOnBoard"] = json!(not_on_board);
checks["boardNotOnSheet"] = json!(board_only);
let boards = args["boards"].as_u64().unwrap_or(1);
let fitted = list.iter().filter(|p| !p.dnp && !p.no_bom).count();
let files: Vec<Value> = files(&lines, &list, boards).into_iter().map(|(n, t)| json!({"name": n, "text": t})).collect();
json!({"success": true, "variant": variant["name"], "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks, "alternates": alternates, "boards": boards, "files": files,
"_hint": "One line per manufacturer part number. Write files[] (bom.csv, bom-jlcpcb.csv + cpl-jlcpcb.csv for JLCPCB assembly, bom-mouser.csv and bom-digikey.csv for those sites' BOM tools). checks: parts with no MPN or distributor number (find them with adom-parts-search and write them back with sheet_params), lines that mix values for one part number (checks.inconsistent), JLCPCB lines with no LCSC number, and parts on the sheets but not the board or the other way round."})
}
/// cross_check: input {sheetParts, board}. The sheets' designators against the board's.
pub fn cross_check(input: &Value) -> Value {
let Some(sheet) = sheet_list(input) else { return crate::input::refuse("bad_args", "pass the sheets' parts as sheetParts") };
let st_ = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let (list, board_only) = parts(&sheet, Some(&st_));
let mut sheet_only: Vec<String> = list.iter().filter(|p| p.placed.is_none() && !p.no_bom).map(|p| p.designator.clone()).collect();
sheet_only.sort_by_key(|d| natural_key(d));
let matched = list.iter().filter(|p| p.placed.is_some()).count();
json!({"success": true, "schematicComponents": list.len(), "boardComponents": matched + board_only.len(), "matched": matched,
"onSheetNotOnBoard": sheet_only, "onBoardNotOnSheet": board_only,
"_hint": "onSheetNotOnBoard: update the board from the schematic (update_pcb). onBoardNotOnSheet: parts placed by hand or left after a schematic change; remove them (remove_parts) or add them to a sheet. Parts kept out of the BOM by kind are not counted as missing from the board."})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bom_groups_by_part_number_and_checks_the_order() {
let p = |d: &str, v: &str, params: Value| json!({"designator": d, "comment": v, "libRef": "R", "params": params});
let sheet = vec![
p("R10", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98220"})),
p("R2", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "LCSC": "C98221"})),
p("R1", "10k", json!({"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo"})),
p("C1", "100n", json!({"Value": "100nF"})),
p("TP1", "TP", json!({"MPN": "none (PCB copper)"})),
p("U1", "MCU", json!([{"name": "MPN", "value": "RP2040"}, {"name": "DNP", "value": "yes"}])),
json!({"designator": "R?", "comment": "x"}),
json!({"designator": "H1", "comment": "hole", "excludeFromBom": true}),
];
let board = json!({"components": [{"ref": "R1", "pattern": "R_0603", "x": 1.0, "y": 2.0, "rotation": 450.0, "side": "top"}, {"ref": "R2", "pattern": "R_0603", "x": 3.0, "y": 2.0, "rotation": 0.0, "side": "bottom"}, {"ref": "J9", "pattern": "X", "x": 0.0, "y": 0.0}]});
let r = bom(&json!({"sheetParts": sheet, "board": board, "args": {"boards": 5, "variant": {"name": "lite", "notFitted": ["C1"]}}}));
// R1, R2, R10 on one line in natural order; TP1 its own line, not purchased; U1 and C1 not fitted; H1 excluded
assert_eq!(r["lines"][0]["designators"], json!(["R1", "R2", "R10"]), "{r}");
assert_eq!(r["lines"][0]["quantity"], 3);
assert_eq!(r["lineCount"], 2);
assert_eq!(r["checks"]["inconsistent"][0]["field"], "lcsc");
assert_eq!(r["checks"]["dnp"], json!(["C1", "U1"]));
assert_eq!(r["checks"]["excludedKinds"], json!(["H1"]));
assert_eq!(r["checks"]["notPurchased"], json!(["TP1"]));
assert_eq!(r["checks"]["boardNotOnSheet"], json!(["J9"]));
assert_eq!(r["checks"]["sheetNotOnBoard"], json!(["C1", "R10", "TP1", "U1"]));
let file = |n: &str| r["files"].as_array().unwrap().iter().find(|f| f["name"] == n).unwrap()["text"].as_str().unwrap().to_string();
assert!(file("cpl-jlcpcb.csv").contains("R1,1.0000mm,2.0000mm,Top,90\r\n") && file("cpl-jlcpcb.csv").contains("R2,3.0000mm,2.0000mm,Bottom,0\r\n"), "{}", file("cpl-jlcpcb.csv"));
assert!(file("bom-mouser.csv").contains(",RC0603FR-0710KL,Yageo,15,\"R1,R2,R10\""), "{}", file("bom-mouser.csv"));
assert!(!file("bom-jlcpcb.csv").contains("TP1"));
let x = cross_check(&json!({"sheetParts": [p("R1", "", json!({})), p("R2", "", json!({}))], "board": {"components": [{"ref": "R1"}, {"ref": "J9"}], "pads": []}}));
assert_eq!((x["matched"].clone(), x["onSheetNotOnBoard"].clone(), x["onBoardNotOnSheet"].clone()), (json!(1), json!(["R2"]), json!(["J9"])), "{x}");
}
}