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.50 source (not yet released, needs signing): build_schematic and export_manufacturing find Altium's sheet and OutJo…
ee90f38
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
//! A project's manufacturing set (Gerbers, NC drill, pick and place, BOM) from ONE OutJob the bridge
//! writes for that project, composed from Altium's own OutJob templates on this machine (so the output
//! settings are the installed Altium's), every output on one GeneratedFiles medium that opens nothing.
use serde_json::{json, Value};
use crate::outputs;
/// INI as Altium writes it: [sections] of key=value lines, in order.
fn parse_ini(text: &str) -> Vec<(String, Vec<(String, String)>)> {
let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();
for line in text.lines() {
let l = line.trim_end_matches('\r');
if l.starts_with('[') && l.ends_with(']') {
out.push((l[1..l.len() - 1].to_string(), Vec::new()));
} else if let (Some(sec), Some((k, v))) = (out.last_mut(), l.split_once('=')) {
sec.1.push((k.to_string(), v.to_string()));
}
}
out
}
/// One output of a template's OutputGroup1 (by its OutputType), its keys renumbered to `to`.
fn take_output(ini: &[(String, Vec<(String, String)>)], kind: &str, to: usize) -> Option<Vec<(String, String)>> {
let g = &ini.iter().find(|(n, _)| n == "OutputGroup1")?.1;
let n = g.iter().find(|(k, v)| k.starts_with("OutputType") && v == kind)?.0["OutputType".len()..].to_string();
let mut out = Vec::new();
for (k, v) in g {
// OutputXxxN, OutputXxxN_..., ConfigurationN_..., PageOptionsN, PcbPrintPreferencesN
let renamed = ["OutputType", "OutputName", "OutputCategory", "OutputDocumentPath", "OutputVariantName", "OutputEnabled", "OutputDefault", "PageOptions", "PcbPrintPreferences", "Configuration"]
.iter().find_map(|p| {
let rest = k.strip_prefix(p)?;
let num: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
let tail = &rest[num.len()..];
// OutputEnabledN_OutputMediumM: only the medium flag is rewritten below
(num == n && (tail.is_empty() || tail.starts_with('_'))).then(|| format!("{p}{to}{tail}"))
});
if let Some(r) = renamed {
if r.starts_with(&format!("OutputEnabled{to}_OutputMedium")) { continue; }
out.push((r, v.clone()));
}
}
Some(out)
}
fn set(kv: &mut Vec<(String, String)>, k: &str, v: &str) {
match kv.iter_mut().find(|(a, _)| a == k) { Some(e) => e.1 = v.to_string(), None => kv.push((k.to_string(), v.to_string())) }
}
/// a `|`-separated record with one field replaced or added
fn field(rec: &str, k: &str, v: &str) -> String {
let mut parts: Vec<String> = rec.split('|').map(String::from).collect();
match parts.iter_mut().find(|p| p.split_once('=').map(|(a, _)| a == k).unwrap_or(false)) {
Some(p) => *p = format!("{k}={v}"),
None => parts.push(format!("{k}={v}")),
}
parts.join("|")
}
/// the project's manufacturing OutJob path (beside the project, named after it)
pub fn outjob_path(prj: &str) -> String {
let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
let stem = prj.rsplit(['\\', '/']).next().unwrap_or(prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
format!(r"{pdir}\{stem}.Manufacturing.OutJob")
}
/// Write the project's manufacturing OutJob (only when its text changes: an open document rewritten on
/// disk makes Altium ask to reload it). Answers its path.
pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> {
let (prj, pcb) = (prj.to_string(), pcb.to_string());
// Altium's own OutJob templates, from the running install's Public Documents folder
let read = |f: &str| crate::support::altium_public_file(&format!(r"OutputJobs\{f}"))
.and_then(|p| std::fs::read_to_string(&p).map_err(|e| format!("{p}: {e}"))).map(|t| parse_ini(&t));
let (fab, asm) = match (read("Fabrication.OutJob"), read("Assembly.OutJob")) {
(Ok(f), Ok(a)) => (f, a),
(f, a) => return Err(json!({"success": false, "errorCode": "no_templates", "error": format!("Altium's OutJob templates were not found: {:?} {:?}", f.err(), a.err())})),
};
let pcb_name = pcb.rsplit(['\\', '/']).next().unwrap_or(&pcb).to_string();
let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
let mut outputs: Vec<Vec<(String, String)>> = Vec::new();
let mut missing = Vec::new();
for (src, kind) in [(&fab, "Gerber"), (&fab, "NC Drill"), (&asm, "Pick Place"), (&asm, "BOM_PartType")] {
match take_output(src, kind, outputs.len() + 1) { Some(o) => outputs.push(o), None => missing.push(kind) }
}
if !missing.is_empty() { return Err(json!({"success": false, "errorCode": "template_output_missing", "error": format!("the templates have no {missing:?} output")})); }
for (i, o) in outputs.iter_mut().enumerate() {
let n = i + 1;
let kind = o.iter().find(|(k, _)| k == &format!("OutputType{n}")).map(|(_, v)| v.clone()).unwrap_or_default();
// the board for the PCB outputs; the BOM is the compiled project's
set(o, &format!("OutputDocumentPath{n}"), if kind == "BOM_PartType" { "" } else { &pcb_name });
set(o, &format!("OutputEnabled{n}"), "1");
set(o, &format!("OutputEnabled{n}_OutputMedium1"), &n.to_string());
if kind == "Pick Place" {
// a CSV in millimetres, what an assembly house loads
if let Some(e) = o.iter_mut().find(|(k, v)| k.starts_with(&format!("Configuration{n}_Item")) && v.contains("Record=PickPlaceView")) {
e.1 = field(&field(&field(&e.1, "GenerateCSVFormat", "True"), "GenerateTextFormat", "False"), "Units", "Metric");
}
}
if kind == "BOM_PartType" {
// BatchMode is the BOM's file format: 0 csv, 1 txt, 2 xlsx, 3 html, 4 xml, 6 pdf; 5 (the
// examples' value) wrote no file here (measured AD 26.11.1, 2026-10-15)
let cols = bom_columns.map(|a| a.iter().filter_map(|x| x.as_str()).map(|c| format!("{c}=100")).collect::<Vec<_>>().join("|"))
.unwrap_or_else(|| "Comment=100|Description=100|Designator=100|Footprint=100|Quantity=100|MPN=100|Manufacturer=100|Mouser Part #=100|DigiKey Part #=100|LCSC Part #=100".into());
o.retain(|(k, _)| !k.starts_with(&format!("Configuration{n}_")));
for (j, (name, item)) in [("General", "OpenExported=False|AddToProject=False|ForceFit=False|NotFitted=False|Database=False|IncludePCBData=False|ShowExportOptions=False|TemplateFilename=|BatchMode=0|FormWidth=1185|FormHeight=665|SupplierProdQty=1|SupplierAutoQty=False|SupplierUseCachedPricing=False|SupplierCurrency=<none>".to_string()),
("GroupOrder", "Comment=True|Footprint=True".to_string()),
("SortOrder", "Designator=Up|Comment=Up|Footprint=Up".to_string()),
("VisibleOrder", cols)].into_iter().enumerate() {
o.push((format!("Configuration{n}_Name{}", j + 1), name.to_string()));
o.push((format!("Configuration{n}_Item{}", j + 1), item));
}
}
}
let base = format!("Project Outputs for {stem}");
let mut t = String::from("[OutputJobFile]\r\nVersion=1.0\r\nCaption=Manufacturing\r\nDescription=Gerbers, NC drill, pick and place and BOM (written by the Altium bridge)\r\n\r\n[OutputGroup1]\r\nName=Manufacturing\r\nDescription=\r\nTargetOutputMedium=Manufacturing\r\nVariantName=[No Variations]\r\nVariantScope=0\r\nCurrentConfigurationName=\r\nOutputMedium1=Manufacturing\r\nOutputMedium1_Type=GeneratedFiles\r\n");
for o in &outputs { for (k, v) in o { t.push_str(&format!("{k}={v}\r\n")); } }
t.push_str(&format!("\r\n[PublishSettings]\r\nOutputFilePath1={pdir}\\{base}\\\r\nReleaseManaged1=1\r\nOutputBasePath1={base}\r\nOutputPathMedia1=\r\nOutputPathMediaValue1=\r\nOutputPathOutputer1=[Output Type]\r\nOutputPathOutputerPrefix1=\r\nOutputPathOutputerValue1=\r\nOutputFileName1=\r\nOutputFileNameMulti1=\r\nUseOutputNameForMulti1=1\r\nOutputFileNameSpecial1=\r\nOpenOutput1=0\r\n"));
t.push_str(&format!("\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1={pdir}\\{base}\\\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nOpenODBOutput1=0\r\nOpenGerberOutput1=0\r\nOpenNCDrillOutput1=0\r\nOpenIPCOutput1=0\r\nEnableReload1=0\r\n"));
let outjob = outjob_path(&prj);
if std::fs::read_to_string(&outjob).ok().as_deref() != Some(t.as_str()) {
if let Err(e) = std::fs::write(&outjob, &t) { return Err(json!({"success": false, "errorCode": "write_failed", "error": format!("could not write {outjob}: {e}")})); }
}
Ok(outjob)
}
/// altium_export_manufacturing {projectPath, pcbPath, bomColumns?, timeout?}
pub fn export_manufacturing(args: &Value) -> Value {
let st = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
let (prj, pcb) = (st("projectPath"), st("pcbPath"));
if prj.is_empty() || pcb.is_empty() {
return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) and pcbPath (.PcbDoc) are required",
"_hint": "altium_build_schematic writes the project; the board must be one of its documents."});
}
let outjob = match write_outjob(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array())) { Ok(p) => p, Err(e) => return e };
// an OutJob generates for the project it belongs to: one that is not in the project runs for Free
// Documents and writes nothing (measured 2026-10-15)
let oj_name = outjob.rsplit(['\\', '/']).next().unwrap_or(&outjob).to_lowercase();
let listed = std::fs::read_to_string(&prj).map(|t| t.lines().any(|l| l.to_lowercase().starts_with("documentpath=") && l.to_lowercase().ends_with(&oj_name))).unwrap_or(false);
if !listed {
return json!({"success": false, "errorCode": "outjob_not_in_project", "outjob": outjob,
"error": format!("{outjob} is not a document of {prj}: an OutJob outside the project writes nothing"),
"_hint": "Projects from altium_build_schematic list it. Otherwise add it in Altium (Project > Add Existing to Project) and run this again."});
}
// an OutJob already open as a FREE document stays one (its outputs went to 'Project Outputs for Free
// Documents', and a free OutJob cannot build the project's BOM, measured 2026-10-15): close it, so
// run_outjob opens it again inside its project
crate::agent::call("close_document", &[("path", outjob.clone())], None, std::time::Duration::from_secs(60));
// a fresh set: files from an earlier run (another BOM format, a removed layer) never pass as this one's
if args.get("clean").and_then(|v| v.as_bool()) != Some(false) {
let pdir = prj.rsplit_once(['\\', '/']).map(|(d, _)| d.to_string()).unwrap_or_default();
let stem = prj.rsplit(['\\', '/']).next().unwrap_or(&prj).rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_default();
let _ = std::fs::remove_dir_all(format!(r"{pdir}\Project Outputs for {stem}"));
}
let mut a = json!({"outjobPath": outjob, "projectPath": prj, "medium": "Manufacturing"});
if let Some(v) = args.get("timeout") { a["timeout"] = v.clone(); }
let mut r = outputs::run_outjob(&a);
r["outjob"] = json!(outjob);
r["_hint"] = json!("files[] are the manufacturing set under outputDir (Gerber, NC Drill, Pick Place, Bill of Materials folders); pull them with ab's pull_file and check them against the board before handing them over.");
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renumbers_one_output() {
let ini = parse_ini("[OutputGroup1]\nOutputType1=Gerber\nOutputName1=Gerber\nOutputEnabled1=1\nOutputEnabled1_OutputMedium3=1\nConfiguration1_Item1=a=b\nOutputType2=NC Drill\nOutputName2=NC\nConfiguration2_Item1=Record=DrillView\n");
let o = take_output(&ini, "NC Drill", 1).unwrap();
assert!(o.contains(&("OutputType1".into(), "NC Drill".into())));
assert!(o.contains(&("Configuration1_Item1".into(), "Record=DrillView".into())));
assert!(!o.iter().any(|(k, _)| k.contains("Medium")));
assert_eq!(field("A=1|Units=Imperial", "Units", "Metric"), "A=1|Units=Metric");
assert_eq!(field("A=1", "B", "2"), "A=1|B=2");
}
}