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.
← Commit history
Merge PR #3 (Aditya Angajala): headless altium_export_step through Altium's own ExportSTEP OutJob, no .NET extension. Applied onto 0.2.45 by hand (its CHANGELOG entry collided with the released 0.2.45; now 0.2.46 unreleased); failure replies name tempDir.
Co-Authored-By: Aditya Angajala
Co-Authored-By: Claude Opus 5.5 <[email protected]>
5 files changed
+195−14
CHANGELOG.md+6@@ -1,5 +1,11 @@ # Changelog +## 0.2.46 (unreleased)+- **Headless STEP export works** (adom/altium-bridge PR #3, Aditya Angajala). `altium_export_step` no longer needs Barrett's .NET extension, which never loads into a running AD 26. The default lane `outjob` writes an ExportSTEP OutJob and a throwaway project pointing at the board into %TEMP%, runs it through the existing OutJob path, copies the .step to `output`, then closes both documents and removes the folder, so the user's own project is never opened or changed. Measured on AD 26.9.1: a 30 x 30 mm board in 3.5 s with no dialog.+- New arguments: `holes` (default true), `suffixType` none/board/custom/id plus `suffix`, `folded`, `singlePart`, and the raw Altium options `copperOption`, `modelsOption`, `componentsOption`. The reply lists `products[]`, the STEP part names: Altium names parts by DESIGNATOR only.+- Named failures: `pcb_not_found`, `bad_args`, `step_not_written` (with the OutJob reply and its dialogs), `copy_failed`; the failure replies name `tempDir`, which is kept for inspection. `lane:"extension"` keeps the old path.++ ## 0.2.45 (2026-10-15) First live run since 0.2.39 on a licensed Altium (Altium Designer Develop, Colby-ROG2). It verified the held 0.2.40-0.2.44 features and fixed what the run and two new issues found.
SKILL.md+2−1@@ -105,7 +105,8 @@ adom-bridge --ai-thread "board review" altium_export_gerber '{"outjobPath":"C:/d ## What is NOT yet real -- `altium_export_step` / `altium_import_kicad` / `altium_install_extension` ship Barrett's AdomBridge .NET extension, but on AD 26.9.1 the server never loads into the running instance (measured; see the plan skill). They fail loudly with `extension_timeout` until it is rebuilt for AD 26. Headless STEP today: an OutJob with an ExportSTEP output needs one human click on *Generate content*.+- `altium_import_kicad` / `altium_install_extension` (and `altium_export_step {lane:"extension"}`) ship Barrett's AdomBridge .NET extension, but on AD 26.9.1 the server never loads into the running instance (measured; see the plan skill). They fail loudly with `extension_timeout` until it is rebuilt for AD 26.+- Headless STEP IS real: `altium_export_step {filePath, output}` (default lane `outjob`) runs Altium's own ExportSTEP output from a throwaway project in %TEMP%, with no dialog and no click (measured on AD 26.9.1, 2026-10-09). Altium names every part by its DESIGNATOR; `suffixType` none/board/custom/id only appends a suffix, and `products[]` in the reply shows the names. - `altium_a365_*` need a token; the Develop trial workspace has no Admin → Developer page. ## DelphiScript facts you need for `altium_eval`
bridge-rs/src/outputs.rs+169−1@@ -192,8 +192,141 @@ fn ext_dispatch(process: &str, req_lines: &[String], result_file: &str, timeout: "_hint": format!("No result file. {EXT_STATUS_NOTE}")}) } -/// Headless STEP with real holes/copper/models — Barrett's AdomBridge:ExportStep.+/// 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+}++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 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, "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. 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; }@@ -362,3 +495,38 @@ pub fn run_script(args: &Value) -> Value { 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 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());+ }+}
bridge-rs/src/verbs.rs+6−6@@ -533,12 +533,12 @@ fn describe() -> Value { &["run_outjob", "export_gerber", "read_schematic"], &["Without outjobPath it exports the BOM of Altium's EXAMPLE design.", "The default OutJob is the example's Assembly.OutJob, which runs every output in that medium, not only a BOM."], json!({"outjobPath": "C:/proj/Assembly.OutJob", "projectPath": "C:/proj/board.PrjPcb"})),- verb_doc("export_step", "Headless STEP export of a board through the AdomBridge .NET extension (X2 -RAdomBridge:ExportStep with a request file). With no filePath it targets the bundled example board.",- json!({"filePath": "path to a .PcbDoc - default: the bundled example board", "output": "path - default: <board>.step", "holes": "string, default 'All'", "padHoles": "bool, default true", "copper": "string, default 'None'", "models": "string, default 'Step'", "folded": "bool, default false", "timeout": "seconds, default 300"}),- json!({"result": "string - the extension's result file", "output": "path", "outputExists": "bool", "outputBytes": "int", "elapsedSec": "float"}), 360,- "altium_extension_ping first. outputExists is the only proof; then pull the file with ab's pull_file.",- &["extension_ping", "install_extension", "run_outjob"],- &["Needs the extension: extension_not_installed -> altium_install_extension with Altium closed.", "Measured on AD 26.9.1: the extension never loaded into the RUNNING instance, so expect extension_timeout until it is rebuilt for AD 26. The verb fails loudly rather than faking a file.", "copper:'All' bakes traces as solids (about 30x the file size)."],+ verb_doc("export_step", "Headless STEP export of a board. Default lane 'outjob': Altium's own ExportSTEP output run from a throwaway project in %TEMP% that points at the board - no dialog, no click (measured on AD 26.9.1). lane:'extension' is the older AdomBridge .NET extension path.",+ json!({"filePath": "path to a .PcbDoc (saved state is exported) - default: the bundled example board", "output": "path - default: <board>.step", "holes": "bool, default true (mechanical, electrical and free pad holes)", "suffixType": "none | board | custom | id, default none - Altium always names parts by DESIGNATOR; this only picks the suffix", "suffix": "string, required with suffixType custom", "folded": "bool, default false", "singlePart": "bool, default false (one merged part)", "copperOption": "int, Altium ExportCopperOption, default 0 (no copper)", "modelsOption": "int, Altium ExportModelsOption, default 2 (template default)", "componentsOption": "int, Altium ExportComponentOptions, default 0 (all)", "lane": "outjob | extension, default outjob", "timeout": "seconds, default 300"}),+ json!({"lane": "string", "output": "path", "outputExists": "bool", "outputBytes": "int", "products": "[string] - STEP PRODUCT names (how parts are named)", "elapsedSec": "float"}), 360,+ "outputExists and products[] are the proof; then pull the file with ab's pull_file. Save the board first: the export reads the file on disk.",+ &["run_outjob", "save", "show_3d_board"],+ &["Part names are designators (J1, U3...), never the Comment or footprint: Altium has no option for that (suffixType 0-3 measured).", "The throwaway project and OutJob are closed afterwards and the temp folder removed; the user's own project is never opened or changed.", "pcb_not_found / step_not_written / bad_args are named; dialogs from the OutJob come back under outjob.dialogs."], json!({"filePath": "C:/proj/board.PcbDoc", "output": "C:/proj/board.step"})), verb_doc("import_kicad", "Import a KiCad file with Altium's own KiCad importer, driven through the AdomBridge .NET extension.", json!({"filePath": "path to a .kicad_sym / .kicad_sch / .kicad_pcb (required)", "mode": "string, default 'Import'", "timeout": "seconds, default 300"}),
skills/altium-outputs/SKILL.md+12−6@@ -3,8 +3,8 @@ name: altium-outputs description: >- Checks and manufacturing outputs through the altium-bridge: compile/ERC with the Messages panel read back, DRC with violations, OutJob runs (Gerber, ODB++, NC drill, BOM,- pick-and-place) with the files actually written, and the current caveats (headless STEP- needs one click, PDF jobs, A365 token gating). Use when running design checks or generating+ pick-and-place) with the files actually written, headless STEP export, and the current+ caveats (PDF jobs, A365 token gating). Use when running design checks or generating fab/assembly outputs from Altium. Trigger words — altium drc, altium erc, compile altium, altium gerber, altium outjob, altium bom, pick and place, odb++, nc drill, altium step export.@@ -33,10 +33,16 @@ Parent skill: **altium-bridge** ## Caveats (current, honest) -- **Headless STEP export**: an OutJob with an ExportSTEP output still needs one human click on- *Generate content*. Two solve lanes are on the plan: a direct process in the 1,111-command- catalog, or automating that click with the background UIA machinery. Barrett's .NET- extension verbs (export_step/import_kicad) do not load on AD 26.+- **Headless STEP export works (0.2.46)**: `altium_export_step {filePath, output}` writes an+ ExportSTEP OutJob and a throwaway .PrjPcb (DocumentPath = the board's absolute path) into+ %TEMP%\adom-altium-step\<stamp>, runs it, copies the .step to `output`, closes both documents+ and deletes the folder. No dialog, no click, ~4 s on a small board. Two facts that shaped it:+ an OutJob only runs for the project it belongs to (an OutJob outside the project wrote+ nothing), and the export reads the board FILE, so `altium_save` first. Part naming is fixed by+ Altium: PRODUCT = designator, instance names are bare numbers; `suffixType` 0-3 only appends+ none / board name / `suffix` / a random id. A 3D model keeps the product name inside its own+ STEP. Barrett's .NET extension lane is still there as `lane:"extension"` and still does not+ load on AD 26. - **A365 GraphQL** (`altium_a365_*`): wired and shaped, but needs a token from a workspace with Admin → Developer (Pro/Enterprise) — the Develop trial has no such page. - OutJob→PDF was blocked in the predecessor bridge; on this one run it through