← 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.

Colby Knox ·ff69123b7a ·6d ago ·parent 5b72b5d
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