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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
//! The EDA Bridge Standard's bom, assembly_check and jlcpcb_order, for KiCad. The BOM's grouping,
//! the parameter names matched across libraries, the files written and JLCPCB's two assembly
//! tiers are ported from the Altium bridge (bridge-rs/src/bom.rs and assembly.rs, 0.2.77), so a
//! KiCad BOM and an Altium BOM read the same. Pure: the verbs read the netlist and the board.
//!
//! A KiCad part is its schematic symbol (the netlist's fields, `dnp` and `exclude_from_bom`
//! flags) merged with its board footprint (footprint, placement, the board's own fields and
//! attributes). Without a schematic the board's footprints are the parts.
use std::collections::{BTreeMap, HashMap};
use serde_json::{json, Map, Value};
use crate::fab;
use crate::netlist::Netlist;
pub const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "Mfr. Part #", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Manufacturer_Part_Number", "Part Number", "PartNumber", "Part_Number"];
pub const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Manufacturer_Name", "Mfr", "MFR", "MANUFACTURER"];
pub const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN", "Mouser"];
pub const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN", "DigiKey", "Digi-Key_PN"];
pub const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "LCSC#", "JLCPCB Part #", "JLC Part #", "JLCPCB Part", "JLCPCB", "JLC"];
const PACKAGE: &[&str] = &["Package", "Case/Package", "PACKAGE"];
const DESCRIPTION: &[&str] = &["Description", "DESCRIPTION"];
const DNP: &[&str] = &["DNP", "Do Not Populate", "DNF", "Do Not Fit"];
fn param(params: &[(String, String)], names: &[&str]) -> String {
for n in names {
if let Some((_, v)) = 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 truthy(v: &str) -> bool {
matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "y" | "dnp" | "x")
}
/// R1 < R2 < R10 < R10A.
pub fn natural_key(d: &str) -> (String, u64, String) {
fab::ref_key(&d.to_ascii_uppercase())
}
/// One placed part: its symbol's fields and its footprint's placement.
#[derive(Debug, Clone, Default)]
pub struct Part {
pub designator: String,
pub value: String,
pub description: String,
pub lib_ref: String,
pub params: Vec<(String, String)>,
pub footprint: String,
/// (x, y in the standard's frame, rotation, "top" | "bottom")
pub placed: Option<(f64, f64, f64, String)>,
pub dnp: bool,
/// exclude_from_bom (schematic or board) or board_only: not a bought part
pub excluded: bool,
}
impl Part {
pub fn get(&self, names: &[&str]) -> String {
param(&self.params, names)
}
pub fn mpn(&self) -> String {
self.get(MPN)
}
/// Test pads, fiducials and logos carry an MPN or manufacturer that says nothing is bought.
pub 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 schematic's netlist merged with the board's footprints (`placed` by
/// reference, from the board model), or the board alone. Returns the parts and which designators
/// are only on the sheets or only on the board.
pub fn parts(nl: Option<&Netlist>, board: &[fab::Part], placed: &HashMap<String, (f64, f64, f64, String)>) -> (Vec<Part>, Value) {
let on_board: HashMap<&str, &fab::Part> = board.iter().filter(|b| !b.reference.is_empty()).map(|b| (b.reference.as_str(), b)).collect();
let board_part = |b: &fab::Part| -> Part {
let params: Vec<(String, String)> = b.props.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let dnp = b.dnp() || DNP.iter().any(|n| truthy(¶m(¶ms, &[n])));
Part { designator: b.reference.clone(), value: b.value.clone(), description: param(¶ms, DESCRIPTION), lib_ref: String::new(), footprint: b.footprint.clone(),
placed: placed.get(&b.reference).cloned(), dnp, excluded: !b.in_bom(), params }
};
let Some(nl) = nl else {
let mut out: Vec<Part> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#')).map(board_part).collect();
out.sort_by_key(|p| natural_key(&p.designator));
return (out, json!({"sheetNotOnBoard": [], "boardNotOnSheet": [], "from": "board"}));
};
let mut out = Vec::new();
let mut sheet_only = Vec::new();
for c in &nl.components {
if c.reference.starts_with('#') {
continue;
}
let flag = |k: &str| c.properties.iter().any(|(n, _)| n.eq_ignore_ascii_case(k));
let mut params: Vec<(String, String)> = c.fields.clone();
params.push(("Value".into(), c.value.clone()));
let b = on_board.get(c.reference.as_str());
if let Some(b) = b {
// the board's fields fill what the sheet left empty
for (k, v) in &b.props {
if param(¶ms, &[k]).is_empty() {
params.push((k.clone(), v.clone()));
}
}
} else if !flag("exclude_from_board") {
sheet_only.push(json!(c.reference));
}
let dnp = flag("dnp") || b.map(|b| b.dnp()).unwrap_or(false) || DNP.iter().any(|n| truthy(¶m(¶ms, &[n])));
let excluded = flag("exclude_from_bom") || b.map(|b| !b.in_bom()).unwrap_or(false);
out.push(Part {
designator: c.reference.clone(), value: c.value.clone(), description: c.description.clone(), lib_ref: c.libsource.clone(),
footprint: b.map(|b| b.footprint.clone()).filter(|f| !f.is_empty()).unwrap_or_else(|| c.footprint.clone()),
placed: placed.get(&c.reference).cloned(), dnp, excluded, params,
});
}
let on_sheet: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
let mut board_only: Vec<String> = board.iter().filter(|b| !b.reference.is_empty() && !b.reference.starts_with('#') && !on_sheet.contains(&b.reference.as_str()) && b.in_bom())
.map(|b| b.reference.clone()).collect();
board_only.sort_by_key(|d| natural_key(d));
out.sort_by_key(|p| natural_key(&p.designator));
(out, json!({"sheetNotOnBoard": sheet_only, "boardNotOnSheet": board_only, "from": "schematic"}))
}
const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];
/// The footprint's name without its library (`Capacitor_SMD:C_0402_1005Metric` is `C_0402_1005Metric`).
fn fp_name(f: &str) -> &str {
f.rsplit(':').next().unwrap_or(f)
}
/// Group the fitted parts: one line per manufacturer part number, else per value and 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.excluded) {
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 vals: Vec<String> = g.iter().map(|p| p.get(names)).collect();
let mut distinct: Vec<&String> = vals.iter().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().map(|s| s.as_str()).unwrap_or("")));
}
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}));
}
let fp = fps.first().copied().unwrap_or("");
let package = first.get(PACKAGE);
line.insert("value".into(), json!(first.value));
line.insert("comment".into(), json!(first.value));
line.insert("description".into(), json!(if first.description.is_empty() { first.get(DESCRIPTION) } else { first.description.clone() }));
line.insert("footprint".into(), json!(fp));
line.insert("package".into(), json!(if package.is_empty() { fp_name(fp).to_string() } else { 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 fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !p.excluded && !p.not_purchased()).collect();
let sorted = |mut v: Vec<String>| { v.sort_by_key(|d| natural_key(d)); v };
let missing = |names: &[&str]| sorted(fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect());
let dnp = sorted(parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect());
let excluded = sorted(parts.iter().filter(|p| !p.dnp && p.excluded).map(|p| p.designator.clone()).collect());
let not_purchased = sorted(parts.iter().filter(|p| !p.dnp && !p.excluded && p.not_purchased()).map(|p| p.designator.clone()).collect());
// JLCPCB places only what it can source by LCSC number: every other purchased line is consigned or blocks
let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && l["lcsc"].as_str().unwrap_or("").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": dnp, "excludedKinds": excluded, "notPurchased": not_purchased,
"jlcpcb": {"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc},
});
(lines, checks)
}
fn csv_cell(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
}
fn csv(rows: &[Vec<String>]) -> String {
rows.iter().map(|r| r.iter().map(|c| csv_cell(c)).collect::<Vec<_>>().join(",")).collect::<Vec<_>>().join("\r\n") + "\r\n"
}
fn st(v: &Value) -> String {
v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())
}
/// The files, as (name, text): bom.csv, JLCPCB's BOM and CPL, the Mouser and DigiKey BOM-tool
/// files. `boards` multiplies the quantities of the ordering files.
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; positions as KiCad's own position file gives them (mm, y up)
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), fp_name(&st(&l["footprint"])).to_string(), 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.excluded && !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)));
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
}
// ---------------------------------------------------------------------------
// assembly_check: JLCPCB's PCBA tiers (jlcpcb.com/capabilities/pcb-assembly-capabilities, read 2026-10-26)
// ---------------------------------------------------------------------------
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(0.0)
}
fn r3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// What the board offers the assembly line, from the board model. Pure.
pub fn facts(st: &Value) -> Value {
let pads = st["pads"].as_array().cloned().unwrap_or_default();
// a component with no pads (a logo, a graphic) is not a part the line places (std #24)
let with_pads: std::collections::BTreeSet<&str> = pads.iter().filter_map(|p| p["ref"].as_str()).collect();
let comps: Vec<Value> = st["components"].as_array().cloned().unwrap_or_default().into_iter().filter(|c| c["ref"].as_str().map(|r| with_pads.contains(r)).unwrap_or(false)).collect();
let mut by: BTreeMap<String, Vec<(f64, f64)>> = BTreeMap::new();
for p in pads.iter().filter(|p| f(&p["drill"]) == 0.0) {
if let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) {
by.entry(r.to_string()).or_default().push((f(&p["x"]), f(&p["y"])));
}
}
// the finest pitch: the closest pad centres within one part with 3 or more SMD pads;
// the smallest two-pad part: its pad centres (0402 about 1.0 mm, 0201 0.5, 01005 0.35)
let mut pitch: Option<(f64, String)> = None;
let mut chip: Option<(f64, String)> = None;
for (r, ps) in &by {
let mut d = f64::MAX;
for i in 0..ps.len() {
for j in i + 1..ps.len() {
// pads sharing one centre are one land drawn in pieces, not a 0 mm pitch (std #24)
let dd = ((ps[i].0 - ps[j].0).powi(2) + (ps[i].1 - ps[j].1).powi(2)).sqrt();
if dd >= 0.01 {
d = d.min(dd);
}
}
}
if d == f64::MAX {
continue;
}
if ps.len() >= 3 && pitch.as_ref().map(|p| d < p.0).unwrap_or(true) {
pitch = Some((d, r.clone()));
}
if ps.len() == 2 && chip.as_ref().map(|p| d < p.0).unwrap_or(true) {
chip = Some((d, r.clone()));
}
}
let named = |c: &Value, pat: &[&str]| { let p = c["pattern"].as_str().or(c["footprint"].as_str()).unwrap_or("").to_ascii_uppercase(); pat.iter().any(|x| p.contains(x)) };
let tiny: Vec<Value> = comps.iter().filter(|c| named(c, &["0201", "01005", "0603M", "0402M"])).map(|c| json!({"ref": c["ref"], "footprint": c["footprint"]})).collect();
let fid = |s: &str| s.to_ascii_uppercase().starts_with("FID");
let fiducials = comps.iter().filter(|c| c["ref"].as_str().map(fid).unwrap_or(false) || named(c, &["FIDUCIAL"])).count();
let bottom: Vec<Value> = comps.iter().filter(|c| c["side"] == "bottom").map(|c| c["ref"].clone()).collect();
let tht: std::collections::BTreeSet<String> = pads.iter().filter(|p| f(&p["drill"]) > 0.0 && p["ref"].as_str().map(|r| !r.is_empty()).unwrap_or(false))
.filter_map(|p| p["ref"].as_str().map(String::from)).collect();
json!({"parts": comps.len(), "bottomSideParts": bottom, "finestPitch": pitch.map(|(d, r)| json!({"mm": r3(d), "ref": r})),
"smallestChip": chip.map(|(d, r)| json!({"padCentresMm": r3(d), "ref": r})), "tinyFootprints": tiny, "fiducials": fiducials,
"throughHoleParts": tht.into_iter().collect::<Vec<_>>()})
}
fn rule(name: &str, ok: Option<bool>, detail: String) -> Value {
json!({"rule": name, "ok": ok, "detail": detail})
}
/// The board against one tier ("economic" or "standard"). `size` is [w, h] mm (the board, or the
/// panel it ships in), `lcsc_missing` the purchased lines with no LCSC number (None: not checked).
pub fn tier(name: &str, fa: &Value, size: (f64, f64), thick: Option<f64>, layers: Option<i64>, lcsc_missing: Option<usize>, panel: bool) -> Value {
let eco = name == "economic";
let mut rs = Vec::new();
let (w, h) = (size.0.min(size.1), size.0.max(size.1));
let (lo, hi): ((f64, f64), (f64, f64)) = match (eco, panel) {
(true, false) => ((10.0, 10.0), (470.0, 500.0)),
(true, true) => ((10.0, 10.0), (250.0, 250.0)),
(false, false) => ((70.0, 70.0), (460.0, 500.0)),
(false, true) => ((70.0, 70.0), (250.0, 250.0)),
};
let ok = w >= lo.0 && h >= lo.1 && w <= hi.0.min(hi.1) && h <= hi.0.max(hi.1);
rs.push(rule(&format!("{} {} x {} to {} x {} mm", if panel { "panel" } else { "board" }, lo.0, lo.1, hi.0, hi.1), Some(ok),
format!("{} x {} mm{}", r3(size.0), r3(size.1), if !ok && !eco && !panel { ": a panel (kicad_panelize) can bring it to 70 x 70" } else { "" })));
if eco {
rs.push(match thick { Some(t) => rule("board 0.8 to 1.6 mm thick", Some((0.8 - 1e-6..=1.6 + 1e-6).contains(&t)), format!("{} mm", r3(t))), None => rule("board 0.8 to 1.6 mm thick", None, "thickness not read".into()) });
rs.push(match layers { Some(n) => rule("2, 4 or 6 layers", Some([2, 4, 6].contains(&n)), format!("{n} layers")), None => rule("2, 4 or 6 layers", None, "layers not read".into()) });
let bot = fa["bottomSideParts"].as_array().map(|a| a.len()).unwrap_or(0);
rs.push(rule("parts on one side only", Some(bot == 0), if bot == 0 { "all on top".into() } else { format!("{bot} part(s) on the bottom: {}", fa["bottomSideParts"]) }));
}
let (chip_min, pitch_min) = if eco { (0.8, 0.4) } else { (0.3, 0.35) };
let tiny = fa["tinyFootprints"].as_array().map(|a| a.len()).unwrap_or(0);
let chip = fa["smallestChip"]["padCentresMm"].as_f64();
let chip_ok = (eco && tiny == 0 || !eco) && chip.map(|c| c >= chip_min).unwrap_or(true);
rs.push(rule(if eco { "smallest package 0402" } else { "smallest package 01005" }, Some(chip_ok),
format!("smallest two-pad part {} (pad centres {} mm){}", fa["smallestChip"]["ref"].as_str().unwrap_or("-"), chip.map(r3).unwrap_or(0.0), if tiny > 0 { format!("; footprints named under 0402: {}", fa["tinyFootprints"]) } else { String::new() })));
let pitch = fa["finestPitch"]["mm"].as_f64();
rs.push(rule(&format!("IC pin pitch {pitch_min} mm or more (BGA {} mm)", if eco { 0.5 } else { 0.3 }), Some(pitch.map(|p| p + 1e-6 >= pitch_min).unwrap_or(true)),
format!("finest {} mm on {}", pitch.map(r3).unwrap_or(0.0), fa["finestPitch"]["ref"].as_str().unwrap_or("-"))));
if !eco {
let n = fa["fiducials"].as_u64().unwrap_or(0);
rs.push(rule("fiducials and edge rails", Some(n >= 2 || panel), if panel { "on the panel rails (kicad_panelize adds three)".into() } else { format!("{n} fiducial(s) on the board: add 3 or ship it in a panel with rails (kicad_panelize)") }));
}
rs.push(match lcsc_missing {
Some(0) => rule("every purchased part has an LCSC number", Some(true), "all lines".into()),
Some(n) => rule("every purchased part has an LCSC number", Some(false), format!("{n} line(s) without one: find them (adom-parts-search, adom-jlcpcb) or consign them")),
None => rule("every purchased part has an LCSC number", None, "not checked: no schematic or BOM read".into()),
});
let ok = rs.iter().all(|r| r["ok"] != json!(false));
json!({"tier": name, "ok": ok, "rules": rs})
}
#[cfg(test)]
mod tests {
use super::*;
fn part(d: &str, value: &str, fp: &str, params: &[(&str, &str)]) -> Part {
Part { designator: d.into(), value: value.into(), footprint: fp.into(), params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
placed: Some((1.0, -2.0, 90.0, "top".into())), ..Default::default() }
}
#[test]
fn lines_group_by_mpn_and_say_what_an_order_lacks() {
let ps = vec![
part("C2", "100n", "C:C_0402", &[("MPN", "GRM155"), ("LCSC", "C1525"), ("Manufacturer", "Murata")]),
part("C10", "100n", "C:C_0402", &[("Manufacturer Part Number", "GRM155"), ("LCSC Part #", "C1525"), ("Manufacturer", "Murata")]),
part("R1", "10k", "R:R_0402", &[]),
Part { dnp: true, ..part("R2", "10k", "R:R_0402", &[]) },
Part { excluded: true, ..part("H1", "MountingHole", "MH:M3", &[]) },
part("TP1", "TP", "TP:Pad", &[("MPN", "none (PCB copper)")]),
];
let (lines, checks) = group(&ps);
let c = lines.iter().find(|l| l["mpn"] == "GRM155").unwrap();
assert_eq!(c["designators"], json!(["C2", "C10"]));
assert_eq!((c["quantity"].as_u64(), c["lcsc"].as_str(), c["package"].as_str()), (Some(2), Some("C1525"), Some("C_0402")));
assert_eq!(checks["missingMpn"], json!(["R1"]));
assert_eq!(checks["dnp"], json!(["R2"]));
assert_eq!(checks["excludedKinds"], json!(["H1"]));
assert_eq!(checks["notPurchased"], json!(["TP1"]));
assert_eq!(checks["jlcpcb"]["linesWithoutLcsc"][0]["designators"], json!(["R1"]));
let fs = files(&lines, &ps, 10);
let cpl = &fs.iter().find(|f| f.0 == "cpl-jlcpcb.csv").unwrap().1;
assert!(cpl.contains("C2,1.0000mm,-2.0000mm,Top,90") && !cpl.contains("R2,") && !cpl.contains("TP1,"), "{cpl}");
let mouser = &fs.iter().find(|f| f.0 == "bom-mouser.csv").unwrap().1;
assert!(mouser.contains(",GRM155,Murata,20,"), "{mouser}");
}
#[test]
fn shared_pad_centres_and_padless_parts_do_not_fail_assembly() {
// std #24: a FET whose drain pads are one land drawn as several pads with one centre, and a logo on the bottom
let st = json!({"components": [{"ref": "Q1", "side": "top"}, {"ref": "LOGO1", "side": "bottom"}],
"pads": [{"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "Q1", "x": 0.0, "y": 0.0, "drill": 0.0},
{"ref": "Q1", "x": 0.65, "y": 1.5, "drill": 0.0}, {"ref": "Q1", "x": 1.3, "y": 1.5, "drill": 0.0}]});
let fa = facts(&st);
assert_eq!(fa["finestPitch"], json!({"mm": 0.65, "ref": "Q1"}), "{fa}");
assert_eq!(fa["parts"], 1);
assert_eq!(fa["bottomSideParts"], json!([]));
}
#[test]
fn a_small_two_sided_board_needs_standard_in_a_panel() {
let st = json!({"components": [{"ref": "U1", "side": "top", "pattern": "QFN-20"}, {"ref": "C1", "side": "top", "pattern": "C_0402"}, {"ref": "R9", "side": "bottom", "pattern": "R_0603"}],
"pads": [{"ref": "U1", "x": 0.0, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 0.5, "y": 0.0, "drill": 0.0}, {"ref": "U1", "x": 1.0, "y": 0.0, "drill": 0.0},
{"ref": "C1", "x": 5.0, "y": 0.0, "drill": 0.0}, {"ref": "C1", "x": 6.0, "y": 0.0, "drill": 0.0},
{"ref": "R9", "x": 9.0, "y": 0.0, "drill": 0.0}, {"ref": "R9", "x": 10.5, "y": 0.0, "drill": 0.0}]});
let fa = facts(&st);
assert_eq!(fa["finestPitch"], json!({"mm": 0.5, "ref": "U1"}));
assert_eq!(fa["bottomSideParts"], json!(["R9"]));
assert_eq!(tier("economic", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);
assert_eq!(tier("standard", &fa, (40.0, 20.0), Some(1.6), Some(2), Some(0), false)["ok"], false);
assert_eq!(tier("standard", &fa, (90.0, 80.0), Some(1.6), Some(2), Some(0), true)["ok"], true);
}
#[test]
fn sheet_fields_merge_with_the_board() {
let xml = r#"<export><components><comp ref="C1"><value>100n</value><footprint>C:C_0402</footprint><fields><field name="MPN">GRM155</field></fields></comp>
<comp ref="R5"><value>1k</value><property name="dnp"/></comp></components><nets/></export>"#;
let nl = crate::netlist::parse_kicadxml(xml).unwrap();
let mut props = BTreeMap::new();
props.insert("Reference".to_string(), "C1".to_string());
props.insert("LCSC".to_string(), "C1525".to_string());
let board = vec![fab::Part { reference: "C1".into(), value: "100n".into(), footprint: "Capacitor_SMD:C_0402_1005Metric".into(), props, ..Default::default() },
fab::Part { reference: "J9".into(), value: "CONN".into(), ..Default::default() }];
let mut placed = HashMap::new();
placed.insert("C1".to_string(), (3.0, -4.0, 0.0, "top".to_string()));
let (ps, cross) = parts(Some(&nl), &board, &placed);
let c1 = ps.iter().find(|p| p.designator == "C1").unwrap();
assert_eq!((c1.mpn().as_str(), c1.get(LCSC).as_str(), c1.footprint.as_str()), ("GRM155", "C1525", "Capacitor_SMD:C_0402_1005Metric"));
assert!(ps.iter().find(|p| p.designator == "R5").unwrap().dnp);
assert_eq!(cross["sheetNotOnBoard"], json!(["R5"]));
assert_eq!(cross["boardNotOnSheet"], json!(["J9"]));
}
}