← Commit history

0.2.54 source (not yet released, needs signing): altium_export_step names component occurrences after their designators

Colby Knox ·afd3267fdd ·1d ago ·parent d96767e
9 files changed +177−11
CHANGELOG.md+4
@@ -1,5 +1,9 @@ # Changelog +## 0.2.54 (2026-10-20)+- **An Altium board's STEP anchors as a molecule.** Altium's STEP export names each component's PRODUCT after its designator but leaves every assembly occurrence unnamed, where KiCad names the occurrence (`MP1`). `step2glb convert --molecule` finds the four machine pins by occurrence name, so an Altium molecule board converted `molecule_anchored: false` (0 MP markers) and its GLB was unanchored. `altium_export_step` now names each board component's occurrence after its designator (both lanes; `occurrences.named` in the reply, `nameOccurrences: false` to keep Altium's file as written). Nested model occurrences and occurrences Altium already named are left alone.+- Verified: the TPS54202 molecule board's Altium STEP (34 components) converts `molecule_anchored: true` after the pass; a unit test covers the root/nested/escaped-quote cases.+ ## 0.2.53 (2026-10-20) - **`symbol_prims` answers the drawn mode's body.** `bodyRectMil` was the first rectangle found, which could belong to any display mode: the TPS54202's 10-mode symbol answered a wider mode's 1200 mil body for its 700 mil drawn body, and a capacitor answered one plate. It is now the union of the current display mode's rectangles (mode 0, the one a sheet draws). - **Library pins keep their electrical type.** `altium-symbol-modes` carries each KiCad pin type into the spec (a 10th field), and `build_symbol_modes` sets Altium's pin type from it: input, output, bidirectional (IO), tri-state (HiZ), open collector, open emitter, power in/out (Power), else Passive. Every pin used to be Passive, so Altium's ERC and AI Flow's pin-type check could not tell a power or output pin from a resistor's. `read_symbol` returns each pin's `electrical` (0 Input, 1 IO, 2 Output, 3 OpenCollector, 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power). Live on AD 26.11.1: the TPS54202's GND, SW and VIN read 7, FB and EN 0, BOOT 4.
bridge-rs/src/main.rs+1−1
@@ -31,7 +31,7 @@ use tiny_http::{Header, Method, Response, Server}; /// Attached to every failed reply (see the /command handler). const REPORT_ISSUES: &str = "If this looks like an altium-bridge BUG (not a refusal you expected), report it: check for a duplicate with `adom-wiki issue list adom/altium-bridge`, then `adom-wiki issue create adom/altium-bridge --title \"<verb>: <one-line symptom>\" --category bug-report --body \"<what you ran, what you expected, what happened, the VERBATIM response JSON (and errorShot if present), bridgeVersion, the Altium version from altium_status, the OS>\"`. A relay, routing, toast or lifecycle problem belongs on adom/adom-bridge instead. Reports from real machines are how this bridge gets correct: three different Altium builds behaved three different ways in one weekend."; -pub const BRIDGE_VERSION: &str = "0.2.53";+pub const BRIDGE_VERSION: &str = "0.2.54";  fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> {     let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/outputs.rs+160−2
@@ -268,6 +268,125 @@ fn step_product_names(path: &std::path::Path) -> Vec<String> {     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;@@ -316,13 +435,14 @@ fn export_step_outjob(args: &Value) -> Value {     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, "suffixType": s_or(args, "suffixType", "none"), "elapsedSec": support::elapsed_round(t0),+        "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. Pull the file with ab's pull_file."})+        "_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.@@ -353,6 +473,9 @@ fn export_step_extension(args: &Value) -> Value {         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.");     }@@ -523,6 +646,41 @@ mod step_outjob_tests {         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();
bridge-rs/src/verbs.rs+3−3
@@ -632,11 +632,11 @@ fn describe() -> Value {             &["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. 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,+            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", "nameOccurrences": "bool, default true - name each component occurrence after its designator, as KiCad's STEP does (step2glb --molecule finds MP1-MP4 by it)", "timeout": "seconds, default 300"}),+            json!({"lane": "string", "output": "path", "outputExists": "bool", "outputBytes": "int", "products": "[string] - STEP PRODUCT names (how parts are named)", "occurrences": "{named: int} - component occurrences named after their designator", "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."],+            &["Part names are designators (J1, U3...), never the Comment or footprint: Altium has no option for that (suffixType 0-3 measured).", "Altium leaves every assembly occurrence unnamed; the bridge names each board component's occurrence after its designator (0.2.54), so tools that find parts by occurrence name (step2glb --molecule's MP1-MP4 anchoring) work as on a KiCad STEP.", "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"}),
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "altium-bridge",-  "version": "0.2.53",+  "version": "0.2.54",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "type": "app",   "title": "Altium - the Altium Bridge",   "brief": "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.",-  "version": "0.2.53",+  "version": "0.2.54",   "hero": {     "type": "image",     "path": "screenshots/hero.png"
skills/altium-outputs/SKILL.md+5−1
@@ -40,7 +40,11 @@ Parent skill: **altium-bridge**   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+  none / board name / `suffix` / a random id. Altium also leaves every assembly occurrence+  unnamed, so since 0.2.54 the bridge names each board component's occurrence after its+  designator (`occurrences.named`; `nameOccurrences:false` keeps Altium's file as written).+  That is what KiCad writes, and what `step2glb convert --molecule` anchors on: before it, an+  Altium molecule board converted `molecule_anchored: false` (0 MP markers). 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
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.2.53+0.2.54
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "altium",   "displayName": "Altium Designer",-  "version": "0.2.53",+  "version": "0.2.54",   "description": "Drive Altium Designer from Adom: a resident DelphiScript agent (eval + the 1,100-command process launcher), the X2 -R command line, UIA over the VCL shell, dialogs by PID, screenshots of every Altium window, and Altium 365.",   "homepage": "https://wiki.adom.inc/adom/altium-bridge",   "docs": "https://wiki.adom.inc/adom/altium-bridge",