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
0.2.54 source (not yet released, needs signing): altium_export_step names component occurrences after their designators
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",