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.57 source (not yet released, needs signing): schematic editing (sch_place_part, sch_connect, sch_edit, annotate, do…
93b3032
10h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
//! Outputs and project ops: compile/ERC, OutJobs (gerber/BOM), STEP export via Barrett's
//! AdomBridge extension, the DRC modal dance, and the -R script lanes.
use crate::support::{self, s, s_or, timeout_of};
use crate::{ab, agent, dialogs, instance, lifecycle, shot};
use serde_json::{json, Value};
use std::time::Duration;
fn ensure_project(args: &Value) -> (String, Value) {
let path = s(args, "projectPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_project);
let r = agent::call("open", &[("path", path.clone())], None, timeout_of(args, 60));
(path, r)
}
pub fn compile(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let (path, r) = ensure_project(args);
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
let c = agent::call("compile",
&[("objectKind", s_or(args, "objectKind", "Project")), ("limit", s_or(args, "limit", "200"))],
None, timeout_of(args, 120));
if c.get("success").and_then(|b| b.as_bool()) != Some(true) {
return c;
}
let data = c.get("data").cloned().unwrap_or(json!({}));
let msgs = data.get("messages").and_then(|m| m.as_array()).cloned().unwrap_or_default();
let mut by_class: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
for m in &msgs {
let k = m.get("class").and_then(|c| c.as_str()).unwrap_or("?").to_string();
*by_class.entry(k).or_default() += 1;
}
json!({"success": true, "project": path, "messageCount": data.get("messageCount"),
"byClass": by_class, "messages": msgs, "elapsedSec": support::elapsed_round(t0),
"windowEtiquette": "background",
"_hint": "messages[] is Altium's Messages panel after Compile (ERC + connectivity). Zero messages = clean."})
}
pub fn run_outjob(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let outjob = s(args, "outjobPath").or_else(|| s(args, "filePath")).unwrap_or_else(support::demo_outjob_fab);
// Refuse a missing OutJob BY NAME. The agent answered ok:false with no error text, so the caller
// saw success:false and nothing else (found by the verb battery, 2026-09-21).
if !std::path::Path::new(&outjob).is_file() {
return json!({"success": false, "errorCode": "outjob_not_found", "outjobPath": outjob,
"error": format!("no OutJob file at {outjob}"),
"_hint": "Pass outjobPath (an absolute path to a .OutJob on THIS machine) and projectPath. With no outjobPath the verb runs Altium's bundled demo OutJob, which OPENS what it generates on the user's screen (a spreadsheet, a PDF): fine for 'show me', wrong for unattended work."});
}
let medium = s_or(args, "medium", "Folder Structure");
let proj = s(args, "projectPath")
.or_else(|| if outjob.starts_with(support::EXAMPLES_DIR) { Some(support::demo_project()) } else { None });
if let Some(p) = &proj {
let r = support::agent_open(p, 60);
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
}
// Altium stops an OutJob on a board with health-check findings (shelved or modified polygons, ...) with a
// modal the agent cannot answer while it waits: a watcher answers it (Generate Outputs, or Cancel with
// healthCheck:"stop") and the reply says what it found (measured 2026-10-21 on a board after a change order)
let stop = s_or(args, "healthCheck", "continue") == "stop";
let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let seen: std::sync::Arc<std::sync::Mutex<Vec<Value>>> = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let watcher = {
let (done, seen) = (done.clone(), seen.clone());
std::thread::spawn(move || {
while !done.load(std::sync::atomic::Ordering::Relaxed) {
instance::invalidate_snapshot();
if let Some(w) = instance::altium_windows().into_iter().find(|w| w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("health check")).unwrap_or(false)) {
let h = w.get("hwnd").cloned().unwrap_or(Value::Null);
let tree = ab::call("desktop_ui_tree", json!({"hwnd": h, "maxDepth": 12, "maxNodes": 200, "roleFilter": ["text"]}), &[], 20);
let mut texts = Vec::new();
fn walk(n: &Value, out: &mut Vec<String>) {
if let Some(s) = n.get("name").and_then(|v| v.as_str()) { if !s.trim().is_empty() { out.push(s.trim().to_string()); } }
for c in n.get("children").and_then(|c| c.as_array()).into_iter().flatten() { walk(c, out); }
}
let root = tree.get("root").cloned().or_else(|| tree.get("output").and_then(|o| o.as_str()).and_then(|t| serde_json::from_str::<Value>(t).ok()).and_then(|v| v.get("root").cloned())).unwrap_or(Value::Null);
walk(&root, &mut texts);
let button = if stop { "Cancel" } else { "Generate Outputs" };
let c = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": button, "role": "button"}), &[], 20);
seen.lock().unwrap().push(json!({"dialog": w.get("title"), "text": texts, "answered": button, "clicked": c.get("success")}));
std::thread::sleep(Duration::from_secs(2));
}
std::thread::sleep(Duration::from_secs(1));
}
})
};
let o = agent::call("outjob",
&[("path", outjob.clone()), ("medium", medium.clone()), ("objectKind", s_or(args, "objectKind", "OutputBatch"))],
None, timeout_of(args, 300));
done.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = watcher.join();
let health: Vec<Value> = seen.lock().unwrap().clone();
let mut dl = dialogs::scan_scoped(true);
shot::attach_dialog_shots(&mut dl);
// what the OutJob wrote under "Project Outputs for <project>"
let base = std::path::Path::new(proj.as_deref().unwrap_or(&outjob));
let stem = base.file_stem().map(|q| q.to_string_lossy().to_string()).unwrap_or_default();
let pdir = base.parent().map(|p| p.join(format!("Project Outputs for {stem}")));
let (out_dir, files) = match pdir {
Some(d) if d.exists() => {
let mut files = Vec::new();
let mut stack = vec![d.clone()];
while let Some(q) = stack.pop() {
if let Ok(rd) = std::fs::read_dir(&q) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if let Ok(rel) = p.strip_prefix(&d) {
files.push(rel.to_string_lossy().to_string());
}
}
}
}
files.sort();
files.truncate(60);
(Some(d.to_string_lossy().to_string()), files)
}
_ => (None, Vec::new()),
};
let ok = o.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();
json!({"success": ok, "outjob": outjob, "medium": medium, "agent": o.get("data"),
"outputDir": out_dir, "files": files, "dialogs": dl, "healthCheck": health,
"elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",
"_hint": "files[] is what the OutJob wrote under 'Project Outputs for <project>'. A dialog means a medium needed a decision (printer/PDF path) — read dialogs[].shot."})
}
pub fn export_gerber(args: &Value) -> Value {
let mut a = args.clone();
if a.get("outjobPath").is_none() {
a["outjobPath"] = json!(support::demo_outjob_fab());
}
if a.get("medium").is_none() {
a["medium"] = json!("Folder Structure");
}
let mut r = run_outjob(&a);
r["surface"] = json!("gerber");
r
}
pub fn export_bom(args: &Value) -> Value {
let mut a = args.clone();
if a.get("outjobPath").is_none() {
a["outjobPath"] = json!(support::demo_outjob_asm());
}
if a.get("medium").is_none() {
a["medium"] = json!("Folder Structure");
}
let mut r = run_outjob(&a);
r["surface"] = json!("bom");
r
}
// ---- the AdomBridge .NET extension (Barrett's) — parameterized processes -----
/// SPACE-FREE: Altium's -R parser splits on whitespace.
const EXT_REQ_DIR: &str = r"C:\Users\Public\AdomAltium";
pub const EXT_STATUS_NOTE: &str = "MEASURED 2026-08-29 on AD 26.9.1: the AdomBridge server never loads into the RUNNING instance — `X2 -RAdomBridge:*` helpers wait on Altium's startup mutex ~65-90 s and then host the server themselves, and RunProcess('AdomBridge:Ping') from inside the agent is silently ignored (unknown process). It also coincided with a 'Could not start Vault Explorer' warning at startup, so it was unregistered again. Keep the verbs; they fail loudly until the extension is rebuilt/verified for AD 26.";
fn extension_installed() -> bool {
let base = std::path::Path::new(r"C:\ProgramData\Altium");
let Ok(rd) = std::fs::read_dir(base) else { return false };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with("Altium Designer")
&& e.path().join("Extensions").join("AdomBridge").join("AdomBridge.dll").exists()
{
return true;
}
}
false
}
/// Extension directory shipped beside the exe (release packs `extension/` next to it).
fn ext_dir() -> std::path::PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("extension")))
.unwrap_or_else(|| std::path::PathBuf::from("extension"))
}
/// X2.EXE -RAdomBridge:<Process>(Req=<file>) with the parameters in a request file at a
/// space-free path. The extension writes its result file; only the filesystem is proof.
fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout: Duration) -> Value {
if !extension_installed() {
return json!({"success": false, "errorCode": "extension_not_installed",
"_hint": "altium_install_extension (Altium closed), restart Altium, retry."});
}
let exe = match lifecycle::x2_exe_pub() {
Some(e) => e,
None => return support::not_installed(),
};
let _ = std::fs::create_dir_all(EXT_REQ_DIR);
let req = format!("{EXT_REQ_DIR}\\{}_req.txt", process.to_lowercase());
let res = format!("{EXT_REQ_DIR}\\{result_file}");
let _ = std::fs::remove_file(&res);
if let Err(e) = std::fs::write(&req, format!("{}\n", req_lines.join("\n"))) {
return json!({"success": false, "error": format!("cannot write request file: {e}")});
}
let mut child = match lifecycle::spawn_helper_pub(&exe, &format!("-RAdomBridge:{process}(Req={req})")) {
Ok(c) => c,
Err(e) => return json!({"success": false, "error": format!("could not launch X2 helper: {e}")}),
};
let t0 = std::time::Instant::now();
while t0.elapsed() < timeout {
if std::path::Path::new(&res).exists() {
std::thread::sleep(Duration::from_millis(300));
let body = std::fs::read_to_string(&res).unwrap_or_default();
return json!({"success": true, "result": body, "elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0});
}
std::thread::sleep(Duration::from_millis(500));
}
let _ = child.kill();
let _ = child.wait();
json!({"success": false, "errorCode": "extension_timeout",
"elapsedSec": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0,
"_hint": format!("No result file. {EXT_STATUS_NOTE}")})
}
/// Headless STEP export. Default lane `outjob`: Altium's own ExportSTEP output, run from a throwaway
/// project in %TEMP% that points at the board (measured on AD 26.9.1, 2026-10-09: no dialog, no click,
/// the file is written in ~5 s). Lane `extension` is Barrett's AdomBridge:ExportStep, kept for when the
/// .NET extension loads again.
pub fn export_step(args: &Value) -> Value {
match s_or(args, "lane", "outjob").as_str() {
"extension" => export_step_extension(args),
_ => export_step_outjob(args),
}
}
/// ComponentSuffixType in Altium's ExportSTEPView record, measured 2026-10-09 by exporting the same board
/// four times: the component (PRODUCT) name is always the DESIGNATOR, the type only picks the suffix.
/// 0 = none ("J1"), 1 = board name ("J1MpinTest"), 2 = `suffix` ("J1_SFX"), 3 = random id ("J1_ID_LENSEEVK").
fn step_suffix_type(name: &str) -> Option<u8> {
match name.to_ascii_lowercase().as_str() {
"none" | "0" => Some(0),
"board" | "1" => Some(1),
"custom" | "2" => Some(2),
"id" | "3" => Some(3),
_ => None,
}
}
fn step_outjob_text(args: &Value) -> Result<String, String> {
let b = |k: &str, d: bool| args.get(k).and_then(|v| v.as_bool()).unwrap_or(d);
let tf = |v: bool| if v { "True" } else { "False" };
let holes = b("holes", true);
let suffix_kind = s_or(args, "suffixType", "none");
let suffix_type = step_suffix_type(&suffix_kind).ok_or_else(|| format!("suffixType must be none, board, custom or id (got {suffix_kind:?})"))?;
let suffix = s(args, "suffix").unwrap_or_default();
if suffix_type == 2 && suffix.is_empty() {
return Err("suffixType custom needs a non-empty suffix".into());
}
if suffix.contains('|') || suffix.contains('\n') || suffix.contains('\r') {
return Err("suffix may not contain '|' or a line break".into());
}
let n = |k: &str, d: u64| args.get(k).and_then(|v| v.as_u64()).unwrap_or(d);
// The record Altium's own Assembly.OutJob template ships, with pad holes on by default.
let item = format!(
"Record=ExportSTEPView|ExportComponentOptions={}|ExportModelsOption={}|ExportHolesOption=0|CanSelectPrimitives=False|IncludeMechanicalPadHoles={}|IncludeElectricalPadHoles={}|IncludeFreePadHoles={}|ExportFoldedBoard={}|ExportFoldedBoardRate=100|ComponentSuffixType={}|ComponentSuffix={}|ExportCopperOption={}|ExportCopperLayer=1|ExportPadAndViaBarrelsOnly=False|IgnoreBoardCopperLayerColors=False|ExportAsSinglePart={}",
n("componentsOption", 0), n("modelsOption", 2), tf(holes), tf(holes), tf(holes), tf(b("folded", false)),
suffix_type, if suffix.is_empty() { " ".to_string() } else { suffix }, n("copperOption", 0), tf(b("singlePart", false)));
let lines = [
"[OutputJobFile]", "Version=1.0", "Caption=", "Description=Adom altium_export_step", "",
"[OutputGroup1]", "Name=", "Description=", "TargetOutputMedium=STEP", "VariantName=[No Variations]", "VariantScope=0",
"CurrentConfigurationName=", "TargetPrinter=",
"PrinterOptions=Record=PrinterOptions|Copies=1|Duplex=1|TrueTypeOptions=3|Collate=1|PrintJobKind=1|PrintWhat=1",
"OutputMedium1=STEP", "OutputMedium1_Type=GeneratedFiles",
"OutputType1=ExportSTEP", "OutputName1=STEP", "OutputCategory1=Export", "OutputDocumentPath1=", "OutputVariantName1=",
"OutputEnabled1=1", "OutputEnabled1_OutputMedium1=1", "OutputDefault1=0",
"Configuration1_Name1=OutputConfigurationParameter1",
];
let mut out = lines.join("\r\n");
out.push_str(&format!("\r\nConfiguration1_Item1={item}\r\n\r\n[PublishSettings]\r\n\r\n[GeneratedFilesSettings]\r\nRelativeOutputPath1=\r\nOpenOutputs1=0\r\nAddToProject1=0\r\nTimestampFolder1=0\r\nUseOutputName1=0\r\nEnableReload1=0\r\n"));
Ok(out)
}
/// PRODUCT names in a STEP file, so the caller sees how parts are named without opening it.
fn step_product_names(path: &std::path::Path) -> Vec<String> {
let text = std::fs::read(path).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();
let mut names: Vec<String> = Vec::new();
for part in text.split("PRODUCT('").skip(1) {
if let Some(end) = part.find('\'') {
let n = part[..end].to_string();
if !n.is_empty() && !n.starts_with("Open CASCADE") && !names.contains(&n) {
names.push(n);
}
}
if names.len() >= 200 {
break;
}
}
names
}
/// Split a STEP entity's argument list at its top-level commas (outside strings and nested lists).
fn step_args(body: &str) -> Vec<&str> {
let (mut out, mut depth, mut quoted, mut start) = (Vec::new(), 0i32, false, 0usize);
for (i, c) in body.char_indices() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => depth -= 1,
',' if !quoted && depth == 0 => {
out.push(body[start..i].trim());
start = i + 1;
}
_ => {}
}
}
out.push(body[start..].trim());
out
}
/// Name each component occurrence of the board assembly after its component (the designator).
/// Altium writes every NEXT_ASSEMBLY_USAGE_OCCURRENCE with an empty name and puts the designator on
/// the PRODUCT, while KiCad names the occurrence ("MP1") and the product after the model. Readers that
/// look for parts by occurrence name (step2glb's molecule MP1-MP4 anchoring, measured 2026-10-20: 0
/// markers before, anchored after) then find nothing in an Altium STEP. Returns the text and how many
/// occurrences it named; occurrences that already have a name and nested model occurrences are kept.
fn name_component_occurrences(text: &str) -> (String, usize) {
// (byte range of the entity's argument list, id, type, args)
let mut ents: Vec<(usize, usize, String, String, String)> = Vec::new();
let bytes = text.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
let at_line_start = i == 0 || bytes[i - 1] == b'\n';
if at_line_start && bytes[i] == b'#' {
let Some(eq) = text[i..].find('=').map(|e| i + e) else { break };
let id = text[i + 1..eq].trim().to_string();
let Some(open) = text[eq..].find('(').map(|o| eq + o) else { break };
let ty = text[eq + 1..open].trim().to_string();
let (mut depth, mut quoted, mut close) = (0i32, false, None);
for (k, c) in text[open..].char_indices() {
match c {
'\'' => quoted = !quoted,
'(' if !quoted => depth += 1,
')' if !quoted => {
depth -= 1;
if depth == 0 {
close = Some(open + k);
break;
}
}
_ => {}
}
}
let Some(close) = close else { break };
if id.chars().all(|c| c.is_ascii_digit()) && !ty.is_empty() {
ents.push((open + 1, close, id, ty, text[open + 1..close].to_string()));
}
i = close + 1;
} else {
i += 1;
}
}
let unquote = |a: &str| a.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'"));
let reference = |a: &str| a.strip_prefix('#').map(|x| x.to_string());
let mut product_name = std::collections::HashMap::new();
let mut formation_product = std::collections::HashMap::new();
let mut definition_formation = std::collections::HashMap::new();
for (_, _, id, ty, body) in &ents {
let a = step_args(body);
match ty.as_str() {
"PRODUCT" => if let Some(n) = a.first().and_then(|x| unquote(x)) { product_name.insert(id.clone(), n); },
t if t.starts_with("PRODUCT_DEFINITION_FORMATION") => if let Some(p) = a.get(2).and_then(|x| reference(x)) { formation_product.insert(id.clone(), p); },
"PRODUCT_DEFINITION" => if let Some(f) = a.get(2).and_then(|x| reference(x)) { definition_formation.insert(id.clone(), f); },
_ => {}
}
}
let name_of = |pd: &str| definition_formation.get(pd).and_then(|f| formation_product.get(f)).and_then(|p| product_name.get(p)).cloned();
let usages: Vec<_> = ents.iter().filter(|e| e.3 == "NEXT_ASSEMBLY_USAGE_OCCURRENCE").collect();
let children: std::collections::HashSet<String> = usages.iter().filter_map(|e| step_args(&e.4).get(4).and_then(|x| reference(x))).collect();
let mut edits: Vec<(usize, usize, String)> = Vec::new();
for (start, end, _, _, body) in usages {
let a = step_args(body);
if a.len() < 5 || a[1] != "''" {
continue;
}
let (Some(parent), Some(child)) = (reference(a[3]), reference(a[4])) else { continue };
// Only the board assembly's own components: the root is the definition that is no one's child.
if children.contains(&parent) {
continue;
}
let Some(name) = name_of(&child).filter(|n| !n.is_empty()) else { continue };
let mut rebuilt: Vec<String> = a.iter().map(|x| x.to_string()).collect();
rebuilt[1] = format!("'{}'", name.replace('\'', "''"));
edits.push((*start, *end, rebuilt.join(",")));
}
let named = edits.len();
let mut out = text.to_string();
edits.sort_by(|x, y| y.0.cmp(&x.0));
for (s, e, body) in edits {
out.replace_range(s..e, &body);
}
(out, named)
}
/// Apply `name_component_occurrences` to an exported STEP in place (unless `nameOccurrences` is false).
fn name_step_occurrences(path: &str, args: &Value) -> Value {
if args.get("nameOccurrences").and_then(|b| b.as_bool()) == Some(false) {
return json!({"skipped": "nameOccurrences false"});
}
let Ok(bytes) = std::fs::read(path) else { return json!({"error": "could not read the STEP"}) };
let text = String::from_utf8_lossy(&bytes).to_string();
let (named_text, n) = name_component_occurrences(&text);
if n > 0 {
if let Err(e) = std::fs::write(path, named_text) {
return json!({"error": e.to_string()});
}
}
json!({"named": n})
}
fn export_step_outjob(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);
if !std::path::Path::new(&pcb).is_file() {
return json!({"success": false, "errorCode": "pcb_not_found", "filePath": pcb,
"_hint": "filePath must be an absolute path to a .PcbDoc on THIS machine (send it with ab's send_files first)."});
}
let out = s(args, "output").unwrap_or_else(|| {
let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());
format!("{stem}.step")
});
let outjob = match step_outjob_text(args) {
Ok(t) => t,
Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e}),
};
// A throwaway project: an OutJob only runs for the project it belongs to (an OutJob outside the
// project wrote nothing, measured), and the user's own project is never touched.
let tmp = std::env::temp_dir().join("adom-altium-step").join(format!("{}", (support::now() * 1000.0) as u64));
let proj = tmp.join("AdomStepExport.PrjPcb");
let oj = tmp.join("AdomStep.OutJob");
let prj = format!("[Design]\r\nVersion=1.0\r\nHierarchyMode=0\r\n\r\n[Document1]\r\nDocumentPath={}\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n\r\n[Document2]\r\nDocumentPath=AdomStep.OutJob\r\nAnnotationEnabled=1\r\nDocumentUniqueId=\r\n",
pcb.replace('/', "\\"));
if let Err(e) = std::fs::create_dir_all(&tmp).and_then(|_| std::fs::write(&oj, outjob)).and_then(|_| std::fs::write(&proj, prj)) {
return json!({"success": false, "errorCode": "temp_write_failed", "error": e.to_string()});
}
let proj_s = proj.to_string_lossy().to_string();
let r = run_outjob(&json!({"outjobPath": oj.to_string_lossy(), "medium": "STEP", "projectPath": proj_s, "timeout": timeout_of(args, 300).as_secs()}));
let gen_dir = tmp.join("Project Outputs for AdomStepExport").join("ExportSTEP");
let produced = std::fs::read_dir(&gen_dir).ok().and_then(|rd| {
rd.flatten().map(|e| e.path()).find(|p| p.extension().map(|x| x.eq_ignore_ascii_case("step") || x.eq_ignore_ascii_case("stp")).unwrap_or(false))
});
// Close the throwaway project whatever happened, so it does not sit in the user's Projects panel.
let _ = agent::call("close_document", &[("path", oj.to_string_lossy().to_string())], None, Duration::from_secs(30));
let _ = agent::call("close_document", &[("path", proj_s.clone())], None, Duration::from_secs(30));
let Some(src) = produced else {
return json!({"success": false, "errorCode": "step_not_written", "filePath": pcb, "outjob": r,
"elapsedSec": support::elapsed_round(t0), "tempDir": tmp.to_string_lossy(),
"_hint": "Altium ran the STEP output but wrote no .step. Read outjob.dialogs[] (a modal needs a decision) and altium_check_dialogs; a board that is open with unsaved changes exports its SAVED state, so altium_save first."});
};
if let Some(parent) = std::path::Path::new(&out).parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::copy(&src, &out) {
return json!({"success": false, "errorCode": "copy_failed", "error": e.to_string(), "produced": src.to_string_lossy(), "tempDir": tmp.to_string_lossy()});
}
let occurrences = name_step_occurrences(&out, args);
let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
let products = step_product_names(std::path::Path::new(&out));
let _ = std::fs::remove_dir_all(&tmp);
json!({"success": bytes > 0, "lane": "outjob", "filePath": pcb, "output": out, "outputExists": bytes > 0, "outputBytes": bytes,
"products": products, "occurrences": occurrences, "suffixType": s_or(args, "suffixType", "none"), "elapsedSec": support::elapsed_round(t0),
"windowEtiquette": "background",
"_hint": "products[] are the STEP part names: Altium names each component by its DESIGNATOR (suffixType only appends none/board/custom/id), and a 3D model keeps the name inside its own STEP. Each component occurrence is also named after its designator (occurrences.named), as KiCad's STEP does, so step2glb --molecule finds MP1-MP4; nameOccurrences:false keeps Altium's empty names. Pull the file with ab's pull_file."})
}
/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.
fn export_step_extension(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let pcb = s(args, "filePath").unwrap_or_else(support::demo_pcb);
let out = s(args, "output").unwrap_or_else(|| {
let stem = pcb.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or_else(|| pcb.clone());
format!("{stem}.step")
});
let lines = vec![
format!("PcbDoc={pcb}"),
format!("Out={out}"),
format!("Holes={}", s_or(args, "holes", "All")),
format!("PadHoles={}", if args.get("padHoles").and_then(|b| b.as_bool()).unwrap_or(true) { "true" } else { "false" }),
format!("Copper={}", s_or(args, "copper", "None")),
format!("Models={}", s_or(args, "models", "Step")),
format!("Folded={}", if args.get("folded").and_then(|b| b.as_bool()).unwrap_or(false) { "true" } else { "false" }),
];
let mut r = ext_dispatch("ExportStep", &lines, "adombridge_exportstep.txt", timeout_of(args, 300));
let exists = std::path::Path::new(&out).exists();
r["output"] = json!(out);
r["outputExists"] = json!(exists);
r["outputBytes"] = json!(if exists { std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0) } else { 0 });
if r.get("success").and_then(|b| b.as_bool()) == Some(true) && !exists {
r["success"] = json!(false);
r["errorCode"] = json!("step_not_written");
}
if exists {
r["occurrences"] = name_step_occurrences(&out, args);
}
if r.get("_hint").is_none() {
r["_hint"] = json!("Copper=All bakes traces as solids (30x bigger file); Holes=All gives real drilled geometry.");
}
r
}
pub fn import_kicad(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let file = match s(args, "filePath") {
Some(f) if !f.is_empty() => f,
_ => return json!({"success": false, "error": "filePath (.kicad_sym / .kicad_sch / .kicad_pcb) is required"}),
};
let lines = vec![format!("Mode={}", s_or(args, "mode", "Import")), format!("File={file}")];
let mut r = ext_dispatch("ImportKiCad", &lines, "adombridge_importkicad.txt", timeout_of(args, 300));
if r.get("_hint").is_none() {
r["_hint"] = json!("Output lands in 'Imported <stem>' beside the source. Footprints are NOT imported by Altium's KiCad importer — link one with altium_set_params {footprint}.");
}
r
}
pub fn install_extension(args: &Value) -> Value {
let installer = ext_dir().join("Install-AdomBridge.ps1");
if !installer.exists() {
return json!({"success": false, "error": format!("installer missing at {}", installer.display())});
}
let remove = args.get("remove").and_then(|b| b.as_bool()).unwrap_or(false);
if !instance::instances().is_empty() && !remove {
return json!({"success": false, "errorCode": "altium_running",
"error": "Altium must be CLOSED to install/update the extension (it holds AdomBridge.dll open).",
"_hint": "altium_close, then altium_install_extension, then altium_launch. Put extension/REMOVE-AdomBridge.cmd on the Desktop first — a failed extension load stops Altium starting."});
}
let mut cmd = std::process::Command::new("powershell");
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
.arg(&installer)
.arg("-Source")
.arg(ext_dir().join("AdomBridge"));
if remove {
cmd.arg("-Remove");
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
match cmd.output() {
Ok(p) => {
let stdout = String::from_utf8_lossy(&p.stdout).to_string();
let stderr = String::from_utf8_lossy(&p.stderr).to_string();
let ok = p.status.success() && !stderr.to_lowercase().contains("denied");
json!({"success": ok, "removed": remove,
"output": stdout.chars().rev().take(1200).collect::<String>().chars().rev().collect::<String>(),
"stderr": stderr.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>(),
"_hint": "Start Altium — extensions load at startup. Then altium_export_step / altium_import_kicad."})
}
Err(e) => json!({"success": false, "error": format!("powershell failed to start: {e}")}),
}
}
pub fn extension_ping(_args: &Value) -> Value {
let mut r = ext_dispatch("Ping", &["Msg=hello".to_string()], "adombridge_ping.txt", Duration::from_secs(30));
r["installedOnDisk"] = json!(extension_installed());
r
}
// ---- DRC: the modal dance ----------------------------------------------------
/// PCB:DesignRuleCheck opens a MODAL — the agent blocks inside RunProcess until it closes.
/// The bridge drives the dialog's 'Run Design Rule Check' via ab UIA in the background, then
/// reads the violations off the board through the agent.
pub fn run_drc(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let t0 = support::now();
let path = s(args, "filePath").unwrap_or_else(support::demo_pcb);
let r = support::agent_open(&path, 60);
if r.get("success").and_then(|b| b.as_bool()) != Some(true) {
return r;
}
// fire the process WITHOUT waiting for the agent (the modal freezes the loop)
agent::call("run_process", &[("process", "PCB:DesignRuleCheck".into()), ("params", "".into())], None, Duration::from_secs(6));
let mut dlg: Option<Value> = None;
for _ in 0..30 {
instance::invalidate_snapshot();
let hit = instance::altium_windows().into_iter().find(|w| {
w.get("kind").and_then(|k| k.as_str()) != Some("main")
&& w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("rule")).unwrap_or(false)
});
if hit.is_some() {
dlg = hit;
break;
}
std::thread::sleep(Duration::from_millis(500));
}
let mut steps = vec![json!({"dialog": dlg.as_ref().and_then(|d| d.get("title")).cloned(),
"hwnd": dlg.as_ref().and_then(|d| d.get("hwnd")).cloned()})];
let dlg = match dlg {
Some(d) => d,
None => return json!({"success": false, "errorCode": "drc_dialog_not_found", "steps": steps,
"_hint": "PCB:DesignRuleCheck did not open its dialog — is a PCB the focused document? altium_window_info."}),
};
let hwnd = dlg.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
let click = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Run Design Rule Check", "role": "button"}), &[], 30);
steps.push(json!({"uiClick": "Run Design Rule Check", "result": click}));
// wait for the agent to come back (the modal closed) and DRC to finish. When pours are out of date
// Altium asks first ("Run DRC / Cancel / Repour Polygons and Run DRC", measured 2026-10-16): repour
// then run, the honest answer (the repour runs in the dialog, outside the script, so scoped rules apply)
let wait = timeout_of(args, 180);
let t1 = std::time::Instant::now();
let mut repour_answered = false;
while t1.elapsed() < wait {
if agent::is_responsive() {
break;
}
if !repour_answered {
instance::invalidate_snapshot();
if let Some(w) = instance::altium_windows().into_iter().find(|w| w.get("kind").and_then(|k| k.as_str()) != Some("main")
&& w.get("title").and_then(|t| t.as_str()).map(|t| t.to_lowercase().contains("design rule check")).unwrap_or(false)) {
let h = w.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);
let c = ab::call("desktop_ui_click", json!({"hwnd": h, "contains": "Repour Polygons and Run DRC", "role": "button"}), &[], 30);
if c.get("success").and_then(|v| v.as_bool()) == Some(true) {
repour_answered = true;
steps.push(json!({"uiClick": "Repour Polygons and Run DRC", "result": c}));
}
}
}
std::thread::sleep(Duration::from_millis(500));
}
// the DRC report (HTML) opens as a new document and takes focus — put the board back first
support::agent_open(&path, 60);
let v = agent::call("violations", &[("limit", s_or(args, "limit", "200"))], None, Duration::from_secs(60));
let data = v.get("data").cloned().unwrap_or(json!({}));
steps.push(json!({"violations": {"success": v.get("success"),
"error": data.get("error").or_else(|| v.get("error")), "ms": v.get("roundtripMs")}}));
let mut dl = dialogs::scan_scoped(true);
shot::attach_dialog_shots(&mut dl);
let ok = v.get("success").and_then(|b| b.as_bool()).unwrap_or(false) && dl.is_empty();
json!({"success": ok, "board": path, "violationCount": data.get("violationCount"),
"violations": data.get("violations"), "steps": steps, "dialogs": dl,
"elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",
"_hint": "violations[] are the DRC markers on the board (rule, description, location). A DRC report document may also have opened — altium_state shows it."})
}
/// One-shot: run ProcName in a .PrjScr through the -R lane (agent must NOT be resident).
pub fn run_script(args: &Value) -> Value {
if let Some(g) = support::need_licensed() {
return g;
}
let prj = match s(args, "prjscr") {
Some(p) if !p.is_empty() => p,
_ => return json!({"success": false, "error": "prjscr (absolute path to a .PrjScr on this machine) is required"}),
};
let proc = s_or(args, "proc", "Main");
lifecycle::run_script_lane_in(&proc, Some(&prj), timeout_of(args, 60))
}
#[cfg(test)]
mod step_outjob_tests {
use super::*;
#[test]
fn suffix_types_follow_the_measured_mapping() {
for (k, v) in [("none", 0), ("board", 1), ("custom", 2), ("id", 3), ("ID", 3), ("2", 2)] {
assert_eq!(step_suffix_type(k), Some(v), "{k}");
}
assert_eq!(step_suffix_type("comment"), None);
}
#[test]
fn board_components_are_named_after_their_designators() {
let step = "ISO-10303-21;\nDATA;\n\
#1 = PRODUCT('Buck','Buck','',(#9));\n#2 = PRODUCT_DEFINITION_FORMATION('','',#1);\n#3 = PRODUCT_DEFINITION('design','',#2,#8);\n\
#11 = PRODUCT('MP1','MP1','',(#9));\n#12 = PRODUCT_DEFINITION_FORMATION('','',#11);\n#13 = PRODUCT_DEFINITION('design','',#12,#8);\n\
#21 = PRODUCT('O''Hare','x','',(#9));\n#22 = PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE('','',#21,.NOT_KNOWN.);\n#23 = PRODUCT_DEFINITION('design','',#22,#8);\n\
#31 = PRODUCT('15354622432','15354622432','',(#9));\n#32 = PRODUCT_DEFINITION_FORMATION('','',#31);\n#33 = PRODUCT_DEFINITION('design','',#32,#8);\n\
#41 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','','',#3,#13,$);\n#42 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','',\n '',#3,#23,$);\n\
#43 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('3','','',#13,#33,$);\n#44 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('4','Kept','',#3,#33,$);\nENDSEC;\n";
let (out, n) = name_component_occurrences(step);
assert_eq!(n, 2);
assert!(out.contains("#41 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('1','MP1','',#3,#13,$);"));
assert!(out.contains("NEXT_ASSEMBLY_USAGE_OCCURRENCE('2','O''Hare','',#3,#23,$);"));
// a model inside a component, and an occurrence that already has a name, stay as they were
assert!(out.contains("#43 = NEXT_ASSEMBLY_USAGE_OCCURRENCE('3','','',#13,#33,$);"));
assert!(out.contains("('4','Kept','',#3,#33,$)"));
assert_eq!(name_component_occurrences(&out).1, 0, "a second pass names nothing");
}
/// ALTIUM_STEP=<an Altium board STEP> cargo test -- --ignored real_altium_step
#[test]
#[ignore]
fn real_altium_step() {
let p = std::env::var("ALTIUM_STEP").expect("ALTIUM_STEP");
let text = String::from_utf8_lossy(&std::fs::read(&p).unwrap()).to_string();
let (out, n) = name_component_occurrences(&text);
println!("named {n}");
for mp in ["MP1", "MP2", "MP3", "MP4"] {
assert!(out.contains(&format!("','{mp}','',#")), "{mp}");
}
if let Ok(w) = std::env::var("ALTIUM_STEP_OUT") {
std::fs::write(w, out).unwrap();
}
}
#[test]
fn default_outjob_exports_holes_without_copper() {
let t = step_outjob_text(&json!({})).unwrap();
assert!(t.contains("OutputType1=ExportSTEP"));
assert!(t.contains("OutputMedium1=STEP\r\nOutputMedium1_Type=GeneratedFiles"));
assert!(t.contains("IncludeElectricalPadHoles=True"));
assert!(t.contains("ComponentSuffixType=0|ComponentSuffix= |"));
assert!(t.contains("ExportCopperOption=0"));
assert!(t.contains("ExportAsSinglePart=False"));
assert!(!t.contains("\n\n[OutputGroup1]") || t.contains("\r\n"));
}
#[test]
fn custom_suffix_is_written_and_validated() {
let t = step_outjob_text(&json!({"suffixType": "custom", "suffix": "_A", "holes": false})).unwrap();
assert!(t.contains("ComponentSuffixType=2|ComponentSuffix=_A|"));
assert!(t.contains("IncludeElectricalPadHoles=False"));
assert!(step_outjob_text(&json!({"suffixType": "custom"})).is_err());
assert!(step_outjob_text(&json!({"suffixType": "custom", "suffix": "a|b"})).is_err());
assert!(step_outjob_text(&json!({"suffixType": "designator"})).is_err());
}
}