app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
main
Colby Knox
0.2.56 source (not yet released, needs signing): altium_bom + altium_sheet_params + the altium-bom CLI
22a91c1
12h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
//! The project BOM Altium itself does not give an AI: every sheet's placed parts with their
//! parameters, the board's placement, variants, grouped by part number, checked for what an order
//! needs, and written as the files distributors and assemblers take (generic, JLCPCB BOM + CPL,
//! Mouser and DigiKey BOM upload). `sheet_params` writes part numbers back onto the sheets.
use crate::support::{self, s, timeout_of};
use crate::agent;
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Parameter names that mean the same thing across libraries: Altium's own templates, the Adom
/// part import (`MPN`, `Mouser Part #`...), the wiki's Cloud Library export (`Manufacturer Part
/// Number`), Altium 365 supplier links (`Supplier Part Number 1`) and Fusion-style attributes.
const MPN: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer Part Number 1", "Mfr Part #", "Mfr. Part Number", "MFR PN", "MFR_PN", "MANUFACTURER_PART_NUMBER", "Part Number", "PartNumber", "Part_Number"];
const MFR: &[&str] = &["Manufacturer", "Manufacturer 1", "Manufacturer Name", "Mfr", "MANUFACTURER"];
const MOUSER: &[&str] = &["Mouser Part #", "Mouser Part Number", "Mouser PN", "MOUSER_PN"];
const DIGIKEY: &[&str] = &["DigiKey Part #", "Digi-Key Part Number", "DigiKey Part Number", "Digikey PN", "DIGIKEY_PN"];
const LCSC: &[&str] = &["LCSC Part #", "LCSC", "LCSC Part", "LCSC_PN", "JLCPCB Part #", "JLC Part #"];
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"];
/// ISch_Component.ComponentKind values that stay out of a BOM: Graphical, Net Tie (no BOM),
/// Standard (no BOM). Mechanical (1), Net Tie (in BOM, 3) and Jumper (6) are bought parts.
const NO_BOM_KINDS: &[i64] = &[2, 4, 5];
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: 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())
}
/// The project file: its schematic sheets and board (absolute), and its variants.
#[derive(Debug, Default)]
pub struct Project {
pub sheets: Vec<String>,
pub boards: Vec<String>,
/// variant description -> designator -> (kind, alternate part)
pub variants: Vec<(String, HashMap<String, (i64, String)>)>,
}
pub fn parse_project(text: &str, dir: &Path) -> Project {
let mut p = Project::default();
let mut section = String::new();
let mut current: Option<(String, HashMap<String, (i64, String)>)> = None;
for raw in text.lines() {
let line = raw.trim();
if line.starts_with('[') && line.ends_with(']') {
if let Some(v) = current.take() { p.variants.push(v); }
section = line[1..line.len() - 1].to_string();
if section.starts_with("ProjectVariant") { current = Some((String::new(), HashMap::new())); }
continue;
}
let Some((k, v)) = line.split_once('=') else { continue };
// [DocumentN], and [DeviceSheetN]: a managed device sheet the project pulled from its workspace
if (section.starts_with("Document") || section.starts_with("DeviceSheet")) && k == "DocumentPath" {
let path = if Path::new(v).is_absolute() || v.contains(':') { PathBuf::from(v) } else { dir.join(v.replace('\\', "/")) };
let full = path.to_string_lossy().replace('/', "\\");
let low = v.to_ascii_lowercase();
if low.ends_with(".schdoc") { p.sheets.push(full); } else if low.ends_with(".pcbdoc") { p.boards.push(full); }
} else if let Some((_, m)) = current.as_mut().filter(|_| k.starts_with("Variation") && k[9..].chars().all(|c| c.is_ascii_digit()) && k.len() > 9) {
// Variation1=Designator=R1|UniqueId=\ABCD|Kind=1|AlternatePart=
let f: HashMap<&str, &str> = v.split('|').filter_map(|x| x.split_once('=')).collect();
if let Some(d) = f.get("Designator") {
m.insert(d.to_ascii_uppercase(), (f.get("Kind").and_then(|x| x.parse().ok()).unwrap_or(0), f.get("AlternatePart").unwrap_or(&"").to_string()));
}
} else if let Some((name, _)) = current.as_mut().filter(|_| k == "Description") {
*name = v.to_string();
}
}
if let Some(v) = current.take() { p.variants.push(v); }
p
}
/// One placed 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,
pub sheet: String,
pub kind: i64,
pub params: Vec<(String, String)>,
pub footprint: String,
pub placed: Option<(f64, f64, f64, String)>,
pub dnp: bool,
pub alternate: String,
/// a workspace-managed part (Altium 365 / a content vault): its item GUID and source library
pub item_guid: String,
pub source_lib: String,
}
impl Part {
fn get(&self, names: &[&str]) -> String { param(&self.params, names) }
pub 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 } }
}
const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];
/// Group the fitted parts: one line per manufacturer part number, else per comment + 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 && !NO_BOM_KINDS.contains(&p.kind)) {
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})); }
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.item_guid.is_empty()));
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));
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 && !NO_BOM_KINDS.contains(&p.kind)).collect();
let missing = |names: &[&str]| -> Vec<String> {
let mut v: Vec<String> = fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect();
v.sort_by_key(|d| natural_key(d)); v
};
let mut dnp: Vec<String> = parts.iter().filter(|p| p.dnp).map(|p| p.designator.clone()).collect();
dnp.sort_by_key(|d| natural_key(d));
let mut excluded: Vec<String> = parts.iter().filter(|p| !p.dnp && NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect();
excluded.sort_by_key(|d| natural_key(d));
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,
});
(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). `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 column names, mm, Top/Bottom
let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];
for l in lines { 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 && !NO_BOM_KINDS.contains(&p.kind) && 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)));
// distributor BOM tools (Mouser BOM Tool, DigiKey myLists / BOM Manager) 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| !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_parts(sheet: &str, timeout: Duration) -> Result<(String, Vec<Value>), Value> {
if let Some(e) = support::must_open(sheet, 60) { return Err(e); }
// only the values a BOM reads: other parameters can hold anything (an example IC's preview-image blobs
// took minutes to read), so their values are never fetched
let mut names: Vec<&str> = [MPN, MFR, MOUSER, DIGIKEY, LCSC, VALUE, PACKAGE, DNP, DESCRIPTION, &["Fitted"]].concat();
names.sort(); names.dedup();
let filter = format!("|{}|", names.iter().map(|n| n.to_ascii_uppercase()).collect::<Vec<_>>().join("|"));
let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "1".into()), ("limit", "5000".into()), ("paramFilter", filter)], None, timeout);
match r.get("data") {
Some(d) if d["ok"].as_bool() == Some(true) => Ok((st(&d["document"]), d["components"].as_array().cloned().unwrap_or_default())),
_ => Err(r),
}
}
/// altium_bom {projectPath, variant?, outputDir?, boards?, write?}
pub fn bom(args: &Value) -> Value {
if let Some(g) = support::need_licensed() { return g; }
let t0 = support::now();
let Some(prj) = s(args, "projectPath") else {
return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required", "_hint": "Pass the project file; the BOM reads every schematic sheet it lists and its board."});
};
let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) };
let dir = Path::new(&prj).parent().map(Path::to_path_buf).unwrap_or_default();
let project = parse_project(&text, &dir);
if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no .SchDoc", "projectPath": prj}); }
let timeout = timeout_of(args, 120);
// the variant: by its description, or none
let want_variant = s(args, "variant");
let variant = match &want_variant {
Some(v) => match project.variants.iter().find(|(n, _)| n.eq_ignore_ascii_case(v)) {
Some(x) => Some(x),
None => return json!({"success": false, "errorCode": "variant_not_found", "error": format!("no variant named {v}"), "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>()}),
},
None => None,
};
let mut parts: BTreeMap<String, Part> = BTreeMap::new();
let mut sheets_read = Vec::new();
// altium_progress shows which sheet a long read is on (a large project reads dozens)
crate::progress::begin("bom", "warm", "reading the schematic sheets", "bridge");
let n_sheets = project.sheets.len();
for (i, sheet) in project.sheets.iter().enumerate() {
let name = Path::new(&sheet.replace('\\', "/")).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
crate::progress::step("bom", &format!("sheet {} of {n_sheets}: {name}", i + 1), Some((i * 90 / n_sheets.max(1)) as i64), None, "bridge");
let (doc, comps) = match sheet_parts(sheet, timeout) { Ok(x) => x, Err(e) => { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "sheet_read_failed", "sheet": sheet, "reply": e}) } };
sheets_read.push(json!({"sheet": doc, "components": comps.len()}));
for c in comps {
let d = st(&c["designator"]);
if d.is_empty() || d.ends_with('?') { continue; }
let params: Vec<(String, String)> = c["params"].as_array().cloned().unwrap_or_default().iter().filter(|p| p.get("value").is_some()).map(|p| (st(&p["name"]), st(&p["value"]))).collect();
// a multi-part component appears once per part: keep the first, add any parameter it lacks
let e = parts.entry(d.to_ascii_uppercase()).or_insert_with(|| Part {
designator: d.clone(), comment: st(&c["comment"]), description: st(&c["description"]), lib_ref: st(&c["libReference"]),
sheet: doc.clone(), kind: c["kind"].as_i64().unwrap_or(0), item_guid: st(&c["itemGuid"]), source_lib: st(&c["sourceLibrary"]), ..Default::default() });
for (k, v) in params { if !e.params.iter().any(|(k2, _)| k2.eq_ignore_ascii_case(&k)) { e.params.push((k, v)); } }
}
}
// the board: footprint and placement of each part
let mut board_only = Vec::new();
let board = s(args, "pcbPath").or_else(|| project.boards.first().cloned());
let mut board_read = Value::Null;
if let Some(b) = &board {
crate::progress::step("bom", "reading the board's placement", Some(92), None, "bridge");
if let Some(e) = support::must_open(b, 60) { crate::progress::end("bom", "bridge"); return json!({"success": false, "errorCode": "board_open_failed", "board": b, "reply": e}); }
// the lean placement read: a full routing_state of a large board (MiniPC) ran past 10 minutes
let r = agent::call("bom_placement", &[], None, timeout);
let Some(comps) = r.get("data").filter(|d| d["ok"].as_bool() == Some(true)).and_then(|d| d["comps"].as_array()).cloned() else {
crate::progress::end("bom", "bridge");
return json!({"success": false, "errorCode": "board_read_failed", "board": b, "reply": r});
};
board_read = json!({"board": b, "components": comps.len()});
for c in comps {
// [designator, pattern, x, y (raw from the origin, 10000 per mil), rotation (millidegrees), bottom]
let d = st(&c[0]);
let mm = |v: &Value| (v.as_f64().unwrap_or(0.0) / 10000.0 * 0.0254 * 10000.0).round() / 10000.0;
let placed = Some((mm(&c[2]), mm(&c[3]), c[4].as_f64().unwrap_or(0.0) / 1000.0, if c[5].as_bool() == Some(true) { "bottom".to_string() } else { "top".to_string() }));
match parts.get_mut(&d.to_ascii_uppercase()) {
Some(p) => { p.footprint = st(&c[1]); p.placed = placed; }
None => board_only.push(d),
}
}
}
let mut not_on_board: Vec<String> = if board.is_some() { parts.values().filter(|p| p.placed.is_none() && !NO_BOM_KINDS.contains(&p.kind)).map(|p| p.designator.clone()).collect() } else { vec![] };
not_on_board.sort_by_key(|d| natural_key(d));
board_only.sort_by_key(|d| natural_key(d));
for p in parts.values_mut() {
if truthy(&p.get(DNP)) { p.dnp = true; }
if p.get(&["Fitted"]).eq_ignore_ascii_case("false") { p.dnp = true; }
if let Some((_, m)) = variant {
if let Some((kind, alt)) = m.get(&p.designator.to_ascii_uppercase()) {
if *kind == 1 { p.dnp = true; } else if !alt.is_empty() { p.alternate = alt.clone(); }
}
}
}
crate::progress::end("bom", "bridge");
let list: Vec<Part> = parts.values().cloned().collect();
let (lines, mut checks) = group(&list);
checks["sheetNotOnBoard"] = json!(not_on_board);
checks["boardNotOnSheet"] = json!(board_only);
let alternates: Vec<Value> = list.iter().filter(|p| !p.alternate.is_empty()).map(|p| json!({"designator": p.designator, "alternatePart": p.alternate})).collect();
let fitted = list.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).count();
let boards = args.get("boards").and_then(|v| v.as_u64()).unwrap_or(1);
let mut written = Vec::new();
if args.get("write").and_then(|b| b.as_bool()) != Some(false) {
let out_dir = s(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| dir.join("BOM"));
if let Err(e) = std::fs::create_dir_all(&out_dir) { return json!({"success": false, "errorCode": "output_dir", "error": format!("{}: {e}", out_dir.display())}); }
for (name, body) in files(&lines, &list, boards) {
let p = out_dir.join(&name);
if let Err(e) = std::fs::write(&p, body) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{}: {e}", p.display())}); }
written.push(p.to_string_lossy().to_string());
}
let j = out_dir.join("bom.json");
let _ = std::fs::write(&j, serde_json::to_string_pretty(&json!({"project": prj, "variant": want_variant, "lines": lines, "checks": checks})).unwrap_or_default());
written.push(j.to_string_lossy().to_string());
}
let missing = checks["missingMpn"].as_array().map(|a| a.len()).unwrap_or(0);
let gaps: Vec<String> = ["mouser", "digikey", "lcsc"].iter().filter_map(|k| { let n = checks["missingPartNumbers"][k].as_array().map(|a| a.len()).unwrap_or(0); (n > 0).then(|| format!("{n} without a {k} number")) }).collect();
let mut hint = format!("{} lines, {fitted} fitted parts{}. ", lines.len(), if checks["dnp"].as_array().map_or(true, |a| a.is_empty()) { String::new() } else { format!(", {} DNP", checks["dnp"].as_array().unwrap().len()) });
if missing > 0 { hint.push_str(&format!("{missing} part(s) have no MPN: find them (adom-parts-search search <value> <package>) and write them back with altium_sheet_params. ")); }
if !gaps.is_empty() { hint.push_str(&format!("Distributor numbers: {}; look them up by MPN (adom-parts-search search <MPN>) and write them back with altium_sheet_params. ", gaps.join(", "))); }
if checks["inconsistent"].as_array().map_or(false, |a| !a.is_empty()) { hint.push_str("Some lines mix values for one part number (checks.inconsistent): fix the sheets so every part of a line agrees. "); }
hint.push_str("Price and stock the lines with adom-parts-search / adom-mouser part <MPN>; bom-mouser.csv and bom-digikey.csv upload to those sites' BOM tools; bom-jlcpcb.csv + cpl-jlcpcb.csv go to JLCPCB assembly (check rotations on their preview).");
json!({"success": true, "projectPath": prj, "variant": want_variant, "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
"sheets": sheets_read, "board": board_read, "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks,
"alternates": alternates, "boards": boards, "files": written, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background", "_hint": hint})
}
/// altium_sheet_params {sheet?, designators, params, save?}: write parameters onto placed parts.
/// Without a sheet, every sheet of projectPath is tried and each designator lands where it lives.
pub fn sheet_params(args: &Value) -> Value {
if let Some(g) = support::need_licensed() { return g; }
let designators: Vec<String> = match args.get("designators") {
Some(Value::Array(a)) => a.iter().map(st).collect(),
Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
_ => vec![],
};
let params: Vec<String> = match args.get("params") {
Some(Value::Object(o)) => o.iter().map(|(k, v)| format!("{}={}", k.trim(), st(v).replace(';', ","))).collect(),
Some(Value::String(t)) => t.split(';').map(str::to_string).filter(|x| x.contains('=')).collect(),
_ => vec![],
};
if designators.is_empty() || params.is_empty() {
return json!({"success": false, "errorCode": "bad_args", "error": "designators and params are required", "_hint": "designators: [\"R1\",\"R2\"]; params: {\"MPN\": \"...\", \"Manufacturer\": \"...\", \"Mouser Part #\": \"...\"}"});
}
if params.iter().any(|p| p.contains('\n') || p.contains('\r')) { return json!({"success": false, "errorCode": "bad_args", "error": "parameter values may not contain line breaks"}); }
let sheets: Vec<String> = match (s(args, "sheet"), s(args, "projectPath")) {
(Some(sh), _) => vec![sh],
(None, Some(prj)) => {
let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();
parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new("."))).sheets
}
_ => return json!({"success": false, "errorCode": "bad_args", "error": "sheet (.SchDoc) or projectPath (.PrjPcb) is required"}),
};
let mut done = Vec::new();
let mut left: Vec<String> = designators.clone();
let mut per_sheet = Vec::new();
for sheet in &sheets {
if left.is_empty() { break; }
if let Some(e) = support::must_open(sheet, 60) { return json!({"success": false, "errorCode": "sheet_open_failed", "sheet": sheet, "reply": e}); }
let r = agent::call("sheet_params", &[("designators", left.join(",")), ("params", params.join(";"))], None, timeout_of(args, 90));
let d = r.get("data").cloned().unwrap_or(Value::Null);
if d["ok"].as_bool() != Some(true) { return json!({"success": false, "errorCode": d["errorCode"].as_str().unwrap_or("sheet_params_failed"), "error": d["error"], "sheet": sheet, "reply": r}); }
let here: Vec<String> = d["designators"].as_array().cloned().unwrap_or_default().iter().map(st).collect();
if !here.is_empty() {
if args.get("save").and_then(|b| b.as_bool()) != Some(false) {
let sv = crate::surfaces::save(&json!({"filePath": sheet}));
if sv["success"].as_bool() == Some(false) { return json!({"success": false, "errorCode": "save_failed", "sheet": sheet, "reply": sv}); }
}
per_sheet.push(json!({"sheet": sheet, "designators": here, "paramsSet": d["paramsSet"]}));
left.retain(|x| !here.iter().any(|h| h.eq_ignore_ascii_case(x)));
done.extend(here);
}
}
json!({"success": left.is_empty(), "errorCode": if left.is_empty() { Value::Null } else { json!("designator_not_found") }, "updated": done, "notFound": left, "sheets": per_sheet,
"_hint": if left.is_empty() { "Written on the sheets (saved unless save:false). The board's components pick the parameters up with Design > Update PCB; the BOM reads the sheets, so altium_bom shows them now." } else { "Some designators are on no sheet of the project: check the names (altium_bom lists every designator)." }})
}
#[cfg(test)]
mod tests {
use super::*;
const PRJ: &str = "[Design]\r\nVersion=1.0\r\n\r\n[Document1]\r\nDocumentPath=Power.SchDoc\r\n\r\n[Document2]\r\nDocumentPath=Sub\\IO.SchDoc\r\n\r\n[Document3]\r\nDocumentPath=Board.PcbDoc\r\n\r\n[DeviceSheet1]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.SchDoc\r\n\r\n[DeviceSheet2]\r\nDocumentPath=Managed\\Sheets\\AB\\Osc.Harness\r\n\r\n[ProjectVariant1]\r\nDescription=Lite\r\nVariationCount=2\r\nVariation1=Designator=R2|UniqueId=\\ABC|Kind=1|AlternatePart=\r\nVariation2=Designator=U1|UniqueId=\\DEF|Kind=2|AlternatePart=TPS54302\r\n";
fn part(d: &str, params: &[(&str, &str)], fp: &str) -> Part {
Part { designator: d.into(), comment: "c".into(), footprint: fp.into(), params: params.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(), placed: Some((1.0, 2.0, 90.0, "top".into())), ..Default::default() }
}
#[test]
fn the_project_file_gives_sheets_board_and_variants() {
let p = parse_project(PRJ, Path::new("C:/proj"));
assert_eq!(p.sheets.len(), 3, "two sheets and a device sheet (its harness is not a sheet)");
assert!(p.sheets[1].ends_with("Sub\\IO.SchDoc"), "{:?}", p.sheets);
assert!(p.sheets[2].ends_with("Managed\\Sheets\\AB\\Osc.SchDoc"), "{:?}", p.sheets);
assert_eq!(p.boards.len(), 1);
assert_eq!(p.variants.len(), 1);
assert_eq!(p.variants[0].0, "Lite");
assert_eq!(p.variants[0].1["R2"].0, 1);
assert_eq!(p.variants[0].1["U1"], (2, "TPS54302".to_string()));
}
#[test]
fn lines_group_by_mpn_across_parameter_names() {
let parts = vec![
part("R10", &[("MPN", "RC0402-10K"), ("Manufacturer", "Yageo"), ("Mouser Part #", "603-RC0402")], "R0402"),
part("R2", &[("Manufacturer Part Number", "RC0402-10K"), ("Manufacturer", "Yageo")], "R0402"),
part("C1", &[("Value", "100n")], "C0402"),
part("C2", &[("Value", "100n")], "C0402"),
part("TP1", &[("DNP", "yes")], "TP"),
];
let mut parts = parts;
parts[4].dnp = true;
let (lines, checks) = group(&parts);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["designators"], json!(["C1", "C2"]));
assert_eq!(lines[1]["designators"], json!(["R2", "R10"]), "natural order");
assert_eq!(lines[1]["mpn"], json!("RC0402-10K"));
assert_eq!(lines[1]["mouser"], json!("603-RC0402"));
assert_eq!(checks["missingMpn"], json!(["C1", "C2"]));
assert_eq!(checks["missingPartNumbers"]["mouser"], json!(["C1", "C2", "R2"]));
assert_eq!(checks["dnp"], json!(["TP1"]));
assert!(checks["inconsistent"].as_array().unwrap().is_empty());
}
#[test]
fn a_line_with_two_footprints_is_inconsistent() {
let parts = vec![part("R1", &[("MPN", "X")], "R0402"), part("R2", &[("MPN", "X")], "R0603")];
let (_, checks) = group(&parts);
assert_eq!(checks["inconsistent"][0]["field"], json!("footprint"));
}
#[test]
fn no_bom_kinds_stay_out() {
let mut logo = part("LOGO1", &[], "");
logo.kind = 2;
let (lines, checks) = group(&[part("R1", &[("MPN", "X")], "R0402"), logo]);
assert_eq!(lines.len(), 1);
assert_eq!(checks["excludedKinds"], json!(["LOGO1"]));
}
#[test]
fn files_carry_the_columns_each_site_takes() {
let parts = vec![part("U1", &[("MPN", "TPS54202DDCR"), ("Manufacturer", "TI"), ("LCSC Part #", "C191884"), ("Mouser Part #", "595-TPS54202DDCR")], "SOT-23-6")];
let (lines, _) = group(&parts);
let f: HashMap<String, String> = files(&lines, &parts, 5).into_iter().collect();
assert!(f["bom-jlcpcb.csv"].starts_with("Comment,Designator,Footprint,LCSC Part #\r\n"));
assert!(f["bom-jlcpcb.csv"].contains("U1,SOT-23-6,C191884"));
assert!(f["cpl-jlcpcb.csv"].contains("U1,1.0000mm,2.0000mm,Top,90"));
assert!(f["bom-mouser.csv"].contains("595-TPS54202DDCR,TPS54202DDCR,TI,5,U1"), "{}", f["bom-mouser.csv"]);
assert!(f["bom.csv"].lines().nth(1).unwrap().starts_with("1,1,U1,"));
}
#[test]
fn csv_quotes_what_needs_it() {
assert_eq!(csv_cell("R1,R2"), "\"R1,R2\"");
assert_eq!(csv_cell("10\" cable"), "\"10\"\" cable\"");
assert_eq!(csv_cell("plain"), "plain");
}
}