app
Altium Bridge: AI for Altium Designer
Public Made by Adomby adom
Let an AI (Claude, ChatGPT, Codex or any agent) run Altium Designer for you: create symbols and footprints, place parts (also from Altium 365), wire and annotate schematics, Update PCB, set the stack-up and rules, route, pour copper, add mounting holes and stitching vias, run ERC and DRC, and make Gerbers, BOMs and STEP. Free in Hydrogen; bring your own AI and Altium licence.
← Commit history
0.2.60 source (not yet released, needs signing): layer_stack editing, net_classes, diff_pairs, set_track_width, variants, board_outline; export_manufacturing fab checks (outline, inner layers); update_pcb detail/keepBoardOnly and keeps board-made classes; BOM JLCPCB readiness; eval script_error + Var; fixes from a context-free usability run
27 files changed
+1349−70
CHANGELOG.md+44PLAN.md+1−1SKILL.md+8−5bridge-rs/src/agent.rs+12bridge-rs/src/bom.rs+44−6bridge-rs/src/design.rs+237bridge-rs/src/dialogs.rs+4−1bridge-rs/src/main.rs+3−1bridge-rs/src/manufacturing.rs+80−3bridge-rs/src/misc.rs+4−1bridge-rs/src/routing.rs+1bridge-rs/src/sch.rs+122−11bridge-rs/src/surfaces.rs+2bridge-rs/src/variants.rs+207bridge-rs/src/verbs.rs+86−9dev-skills/altium-bridge-dev/SKILL.md+1−1dev-skills/altium-bridge-test/run_verb_tests.py+12−1package.json+1−1page.json+1−1skills/altium-boards/SKILL.md+28skills/altium-bom/SKILL.md+25−1skills/altium-bridge-plan/SKILL.md+1−1skills/altium-outputs/SKILL.md+13skills/altium-schematic-edit/SKILL.md+8−3src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+391−20src/bridge.json+12−2CHANGELOG.md+44@@ -1,5 +1,49 @@ # Changelog +## 0.2.60 (2026-10-22)+**Board setup an engineer does before routing, fab checks, and fixes from a context-free usability run.** A fresh AI with no knowledge of the bridge did eight engineer tasks on a copy of the buck. These are its bugs and missing pieces.++New verbs:+- `altium_layer_stack` edits the stack: `add` planes or inner signal layers (with a plane's net), `remove`, `set` copper / dielectric / Dk / material / plane net / pullback / name, and `totalMm` to spread the dielectrics to a board thickness. Through `Board.LayerStack_V7.InsertLayer`. Measured: the buck went 2 layers to Top / GND / PWR / Bottom at 1.595 mm and survived save and reopen. The read now reports each layer's kind and a plane's net.+- `altium_net_classes`: list, make (replacing one of that name) or remove net classes, with optional `width` and `clearance` rules scoped `InNetClass('<name>')`. Nets not on the board come back in `notOnBoard`.+- `altium_diff_pairs`: list, define or remove differential pairs.+- `altium_set_track_width`: resize the copper already routed on nets or a class (pours and footprints untouched), with an online-DRC count after. Collects first, then changes: changing tracks mid-iteration crashed Altium.+- `altium_variants`: list, make, change or remove assembly variants (not-fitted parts). Altium's API cannot create variants, so the bridge writes the `.PrjPcb` variant sections, reloads the project and reads back what Altium holds. Measured: a Lite variant read back from Altium, and its BOM dropped the two parts.+- `altium_board_outline`: the board shape as tracks and arcs on a mechanical layer.++Fab:+- `altium_export_manufacturing` checks its own Gerbers (`fabChecks`):+ - **Outline:** a scripted board's GM1/GKO were empty, and a fab house would reject the set. The bridge now draws the outline on Mechanical 1 and generates again (`addOutline:false` only reports).+ - **Copper layers:** a board's planes and inner layers are added to the Gerber plot set, which the template limited to top, mid 1-2 and bottom. A 4-layer board had come out as 2 copper files; it now gives GTL, GP1, GP2, GBL.+- `altium_bom`:+ - `checks.jlcpcb` gives `ready` and the purchased lines without an LCSC number.+ - `checks.notPurchased` covers parts whose MPN or manufacturer says nothing is bought, such as "none (PCB copper)" test pads. They stay in bom.csv (`purchase:false`) and out of the JLCPCB BOM/CPL and distributor files.+ - An empty `variant` means none.++The change order (`altium_update_pcb`):+- `detail:true` lists EVERY row of Altium's change order without running it. The grid is virtualized, so the bridge selects a row and pages through it.+- `keepBoardOnly:true` runs it and leaves board-only parts on the board, by unticking their rows.+- Board-made net classes, diff pairs, rules and rooms are no longer removed by the next change order: their rows are unticked unless `removeBoardOnlyClasses:true`. Before this, a class made with `altium_net_classes` would have been deleted.++Fixes:+- `altium_eval`:+ - It answered `success:true` when the script failed. It now watches for Altium's error box and answers `script_error` with a shot of the message.+ - A `Var` block is allowed; the docs had said otherwise.+ - `filePath` makes a document current first; `run_command` takes `filePath` too. After an export the OutJob was current, so board scripts saw no board.+- A script error inside a board edit left Altium "in a command", and every save then asked "A command is currently active". The bridge now closes the edit (`boardEditClosed`) after recovering.+- Shots of Altium's error boxes wait for the painted text; they were blank.+- `altium_routing_drc` violations name both clashing objects (`objects`), so an empty description still says what clashes.+- `altium_sheet_params` with `Description` sets the part's own description, which the BOM and the pick and place read.+- `altium_export_pdf` answers `pdfPath`. The old `output` collided with ab's envelope field.+- `altium_dismiss_dialogs {hwnd}` on a dialog that is already gone answers `alreadyGone`.+- `altium_dialog_log {shots}` honours `limit`.+- `show_*` replies name the document just shown, not the previous one.+- Width rules (`add_rule`, `set_design_rules`) cover every copper layer, not just top and bottom.+- Skills:+ - `zoom_to` args are corrected.+ - `schematic_data` needs `params:true` for parameters.+ - The intent map covers stack-up, classes, variants and fab checks.+ ## 0.2.59 (2026-10-22) **Fixes from the full verb battery on signed 0.2.58** (155 pass, `close` failed after `update_pcb`). - `altium_update_pcb` closes the change order with a real click on Close. UIA Invoke only hid the dialog, and Altium's quit later waited on that hidden change order with its main window disabled and nothing on screen. Every close after a change order failed with `modal_orphan_survived_quit` (reproduced 4 times on AD 26.11.1; it was Altium's own quit too, not only the bridge's).
PLAN.md+1−1@@ -217,7 +217,7 @@ heartbeat and recover. Keep `handlers/dialog_classify.py` with title → categor |---|---| | Trial licence | `Start Free Trial` (WebView2, image click) → Edge opens `altium.com/365-intro/activate-add-eval` → workspace form (name, region, terms, **Activate**) → a workspace is provisioned at `<name>.365.altium.com` in ~60 s → back in AD: **Refresh**, then **Use License** on "Altium Designer Develop · On-Demand · Trial · 30 days". Title flips to `… - Trial`. | | `-R` forwarding | A second `X2.EXE -R<Server>:<Cmd>(…)` forwards to the running instance and exits in ~2–8 s. **If the instance is busy (any modal, or a script running) the helper hangs and shows its own "There is another instance … busy" Information dialog** — kill it. |-| `RunScriptText` | Works from the shell: `-RScriptingSystem:RunScriptText(Text="Begin RunApplication('…'); End;")`. **Statements only** — a `Var` block is "Unknown identifier: Var". |+| `RunScriptText` | Works from the shell: `-RScriptingSystem:RunScriptText(Text="Begin RunApplication('…'); End;")`. A `Var` block before `Begin` works on AD 26.11.1 (verified 2026-10-22; an earlier build answered "Unknown identifier: Var"). | | `RunScript` | `-RScriptingSystem:RunScript(ProjectName="…\X.PrjScr"\|ProcName="Main")` works with a plain `\|` when the arg is passed VERBATIM (PowerShell `Start-Process`, or Python `Popen("<one string>")`). `Popen([exe, arg])` re-quotes (the path has a space) and the helper never forwards. `^\|` is only for cmd.exe. | | Concurrency | **One script at a time.** A -R script while the agent loop is resident raises `Error: Another script executing now` on the MAIN window; every later -R is refused until it is dismissed. | | Late binding | An unknown identifier (`CreateOleObject`, `RunProcessWithParameters`) is a **runtime** error: `Error: Undeclared identifier …` modal + the Script editor opens with the **debugger halted** on that line. The halted script counts as executing. |
SKILL.md+8−5@@ -18,19 +18,22 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | Goal | Verbs (skill) | |---|---| | See a design | `altium_show_project` / `_schematic` / `_2d_board` / `_3d_board` (frames come back as images), `altium_state` |-| Read a design as data | `altium_schematic_data` (parts, pins, parameters, kind, workspace ids), `altium_routing_state` (the whole board in mm), `altium_read_board`, `altium_net_connections` |+| Read a design as data | `altium_schematic_data` (parts, pins, kind, workspace ids; `params:true` for parameters), `altium_routing_state` (the whole board in mm), `altium_read_board`, `altium_net_connections` | | Add a part / wire pins / change values / delete | `altium_sch_place_part`, `altium_sch_connect`, `altium_sch_edit`, `altium_sheet_params` (**altium-schematic-edit**) | | Number `?` designators, set the title block | `altium_annotate`, `altium_doc_params` (**altium-schematic-edit**) | | Push schematic changes to the board | `altium_update_pcb` (previews; `execute:true` runs the change order) (**altium-schematic-edit**) |-| A BOM, prices, stock, JLCPCB / Mouser / DigiKey files | `altium_bom`, `altium-bom price` / `fill` (**altium-bom**) |+| A BOM, prices, stock, JLCPCB / Mouser / DigiKey files | `altium_bom` (JLCPCB readiness in `checks.jlcpcb`), `altium-bom price` / `fill` (**altium-bom**) |+| Assembly variants (DNP per build) | `altium_variants` (make, change, remove; Altium reads them back) (**altium-bom**) |+| Stack-up: 2 to 4 layers, planes, thickness | `altium_layer_stack {add, set, remove, totalMm}` (**altium-boards**) |+| Net classes, their widths and clearances; diff pairs | `altium_net_classes`, `altium_diff_pairs`, `altium_set_track_width` (existing copper) (**altium-boards**) | | Check the design | `altium_compile` (ERC), `altium_run_drc`, `altium_routing_drc`, `altium_review_board`, `altium_cross_check`, `altium_read_rules` (rule values) | | Find a part on the sheet or board | `altium_sch_zoom`, `altium_zoom_to` | | Place, route, pour, silkscreen a board | `altium_move_parts`, `altium_route_net`, `altium_autoroute` (Situs), `altium_place_vias`, `altium_apply_pours`, `altium_apply_silkscreen`, `altium_set_design_rules`, `altium_layer_stack` (**altium-boards**) |-| Manufacturing files, STEP, PDF | `altium_export_manufacturing`, `altium_export_step`, `altium_export_pdf`, `altium_run_outjob` (**altium-outputs**) |+| Manufacturing files, STEP, PDF | `altium_export_manufacturing` (checks the outline and copper layers in its Gerbers: `fabChecks`), `altium_board_outline`, `altium_export_step`, `altium_export_pdf`, `altium_run_outjob` (**altium-outputs**) | | Make or fix library parts | `altium_import_part`, `altium_build_symbol_modes`, `altium_build_footprint`, `altium_add_body`, `altium_set_params` (**altium-libraries**, **altium-part-import**) | | Parts from the Adom wiki | `adom-wiki library export --format altium`, `altium_install_library_bundle` (**altium-wiki-import**) | | Altium 365 workspace | `altium_signin_status`, `altium_a365_*` (needs API access) (**altium-a365**) |-| Anything else Altium can do | `altium_list_commands {query}` then `altium_run_command {command, params}` (1,100 processes) |+| Anything else Altium can do | `altium_list_commands {query}` then `altium_run_command {command, params, filePath}` (1,100 processes); `altium_eval {text, filePath}` runs DelphiScript (a `Var` block is allowed; a failing script answers `script_error` with a shot of Altium's message) | Long verbs: pass `timeout` (seconds) in the args; ab waits for the call's own `timeout`. @@ -135,7 +138,7 @@ adom-bridge --ai-thread "board review" altium_export_gerber '{"outjobPath":"C:/d ## DelphiScript facts you need for `altium_eval` -- Statements only — `Begin … End;` — no `Var` block (use `altium_run_script` with a `.PrjScr` for real units, and only when the agent is stopped).+- `[Var …;] Begin … End;` — a `Var` block is allowed (verified on AD 26.11.1, both lanes, 2026-10-22). Single quotes only. Use `altium_run_script` with a `.PrjScr` for real units, only when the agent is stopped. - While the resident agent runs, `altium_eval` hands the text to RunScriptText from inside the agent and Altium ignores the nested script (measured on AD 26.11.1: `success:true`, nothing ran). Stop the agent first, or use a structured verb. - Single quotes for strings; no double quotes anywhere in the text. - Unknown identifiers fail at **runtime** with an `Error` modal and a halted engine → `altium_agent_recover`.
bridge-rs/src/agent.rs+12@@ -114,6 +114,12 @@ fn rid() -> String { format!("{:x}", n & 0xffff_ffff_ffff) } +/// Agent commands that open a board edit (PCBServer.PreProcess): after a runtime error inside one, the bridge+/// closes it with pcb_postprocess.+const PCB_EDIT_COMMANDS: &[&str] = &["add_body", "add_rule", "apply_pours", "apply_silk", "board_shape", "build_board", "build_footprint",+ "copy_footprint", "create_footprint", "diff_pairs", "edit_board", "net_classes", "normalize_bodies", "place_on_pcb", "plate_vias",+ "remove_pours", "set_outline", "set_rule", "swap_footprint", "test_board", "xcopy_footprint"];+ pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value { if !is_alive() { // A dead agent with Altium still showing the agent's script is a script PARKED ON A@@ -250,11 +256,17 @@ pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], p } if let Some(ev) = crate::dialogs::script_error_since(posted_unix_ms) { let recovered = crate::lifecycle::recover(&format!("auto: script Error box during agent command '{command}'"));+ // a board edit halted between PCBServer.PreProcess and PostProcess leaves Altium "in a command":+ // every later save is refused until it is closed+ let rebalanced = if PCB_EDIT_COMMANDS.contains(&command) {+ Some(call("pcb_postprocess", &[], None, Duration::from_secs(30)).get("data").and_then(|d| d.get("ok")).cloned().unwrap_or(Value::Null))+ } else { None }; return json!({"success": false, "errorCode": "agent_script_error", "command": command, "error": format!("the agent script raised a runtime error while running '{command}' (Altium showed an 'Error' box; its text is in errorShot)"), "errorShot": ev.get("shot").cloned().unwrap_or(Value::Null), "autoRecovered": recovered.get("success").and_then(|b| b.as_bool()).unwrap_or(false), "recoverSteps": recovered.get("steps").cloned().unwrap_or(Value::Null),+ "boardEditClosed": rebalanced, "agentAlive": is_alive(), "_hint": "READ errorShot (a PNG of Altium's error box, e.g. 'Undeclared identifier: X' = that API does not exist on this Altium build). The bridge already sent EditScript:Stop, so Altium is NOT parked on a breakpoint and the next verb restarts the agent. Do not retry the same command: it will halt again. Report it on adom/altium-bridge with the errorShot."}); }
bridge-rs/src/bom.rs+44−6@@ -112,6 +112,15 @@ impl Part { fn get(&self, names: &[&str]) -> String { param(&self.params, names) } pub fn mpn(&self) -> String { self.get(MPN) } fn value(&self) -> String { let v = self.get(VALUE); if v.is_empty() { self.comment.clone() } else { v } }+ /// Copper test pads, fiducials and the like carry an MPN that says nothing is bought ("none (PCB copper)"):+ /// they stay in the BOM, marked, and stay out of the ordering and assembly files.+ pub fn not_purchased(&self) -> bool {+ // in the MPN or the manufacturer (the Adom test pads say Manufacturer "none (PCB copper)")+ [self.mpn(), self.get(MFR)].iter().any(|v| {+ let m = v.trim().to_ascii_lowercase();+ m.starts_with("none") || m == "n/a" || m == "na" || m == "-" || m.contains("pcb copper") || m.contains("not purchased") || m.contains("no part")+ })+ } } const FIELDS: &[(&str, &[&str])] = &[("mpn", MPN), ("manufacturer", MFR), ("mouser", MOUSER), ("digikey", DIGIKEY), ("lcsc", LCSC)];@@ -152,10 +161,13 @@ pub fn group(parts: &[Part]) -> (Vec<Value>, Value) { line.insert("footprint".into(), json!(fps.first().copied().unwrap_or(""))); line.insert("package".into(), json!(first.get(PACKAGE))); line.insert("libReference".into(), json!(first.lib_ref));+ line.insert("purchase".into(), json!(!first.not_purchased())); lines.push(Value::Object(line)); } lines.sort_by_key(|l| natural_key(l["designators"][0].as_str().unwrap_or("")));- let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind)).collect();+ let fitted: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && !p.not_purchased()).collect();+ let mut not_purchased: Vec<String> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && p.not_purchased()).map(|p| p.designator.clone()).collect();+ not_purchased.sort_by_key(|d| natural_key(d)); let missing = |names: &[&str]| -> Vec<String> { let mut v: Vec<String> = fitted.iter().filter(|p| p.get(names).is_empty()).map(|p| p.designator.clone()).collect(); v.sort_by_key(|d| natural_key(d)); v@@ -167,8 +179,13 @@ pub fn group(parts: &[Part]) -> (Vec<Value>, Value) { let checks = json!({ "missingMpn": missing(MPN), "missingManufacturer": missing(MFR), "missingPartNumbers": {"mouser": missing(MOUSER), "digikey": missing(DIGIKEY), "lcsc": missing(LCSC)},- "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded,+ "inconsistent": inconsistent, "dnp": dnp, "excludedKinds": excluded, "notPurchased": not_purchased, });+ let mut checks = checks;+ // JLCPCB assembly places only what it can source by LCSC number: every other purchased line is consigned or blocks+ let no_lcsc: Vec<Value> = lines.iter().filter(|l| l["purchase"] == json!(true) && st(&l["lcsc"]).is_empty())+ .map(|l| json!({"designators": l["designators"], "mpn": l["mpn"], "value": l["value"]})).collect();+ checks["jlcpcb"] = json!({"ready": no_lcsc.is_empty(), "linesWithoutLcsc": no_lcsc}); (lines, checks) } @@ -193,10 +210,10 @@ pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, Strin out.push(("bom.csv".to_string(), csv(&rows))); // JLCPCB assembly: their BOM and CPL column names, mm, Top/Bottom let mut j = vec![["Comment", "Designator", "Footprint", "LCSC Part #"].map(String::from).to_vec()];- for l in lines { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); }+ for l in lines.iter().filter(|l| l["purchase"] != json!(false)) { j.push(vec![st(&l["value"]), refs(l), st(&l["footprint"]), st(&l["lcsc"])]); } out.push(("bom-jlcpcb.csv".to_string(), csv(&j))); let mut c = vec![["Designator", "Mid X", "Mid Y", "Layer", "Rotation"].map(String::from).to_vec()];- let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && p.placed.is_some()).collect();+ let mut placed: Vec<&Part> = parts.iter().filter(|p| !p.dnp && !NO_BOM_KINDS.contains(&p.kind) && !p.not_purchased() && p.placed.is_some()).collect(); placed.sort_by_key(|p| natural_key(&p.designator)); for p in placed { let (x, y, r, side) = p.placed.clone().unwrap();@@ -206,7 +223,7 @@ pub fn files(lines: &[Value], parts: &[Part], boards: u64) -> Vec<(String, Strin // distributor BOM tools (Mouser BOM Tool, DigiKey myLists / BOM Manager) map these headers directly for (name, field, header) in [("bom-mouser.csv", "mouser", "Mouser Part Number"), ("bom-digikey.csv", "digikey", "Digi-Key Part Number")] { let mut m = vec![vec![header.to_string(), "Manufacturer Part Number".into(), "Manufacturer".into(), "Quantity".into(), "Customer Reference".into()]];- for l in lines.iter().filter(|l| !st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty()) {+ for l in lines.iter().filter(|l| l["purchase"] != json!(false) && (!st(&l[field]).is_empty() || !st(&l["mpn"]).is_empty())) { m.push(vec![st(&l[field]), st(&l["mpn"]), st(&l["manufacturer"]), (l["quantity"].as_u64().unwrap_or(0) * boards.max(1)).to_string(), refs(l)]); } out.push((name.to_string(), csv(&m)));@@ -241,7 +258,7 @@ pub fn bom(args: &Value) -> Value { if project.sheets.is_empty() { return json!({"success": false, "errorCode": "no_sheets", "error": "the project lists no .SchDoc", "projectPath": prj}); } let timeout = timeout_of(args, 120); // the variant: by its description, or none- let want_variant = s(args, "variant");+ let want_variant = s(args, "variant").filter(|v| !v.trim().is_empty() && !v.eq_ignore_ascii_case("none") && v != "null"); let variant = match &want_variant { Some(v) => match project.variants.iter().find(|(n, _)| n.eq_ignore_ascii_case(v)) { Some(x) => Some(x),@@ -334,6 +351,8 @@ pub fn bom(args: &Value) -> Value { if missing > 0 { hint.push_str(&format!("{missing} part(s) have no MPN: find them (adom-parts-search search <value> <package>) and write them back with altium_sheet_params. ")); } if !gaps.is_empty() { hint.push_str(&format!("Distributor numbers: {}; look them up by MPN (adom-parts-search search <MPN>) and write them back with altium_sheet_params. ", gaps.join(", "))); } if checks["inconsistent"].as_array().map_or(false, |a| !a.is_empty()) { hint.push_str("Some lines mix values for one part number (checks.inconsistent): fix the sheets so every part of a line agrees. "); }+ if checks["jlcpcb"]["ready"] == json!(false) { hint.push_str(&format!("JLCPCB assembly: {} purchased line(s) have no LCSC number (checks.jlcpcb.linesWithoutLcsc): find one with adom-parts-search / adom-jlcpcb, or plan them as consigned. ", checks["jlcpcb"]["linesWithoutLcsc"].as_array().map(|a| a.len()).unwrap_or(0))); }+ if checks["notPurchased"].as_array().map_or(false, |a| !a.is_empty()) { hint.push_str("checks.notPurchased (copper pads and the like, MPN 'none') stay in bom.csv and out of the ordering and JLCPCB files. "); } hint.push_str("Price and stock the lines with adom-parts-search / adom-mouser part <MPN>; bom-mouser.csv and bom-digikey.csv upload to those sites' BOM tools; bom-jlcpcb.csv + cpl-jlcpcb.csv go to JLCPCB assembly (check rotations on their preview)."); json!({"success": true, "projectPath": prj, "variant": want_variant, "variants": project.variants.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "sheets": sheets_read, "board": board_read, "lines": lines, "lineCount": lines.len(), "fittedParts": fitted, "checks": checks,@@ -464,6 +483,25 @@ mod tests { assert!(f["bom.csv"].lines().nth(1).unwrap().starts_with("1,1,U1,")); } + #[test]+ fn copper_pads_stay_out_of_the_order_files() {+ let mut tp = part("TP5", &[("MPN", "Adom_TestPoint_D0.5mm"), ("Manufacturer", "none (PCB copper)")], "TESTPOINT_PAD");+ tp.placed = Some((1.0, 2.0, 0.0, "top".into()));+ let mut r = part("R1", &[("MPN", "X"), ("LCSC Part #", "C1")], "R0402");+ r.placed = Some((3.0, 4.0, 0.0, "top".into()));+ let c = part("C1", &[("MPN", "Y")], "C0402");+ let parts = vec![tp, r, c];+ let (lines, checks) = group(&parts);+ assert_eq!(checks["notPurchased"], json!(["TP5"]));+ assert_eq!(checks["jlcpcb"]["ready"], json!(false));+ assert_eq!(checks["jlcpcb"]["linesWithoutLcsc"][0]["designators"], json!(["C1"]));+ assert!(!checks["missingPartNumbers"]["mouser"].as_array().unwrap().contains(&json!("TP5")));+ let f: std::collections::HashMap<String, String> = files(&lines, &parts, 1).into_iter().collect();+ assert!(!f["bom-jlcpcb.csv"].contains("TP5"));+ assert!(!f["cpl-jlcpcb.csv"].contains("TP5"));+ assert!(f["bom.csv"].contains("TP5"));+ }+ #[test] fn csv_quotes_what_needs_it() { assert_eq!(csv_cell("R1,R2"), "\"R1,R2\"");
bridge-rs/src/design.rsadded+237@@ -0,0 +1,237 @@+//! Board design data an engineer sets up before routing: net classes (with their width and clearance rules)+//! and differential pairs.+use crate::support::{self, s};+use crate::agent;+use serde_json::{json, Value};+use std::time::Duration;++fn agent_data(r: &Value) -> Result<Value, Value> {+ match r.get("data") {+ Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => Ok(d.clone()),+ Some(d) => Err(json!({"success": false, "errorCode": d.get("errorCode").cloned().unwrap_or(json!("agent_error")), "error": d.get("error"), "reply": d})),+ None => Err(r.clone()),+ }+}++/// `["A","B"]` or `"A,B"` -> the agent's `A;B`.+fn name_list(v: Option<&Value>) -> Vec<String> {+ match v {+ Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+ Some(Value::String(t)) => t.split([',', ';']).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+ _ => vec![],+ }+}++fn open_board(args: &Value) -> Option<Value> {+ if let Some(g) = support::need_licensed() { return Some(g); }+ s(args, "filePath").and_then(|p| support::must_open(&p, 60))+}++/// altium_net_classes {filePath?, name?, nets?, width?, clearance?, remove?}: without name, list them.+pub fn net_classes(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let Some(name) = s(args, "name") else {+ let r = agent::call("net_classes", &[("op", "list".into())], None, Duration::from_secs(60));+ return match agent_data(&r) {+ Ok(d) => json!({"success": true, "classes": d["classes"],+ "_hint": "'All Nets' is Altium's own class of every net (superClass). Make one with {name, nets, width?, clearance?}; rules scoped InNetClass('<name>') then apply to its nets."}),+ Err(e) => e,+ };+ };+ if name.contains(['\'', '\n', ';']) { return json!({"success": false, "errorCode": "bad_args", "error": "a class name cannot hold quotes, semicolons or newlines"}); }+ let scope = format!("InNetClass('{name}')");+ if args.get("remove").and_then(|b| b.as_bool()) == Some(true) {+ let r = agent::call("net_classes", &[("op", "remove".into()), ("name", name.clone())], None, Duration::from_secs(60));+ let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };+ let rules: Vec<Value> = ["width", "clearance"].iter().map(|k| {+ let r = crate::routing::add_rule(&json!({"kind": k, "name": format!("{name} {k}"), "remove": true}));+ json!({"kind": k, "removed": r.get("removed")})+ }).collect();+ return json!({"success": true, "class": name, "removed": d["removed"], "rules": rules});+ }+ let nets = name_list(args.get("nets"));+ if nets.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "nets is required: [\"VIN\",\"GND\"]", "_hint": "altium_list_nets names the board's nets."}); }+ let r = agent::call("net_classes", &[("op", "set".into()), ("name", name.clone()), ("nets", nets.join(";"))], None, Duration::from_secs(60));+ let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };+ let mut rules = Vec::new();+ // width: a number (mm, min = preferred) or {min, preferred, max}+ if let Some(w) = args.get("width") {+ let (min, pref, max) = match w {+ Value::Number(n) => { let v = n.as_f64().unwrap_or(0.0); (v, v, v.max(5.0)) }+ Value::Object(_) => {+ let min = w.get("min").and_then(|v| v.as_f64()).or_else(|| w.get("preferred").and_then(|v| v.as_f64())).unwrap_or(0.0);+ (min, w.get("preferred").and_then(|v| v.as_f64()).unwrap_or(min), w.get("max").and_then(|v| v.as_f64()).unwrap_or(min.max(5.0)))+ }+ _ => (0.0, 0.0, 0.0),+ };+ if min <= 0.0 { return json!({"success": false, "errorCode": "bad_args", "error": "width is mm (0.5) or {min, preferred, max} in mm", "class": d}); }+ let r = crate::routing::add_rule(&json!({"kind": "width", "name": format!("{name} width"), "scope1": scope, "min": min, "preferred": pref, "max": max,+ "comment": format!("net class {name}")}));+ rules.push(json!({"kind": "width", "minMm": min, "preferredMm": pref, "maxMm": max, "success": r.get("success"), "name": r.get("name"), "error": r.get("error")}));+ }+ if let Some(g) = args.get("clearance").and_then(|v| v.as_f64()) {+ let r = crate::routing::add_rule(&json!({"kind": "clearance", "name": format!("{name} clearance"), "scope1": scope, "scope2": "All", "gap": g,+ "comment": format!("net class {name}")}));+ rules.push(json!({"kind": "clearance", "gapMm": g, "success": r.get("success"), "name": r.get("name"), "error": r.get("error")}));+ }+ let not_on_board = d["notOnBoard"].as_array().cloned().unwrap_or_default();+ let ok = rules.iter().all(|r| r["success"] == json!(true));+ json!({"success": ok, "class": name, "members": d["members"], "replaced": d["replaced"], "notOnBoard": not_on_board, "rules": rules, "scope": scope,+ "_hint": if !not_on_board.is_empty() { "Some nets are not on the board (names are case-insensitive but must exist: altium_list_nets). The class was made without them." }+ else { "The class is on the board, and its rules (if any) come first among their kind. New tracks on its nets take the preferred width; existing ones keep theirs (re-route or check with altium_run_drc). Save with altium_save. A class made on the board is kept by altium_update_pcb unless the project removes board-only classes." }})+}++/// altium_diff_pairs {filePath?, name?, pos?, neg?, remove?}: without name, list them.+pub fn diff_pairs(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let Some(name) = s(args, "name") else {+ let r = agent::call("diff_pairs", &[("op", "list".into())], None, Duration::from_secs(60));+ return match agent_data(&r) {+ Ok(d) => json!({"success": true, "pairs": d["pairs"], "_hint": "Make one with {name, pos, neg} (board net names, e.g. USB_P / USB_N)."}),+ Err(e) => e,+ };+ };+ if args.get("remove").and_then(|b| b.as_bool()) == Some(true) {+ let r = agent::call("diff_pairs", &[("op", "remove".into()), ("name", name.clone())], None, Duration::from_secs(60));+ return match agent_data(&r) { Ok(d) => json!({"success": true, "pair": name, "removed": d["removed"]}), Err(e) => e };+ }+ let (Some(pos), Some(neg)) = (s(args, "pos"), s(args, "neg")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "pos and neg (board net names) are required", "_hint": "altium_list_nets names the board's nets; USB_D+/USB_D- style pairs are the usual ones."});+ };+ let r = agent::call("diff_pairs", &[("op", "set".into()), ("name", name.clone()), ("pos", pos), ("neg", neg)], None, Duration::from_secs(60));+ match agent_data(&r) {+ Ok(d) => json!({"success": true, "pair": name, "pos": d["pos"], "neg": d["neg"], "replaced": d["replaced"],+ "_hint": "The pair is defined on the board: Altium's interactive differential-pair router and its Differential Pairs Routing rule use it. Save with altium_save."}),+ Err(mut e) => { if e.get("errorCode") == Some(&json!("net_not_on_board")) { e["_hint"] = json!("A net name is not on the board: altium_list_nets lists them."); } e }+ }+}++fn read_stack() -> Result<Value, Value> { agent_data(&agent::call("layer_stack", &[], None, Duration::from_secs(60))) }++fn as_list(v: Option<&Value>) -> Vec<Value> {+ match v { Some(Value::Array(a)) => a.clone(), Some(o @ Value::Object(_)) => vec![o.clone()], _ => vec![] }+}++/// altium_layer_stack {add?, remove?, set?, totalMm?}: edit the current board's stack (the read is in verbs.rs).+/// add: [{kind: plane|signal, net?, name?, n?}] (n = plane/mid number; default the lowest free one)+/// remove: [{name}]+/// set: [{name, copperUm?, dielectricUm?, dk?, material?, net?, pullbackUm?, rename?}]+/// totalMm: board thickness: the dielectrics are spread evenly to reach it+pub fn layer_stack_edit(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let mut steps = Vec::new();+ let mut stack = match read_stack() { Ok(d) => d, Err(e) => return e };+ let layers = |st: &Value| st["layers"].as_array().cloned().unwrap_or_default();+ for a in as_list(args.get("add")) {+ let kind = a.get("kind").and_then(|v| v.as_str()).unwrap_or("signal");+ if kind != "plane" && kind != "signal" { return json!({"success": false, "errorCode": "bad_args", "error": "add.kind is plane or signal", "stack": stack}); }+ let key = if kind == "plane" { "plane" } else { "mid" };+ let used: Vec<i64> = layers(&stack).iter().filter_map(|l| l[key].as_i64()).collect();+ let n = a.get("n").and_then(|v| v.as_i64()).unwrap_or_else(|| (1..=30).find(|k| !used.contains(k)).unwrap_or(1));+ if used.contains(&n) { return json!({"success": false, "errorCode": "layer_exists", "error": format!("{kind} {n} is already in the stack"), "stack": stack}); }+ let mut p: Vec<(&str, String)> = vec![("op", "insert".into()), ("kind", kind.into()), ("n", n.to_string())];+ if let Some(net) = a.get("net").and_then(|v| v.as_str()) { p.push(("net", net.into())); }+ if let Some(nm) = a.get("name").and_then(|v| v.as_str()) { p.push(("name", nm.into())); }+ match agent_data(&agent::call("layer_stack_edit", &p, None, Duration::from_secs(90))) { Ok(d) => { stack = d; steps.push(json!({"added": kind, "n": n})); } Err(e) => return e }+ }+ for r in as_list(args.get("remove")) {+ let name = r.get("name").and_then(|v| v.as_str()).unwrap_or("");+ let Some(l) = layers(&stack).into_iter().find(|l| l["name"].as_str().map(|x| x.eq_ignore_ascii_case(name)).unwrap_or(false)) else {+ return json!({"success": false, "errorCode": "layer_not_found", "error": format!("no layer named {name}"), "stack": stack});+ };+ let (kind, n) = if let Some(n) = l["plane"].as_i64() { ("plane", n) } else if let Some(n) = l["mid"].as_i64() { ("signal", n) } else {+ return json!({"success": false, "errorCode": "outer_layer", "error": "the top and bottom layers stay", "stack": stack});+ };+ match agent_data(&agent::call("layer_stack_edit", &[("op", "remove".into()), ("kind", kind.into()), ("n", n.to_string())], None, Duration::from_secs(90))) {+ Ok(d) => { stack = d; steps.push(json!({"removed": name})); } Err(e) => return e+ }+ }+ let mut sets = as_list(args.get("set"));+ // copper weights first, so a thickness target is spread over the final copper+ let (copper_sets, rest): (Vec<Value>, Vec<Value>) = sets.into_iter().partition(|x| x.get("copperUm").is_some() && x.get("dielectricUm").is_none());+ sets = rest;+ for x in copper_sets {+ let name = x.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();+ let mut p: Vec<(&str, String)> = vec![("op", "set".into()), ("target", name.clone()), ("copperUm", (x["copperUm"].as_f64().unwrap_or(35.0).round() as i64).to_string())];+ if let Some(v) = x.get("pullbackUm").and_then(|v| v.as_f64()) { p.push(("pullbackUm", (v.round() as i64).to_string())); }+ for (k, a) in [("material", "material"), ("net", "net"), ("rename", "name")] { if let Some(v) = x.get(k).and_then(|v| v.as_str()) { p.push((a, v.into())); } }+ if let Some(v) = x.get("dk").and_then(|v| v.as_f64()) { p.push(("dk1000", ((v * 1000.0).round() as i64).to_string())); }+ match agent_data(&agent::call("layer_stack_edit", &p, None, Duration::from_secs(90))) { Ok(d) => { stack = d; steps.push(json!({"set": name})); } Err(e) => return e }+ }+ if let Some(total) = args.get("totalMm").and_then(|v| v.as_f64()) {+ let ls = layers(&stack);+ let copper: f64 = ls.iter().filter_map(|l| l["copperUm"].as_f64()).sum::<f64>();+ let gaps = ls.len().saturating_sub(1);+ let each = if gaps > 0 { ((total * 1000.0 - copper) / gaps as f64).round() } else { 0.0 };+ if each < 50.0 { return json!({"success": false, "errorCode": "too_thin", "error": format!("{total} mm leaves {each} um per dielectric"), "stack": stack}); }+ for l in ls.iter().take(gaps) {+ let name = l["name"].as_str().unwrap_or("").to_string();+ if !sets.iter().any(|x| x.get("name").and_then(|v| v.as_str()).map(|v| v.eq_ignore_ascii_case(&name)).unwrap_or(false) && x.get("dielectricUm").is_some()) {+ sets.push(json!({"name": name, "dielectricUm": each}));+ }+ }+ }+ for x in sets {+ let Some(name) = x.get("name").and_then(|v| v.as_str()) else { return json!({"success": false, "errorCode": "bad_args", "error": "set entries need name", "stack": stack}) };+ let mut p: Vec<(&str, String)> = vec![("op", "set".into()), ("target", name.into())];+ let num = |k: &str| x.get(k).and_then(|v| v.as_f64()).map(|v| (v.round() as i64).to_string());+ if let Some(v) = num("copperUm") { p.push(("copperUm", v)); }+ if let Some(v) = num("dielectricUm") { p.push(("dielectricUm", v)); }+ if let Some(v) = x.get("dk").and_then(|v| v.as_f64()) { p.push(("dk1000", ((v * 1000.0).round() as i64).to_string())); }+ if let Some(v) = num("pullbackUm") { p.push(("pullbackUm", v)); }+ for (k, a) in [("material", "material"), ("net", "net"), ("rename", "name")] { if let Some(v) = x.get(k).and_then(|v| v.as_str()) { p.push((a, v.into())); } }+ match agent_data(&agent::call("layer_stack_edit", &p, None, Duration::from_secs(90))) { Ok(d) => { stack = d; steps.push(json!({"set": name})); } Err(e) => return e }+ }+ json!({"success": true, "steps": steps, "copperLayers": stack["copperLayers"], "totalUm": stack["totalUm"], "layers": stack["layers"],+ "_hint": "The stack is changed on the board in memory: altium_save keeps it. Planes connect to their net's pads and vias through Altium's Power Plane Connect rule (relief by default); check with altium_run_drc. Inner signal layers route with altium_route_net {layer}. Fab-quote thickness is totalUm."})+}++/// altium_board_outline {filePath?, mech?, widthMm?, replace?}: the board shape as tracks and arcs on a+/// mechanical layer, the outline a fab house reads from the Gerbers.+pub fn board_outline(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let mech = args.get("mech").and_then(|v| v.as_i64()).unwrap_or(1).clamp(1, 16);+ let w = args.get("widthMm").and_then(|v| v.as_f64()).unwrap_or(0.1);+ let raw = ((w / 25.4) * 10_000_000.0).round() as i64;+ let mut p: Vec<(&str, String)> = vec![("op", "draw".into()), ("mech", mech.to_string()), ("width", raw.to_string())];+ if args.get("replace").and_then(|v| v.as_bool()) == Some(true) { p.push(("replace", "1".into())); }+ match agent_data(&agent::call("outline_tracks", &p, None, Duration::from_secs(60))) {+ Ok(d) => {+ let drawn = d["drawn"].as_i64().unwrap_or(0);+ json!({"success": true, "layer": d["layer"], "drawn": drawn, "alreadyThere": d["alreadyThere"], "outlineVertices": d["outlineVertices"],+ "_hint": if drawn > 0 { "The outline is on the board in memory: altium_save keeps it, and the layer's Gerber now carries it (altium_export_manufacturing plots Mechanical 1)." }+ else { "Nothing drawn: the layer already holds primitives (pass replace:true to draw the outline anyway), or the board has no shape (altium_set_outline)." }})+ }+ Err(e) => e,+ }+}++/// altium_set_track_width {filePath?, nets? | netClass?, widthMm, layer?}: widen (or narrow) the existing copper+/// on nets: rules only steer new routing, so a class's preferred width left its old tracks thin.+pub fn set_track_width(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let Some(w) = args.get("widthMm").and_then(|v| v.as_f64()).filter(|w| *w > 0.0 && *w < 20.0) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "widthMm is required (e.g. 0.5)"});+ };+ let mut nets = name_list(args.get("nets"));+ if let Some(class) = s(args, "netClass") {+ let r = agent::call("net_classes", &[("op", "list".into())], None, Duration::from_secs(60));+ let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };+ match d["classes"].as_array().and_then(|a| a.iter().find(|c| c["name"].as_str().map(|n| n.eq_ignore_ascii_case(&class)).unwrap_or(false))) {+ Some(c) => nets.extend(c["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| n.as_str().map(str::to_string))),+ None => return json!({"success": false, "errorCode": "class_not_found", "error": format!("no net class {class}"), "classes": d["classes"]}),+ }+ }+ if nets.is_empty() { return json!({"success": false, "errorCode": "bad_args", "error": "nets [..] or netClass is required"}); }+ let layer = match s(args, "layer").map(|l| l.to_ascii_lowercase()) {+ None => 0, Some(l) if l.starts_with("top") => 1, Some(l) if l.starts_with("bottom") => 32,+ Some(l) => match l.trim_start_matches("mid").trim_start_matches("inner").trim().parse::<i64>() { Ok(n) if (1..=30).contains(&n) => 1 + n, _ => return json!({"success": false, "errorCode": "bad_args", "error": "layer is top, bottom or mid N"}) },+ };+ let raw = ((w / 25.4) * 10_000_000.0).round() as i64;+ let r = agent::call("track_width", &[("nets", nets.join(";")), ("width", raw.to_string()), ("layer", layer.to_string())], None, Duration::from_secs(120));+ let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };+ let drc = crate::routing::routing_drc(&json!({}));+ json!({"success": true, "widthMm": w, "nets": nets, "changed": d["changed"], "byNet": d["byNet"],+ "drcAfter": {"errors": drc.get("errors"), "unconnected": drc.get("unconnected")},+ "_hint": "Existing tracks and arcs on those nets now have that width (component copper untouched). Wider copper can break clearance: drcAfter counts online DRC; altium_routing_drc {full:true} lists the violations. altium_save keeps it."})+}
bridge-rs/src/dialogs.rs+4−1@@ -31,7 +31,7 @@ const CATALOG: &[(&str, &str, &str, &str)] = &[ ("undeclared identifier", "script_compile_error", "OK", "The script failed to compile; the engine is not halted after OK."), ("unknown identifier", "script_compile_error", "OK",- "Compile error in RunScriptText text (statements only, no Var)."),+ "Compile error in RunScriptText text (read the shot: an undeclared identifier names the line)."), ("error reading form", "script_form_load_error", "Cancel", "A script form's .dfm references a class or property this engine does not know (e.g. 'Class TApplicationEvents not found'). Cancel aborts the form load cleanly; Ignore would half-load the form. Fires on open and again on each editor touch until the project is closed."), ("select item to run", "run_script_picker", "Cancel",@@ -375,6 +375,9 @@ pub fn reap_once() -> usize { // why Altium refused to quit was pressed away five times in a row, unread (2026-09-21). let mut err_shot = Value::Null; {+ // the message is painted a moment after the box appears: a shot taken at once showed an empty+ // Error box (2026-10-22)+ std::thread::sleep(Duration::from_millis(600)); let r = ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10); if let Some(img) = r.get("image").or_else(|| r.get("base64")).and_then(|i| i.as_str()) { err_shot = json!(img);
bridge-rs/src/main.rs+3−1@@ -7,6 +7,7 @@ mod agent; mod board; mod bom; mod sch;+mod design; mod dialogs; mod health; mod install;@@ -25,6 +26,7 @@ mod shot; mod support; mod surfaces; mod taskbar;+mod variants; mod verbs; mod win32; @@ -34,7 +36,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.59";+pub const BRIDGE_VERSION: &str = "0.2.60"; 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/manufacturing.rs+80−3@@ -67,7 +67,24 @@ pub fn outjob_path(prj: &str) -> String { /// Write the project's manufacturing OutJob (only when its text changes: an open document rewritten on /// disk makes Altium ask to reload it). Answers its path.-pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> {+pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> Result<String, Value> { write_outjob_layers(prj, pcb, bom_columns, &[]) }++/// Altium's V7 layer ids for the inner copper the template does not plot: mid N and internal plane N.+pub fn inner_layer_ids(stack: &Value) -> Vec<i64> {+ stack["layers"].as_array().cloned().unwrap_or_default().iter().filter_map(|l| {+ if let Some(n) = l["mid"].as_i64() { Some(0x0100_0001 + n) } else { l["plane"].as_i64().map(|n| 0x0101_0000 + n) }+ }).collect()+}++/// Add layers to a Gerber configuration's Plot.Set (SerializeLayerHash ...,id~1,...).+pub fn plot_with(rec: &str, ids: &[i64]) -> String {+ let cur = rec.split('|').find_map(|f| f.strip_prefix("Plot.Set=")).unwrap_or("SerializeLayerHash.Version~2,ClassName~TLayerToBoolean").to_string();+ let mut v = cur.clone();+ for id in ids { if !cur.split(',').any(|x| x == format!("{id}~1")) { v.push_str(&format!(",{id}~1")); } }+ field(rec, "Plot.Set", &v)+}++pub fn write_outjob_layers(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>, inner: &[i64]) -> Result<String, Value> { let (prj, pcb) = (prj.to_string(), pcb.to_string()); // Altium's own OutJob templates, from the running install's Public Documents folder let read = |f: &str| crate::support::altium_public_file(&format!(r"OutputJobs\{f}"))@@ -92,6 +109,13 @@ pub fn write_outjob(prj: &str, pcb: &str, bom_columns: Option<&Vec<Value>>) -> R set(o, &format!("OutputDocumentPath{n}"), if kind == "BOM_PartType" { "" } else { &pcb_name }); set(o, &format!("OutputEnabled{n}"), "1"); set(o, &format!("OutputEnabled{n}_OutputMedium1"), &n.to_string());+ if kind == "Gerber" && !inner.is_empty() {+ // the template plots top, mid 1-2 and bottom: a board's internal planes and deeper mid layers were+ // missing from its Gerbers (a 4-layer board came out as 2 copper files, 2026-10-22)+ if let Some(e) = o.iter_mut().find(|(k, v)| k.starts_with(&format!("Configuration{n}_Item")) && v.contains("Record=GerberView")) {+ e.1 = plot_with(&e.1, inner);+ }+ } if kind == "Pick Place" { // a CSV in millimetres, what an assembly house loads if let Some(e) = o.iter_mut().find(|(k, v)| k.starts_with(&format!("Configuration{n}_Item")) && v.contains("Record=PickPlaceView")) {@@ -133,7 +157,11 @@ pub fn export_manufacturing(args: &Value) -> Value { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) and pcbPath (.PcbDoc) are required", "_hint": "altium_build_schematic writes the project; the board must be one of its documents."}); }- let outjob = match write_outjob(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array())) { Ok(p) => p, Err(e) => return e };+ // the board's inner copper (planes, mid layers) goes into the Gerber plot set+ let inner = if crate::support::must_open(&pcb, 60).is_none() {+ crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60)).get("data").map(inner_layer_ids).unwrap_or_default()+ } else { vec![] };+ let outjob = match write_outjob_layers(&prj, &pcb, args.get("bomColumns").and_then(|v| v.as_array()), &inner) { Ok(p) => p, Err(e) => return e }; // an OutJob generates for the project it belongs to: one that is not in the project runs for Free // Documents and writes nothing (measured 2026-10-15) let oj_name = outjob.rsplit(['\\', '/']).next().unwrap_or(&outjob).to_lowercase();@@ -156,11 +184,57 @@ pub fn export_manufacturing(args: &Value) -> Value { let mut a = json!({"outjobPath": outjob, "projectPath": prj, "medium": "Manufacturing"}); if let Some(v) = args.get("timeout") { a["timeout"] = v.clone(); } let mut r = outputs::run_outjob(&a);+ // a fab house reads the board's outline from the Gerbers: a scripted board has its shape but nothing on any+ // outline layer, and its GM1/GKO came out empty with no warning (2026-10-22). Draw it on Mechanical 1 and+ // generate again, unless told not to.+ let mut outline = outline_check(&r);+ if outline["present"] == json!(false) && args.get("addOutline").and_then(|v| v.as_bool()) != Some(false) {+ if crate::support::must_open(&pcb, 60).is_none() {+ let d = crate::agent::call("outline_tracks", &[("op", "draw".into()), ("mech", "1".into())], None, std::time::Duration::from_secs(60));+ outline["added"] = d.get("data").cloned().unwrap_or(d);+ let _ = crate::agent::call("close_document", &[("path", outjob.clone())], None, std::time::Duration::from_secs(60));+ r = outputs::run_outjob(&a);+ let after = outline_check(&r);+ outline["present"] = after["present"].clone();+ outline["layers"] = after["layers"].clone();+ }+ } r["outjob"] = json!(outjob);- r["_hint"] = json!("files[] are the manufacturing set under outputDir (Gerber, NC Drill, Pick Place, Bill of Materials folders); pull them with ab's pull_file and check them against the board before handing them over.");+ r["outjobNote"] = json!("the bridge's own OutJob for this project, rewritten from Altium's templates on every run: customise outputs in another OutJob and use altium_run_outjob");+ let ok_outline = outline["present"] == json!(true);+ let copper_files: Vec<String> = r["files"].as_array().cloned().unwrap_or_default().iter().filter_map(|f| f.as_str()).filter(|f| {+ let e = f.rsplit('.').next().unwrap_or("").to_ascii_uppercase();+ e == "GTL" || e == "GBL" || (e.len() > 1 && (e.starts_with('G') && e[1..].chars().all(|c| c.is_ascii_digit())) ) || (e.starts_with("GP") && e[2..].chars().all(|c| c.is_ascii_digit()) && e.len() > 2)+ }).map(str::to_string).collect();+ let want_copper = 2 + inner.len();+ r["fabChecks"] = json!({"outline": outline, "copperLayers": {"board": want_copper, "gerbers": copper_files.len(), "files": copper_files, "ok": copper_files.len() >= want_copper}});+ r["_hint"] = json!(if ok_outline {+ if outline.get("added").is_some() { "files[] are the manufacturing set under outputDir. The board had no outline on any Gerber layer: the bridge drew its shape on Mechanical 1 (in memory; altium_save keeps it) and generated again, so GM1 carries it." }+ else { "files[] are the manufacturing set under outputDir (Gerber, NC Drill, Pick Place, Bill of Materials folders); pull them with ab's pull_file and check them against the board before handing them over." }+ } else { "NO BOARD OUTLINE in the Gerbers (GM1/GKO empty): a fab house will reject this set. altium_board_outline draws the board shape on a mechanical layer; then run this again." });+ if r["fabChecks"]["copperLayers"]["ok"] == json!(false) { r["_hint"] = json!("COPPER LAYERS MISSING from the Gerbers: fabChecks.copperLayers counts fewer copper files than the board has. Check the stack (altium_layer_stack) and report it."); } r } +/// Does any outline Gerber (mechanical or keep-out) carry a draw? Reads the files run_outjob listed.+fn outline_check(r: &Value) -> Value {+ let dir = r["outputDir"].as_str().unwrap_or("").to_string();+ let mut layers = Vec::new();+ let mut present = false;+ for f in r["files"].as_array().cloned().unwrap_or_default() {+ let name = f.as_str().unwrap_or("").to_string();+ let ext = name.rsplit('.').next().unwrap_or("").to_ascii_uppercase();+ // the board outline layers a fab reads: keep-out (GKO) and Mechanical 1 (GM1); GM13/GM15 carry component bodies+ if ext == "GKO" || ext == "GM1" || ext == "GML" || ext == "GBR" && name.to_ascii_lowercase().contains("outline") {+ let text = std::fs::read_to_string(std::path::Path::new(&dir).join(&name)).unwrap_or_default();+ let draws = text.matches("D01").count() + text.matches("D1*").count();+ if draws > 0 { present = true; }+ layers.push(json!({"file": name, "draws": draws}));+ }+ }+ json!({"present": if layers.is_empty() && dir.is_empty() { Value::Null } else { json!(present) }, "layers": layers})+}+ #[cfg(test)] mod tests { use super::*;@@ -174,5 +248,8 @@ mod tests { assert!(!o.iter().any(|(k, _)| k.contains("Medium"))); assert_eq!(field("A=1|Units=Imperial", "Units", "Metric"), "A=1|Units=Metric"); assert_eq!(field("A=1", "B", "2"), "A=1|B=2");+ let p = plot_with("Plot.Set=SerializeLayerHash.Version~2,ClassName~TLayerToBoolean,16777217~1|Record=GerberView", &[16777217, 16842753]);+ assert_eq!(p, "Plot.Set=SerializeLayerHash.Version~2,ClassName~TLayerToBoolean,16777217~1,16842753~1|Record=GerberView");+ assert_eq!(inner_layer_ids(&serde_json::json!({"layers": [{"name": "Top"}, {"plane": 1}, {"mid": 1}, {"plane": 2}, {"name": "Bottom"}]})), vec![16842753, 16777218, 16842754]); } }
bridge-rs/src/misc.rs+4−1@@ -220,7 +220,10 @@ pub fn panel(args: &Value) -> Value { /// What the standing dialog reaper has pre-empted. pub fn dialog_log(args: &Value) -> Value { let limit = args.get("limit").and_then(|l| l.as_u64()).unwrap_or(25) as usize;- let shots = if args.get("shots").and_then(|b| b.as_bool()) == Some(true) { json!(dialogs::reaped_shots()) } else { Value::Null };+ let shots = if args.get("shots").and_then(|b| b.as_bool()) == Some(true) {+ let all = dialogs::reaped_shots();+ json!(all[all.len().saturating_sub(limit)..].to_vec())+ } else { Value::Null }; json!({"success": true, "reaped": dialogs::reaped_log(limit), "running": true, "shots": shots, "_shotsHint": "shots:true returns PNGs (base64) of the last 8 boxes the reaper pressed. Altium paints its message text, so the picture is the only copy of what a box SAID: read it whenever a verb is refused for no visible reason.", "_hint": "Anything NOT listed here that reached the user is either a save prompt (never auto-answered, by design) or a dialog missing from the catalog — report it so it can be added with a safe action."})
bridge-rs/src/routing.rs+1@@ -748,6 +748,7 @@ pub fn routing_drc(args: &Value) -> Value { let d = match r.get("data") { Some(d) if d.get("ok").and_then(|v| v.as_bool()) == Some(true) => d.clone(), _ => return r }; let viol: Vec<Value> = arr(&d, "violations").iter().map(|v| json!({ "rule": v[0], "description": v[1],+ "objects": (6..8).filter_map(|i| v.get(i).and_then(|o| o.as_str()).filter(|t| !t.is_empty()).map(str::to_string)).collect::<Vec<_>>(), "at": {"x": json!(((v[2].as_f64().unwrap_or(0.0) + v[4].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0), "y": json!(((v[3].as_f64().unwrap_or(0.0) + v[5].as_f64().unwrap_or(0.0)) / 2.0 / PER_MM * 1000.0).round() / 1000.0)}})).collect(); let errors = d["violationCount"].as_i64().unwrap_or(0);
bridge-rs/src/sch.rs+122−11@@ -256,14 +256,16 @@ pub fn update_pcb(args: &Value) -> Value { let (add, remove) = part_diff(&sheet_refs, &board_refs); let preview = json!({"addParts": add, "removeParts": remove, "sheetParts": sheet_refs.len(), "boardParts": board_refs.len()}); let execute = args.get("execute").and_then(|b| b.as_bool()) == Some(true);- if !execute {+ if !execute && args.get("detail").and_then(|b| b.as_bool()) != Some(true) { return json!({"success": true, "executed": false, "preview": preview, "elapsedSec": support::elapsed_round(t0),- "_hint": if remove.is_empty() { "Preview only. Parameter, footprint and net changes are not listed here; altium_update_pcb {execute:true} runs the change order.".to_string() }- else { format!("Preview only. The change order would REMOVE {} part(s) from the board that are on no sheet ({}): board-only test pads, fiducials or mounting holes usually. Put them on a sheet, or run with allowRemovals:true if they really should go.", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", ")) }});+ "_hint": if remove.is_empty() { "Quick preview (parts only). detail:true opens Altium's change order and lists EVERY change (parameters, footprints, nets, classes, rules) without running it; execute:true runs it.".to_string() }+ else { format!("Quick preview. The change order would REMOVE {} part(s) from the board that are on no sheet ({}): board-only test pads, fiducials or mounting holes usually. execute:true with keepBoardOnly:true leaves them on the board; allowRemovals:true deletes them. detail:true lists every change first.", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", ")) }}); }- if !remove.is_empty() && args.get("allowRemovals").and_then(|b| b.as_bool()) != Some(true) {+ let keep_board_only = args.get("keepBoardOnly").and_then(|b| b.as_bool()) == Some(true);+ // (a detail preview opens the change order but never runs it, so it needs no permission to remove)+ if execute && !remove.is_empty() && !keep_board_only && args.get("allowRemovals").and_then(|b| b.as_bool()) != Some(true) { return json!({"success": false, "errorCode": "would_remove_parts", "executed": false, "preview": preview,- "_hint": format!("Not run: the change order would delete {} board part(s) that are on no sheet ({}). Add them to a sheet (altium_sch_place_part), or pass allowRemovals:true to delete them.", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", "))});+ "_hint": format!("Not run: the change order would delete {} board part(s) that are on no sheet ({}). keepBoardOnly:true runs it and leaves them on the board; allowRemovals:true deletes them; or add them to a sheet (altium_sch_place_part).", remove.len(), remove.iter().take(10).cloned().collect::<Vec<_>>().join(", "))}); } // from the board, Import Changes (Action=UpdateMe) is the change order; from a sheet, Action=UpdateOther // opened nothing on AD 26.11.1 (2026-10-21)@@ -315,9 +317,23 @@ pub fn update_pcb(args: &Value) -> Value { "_hint": "No Engineering Change Order opened: Altium found no differences between the sheets and the board that its change order carries. Parts, nets and parameters are carried. If you expected changes, check the sheets are saved and that no second project with the same file names is open."}); }; let hwnd = eco.get("hwnd").and_then(|h| h.as_i64()).unwrap_or(0);- // the change order's first page, as Altium lists it (group rows carry counts: 'Add Components(1)')- let tree = ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 14, "maxNodes": 400}), &[], 30);- let rows = eco_rows(&tree);+ // every row, unticking what must stay on the board: the parts when keepBoardOnly, and the board's own net+ // classes, pairs, rules and rooms unless removeBoardOnlyClasses (net classes made with altium_net_classes+ // were about to be removed by the next change order, 2026-10-22)+ let keep_parts: Vec<String> = if keep_board_only { remove.clone() } else { vec![] };+ let keep_classes = args.get("removeBoardOnlyClasses").and_then(|b| b.as_bool()) != Some(true);+ let (rows, unticked) = eco_walk(hwnd, &keep_parts, keep_classes);+ steps.push(json!({"walked": rows.len(), "unticked": unticked.len()}));+ if !execute {+ let closed = match button_rect(hwnd, "Close") {+ Some((x, y)) => ab::call("desktop_click", json!({"hwnd": hwnd, "x": x, "y": y, "reason": "close the change order without running it"}), &[], 20).get("success").cloned(),+ None => None,+ };+ for _ in 0..20 { if agent::is_responsive() { break; } std::thread::sleep(Duration::from_millis(500)); }+ return json!({"success": true, "executed": false, "preview": preview, "changeOrder": rows, "wouldLeaveOut": unticked, "closed": closed,+ "elapsedSec": support::elapsed_round(t0),+ "_hint": "changeOrder is Altium's own change order, every row, grouped; it was closed without running. wouldLeaveOut is what execute would untick (board-only classes/rules by default; board-only parts with keepBoardOnly). execute:true runs it."});+ } let execute_r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "contains": "Execute Changes", "role": "button"}), &[], 60); steps.push(json!({"uiClick": "Execute Changes", "success": execute_r.get("success")})); // done when the list stops changing (Altium marks each row Done and scrolls to the end); the agent@@ -354,8 +370,8 @@ pub fn update_pcb(args: &Value) -> Value { steps.push(json!({"saveBoard": saved.get("success")})); let dl = crate::dialogs::scan_scoped(true); json!({"success": execute_r.get("success").and_then(|b| b.as_bool()) == Some(true), "executed": true, "projectPath": prj, "board": board, "preview": preview,- "changeOrder": rows, "afterExecute": rows_after, "steps": steps, "dialogs": dl, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",- "_hint": "The change order ran. New parts land beside the board outline: place them (altium_move_parts) and check with altium_routing_drc. changeOrder lists the dialog's first page (groups carry counts)."})+ "changeOrder": rows, "leftOut": unticked, "afterExecute": rows_after, "steps": steps, "dialogs": dl, "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background",+ "_hint": "The change order ran. New parts land beside the board outline: place them (altium_move_parts) and check with altium_routing_drc. changeOrder is every row of the change order; leftOut is what was unticked (board-only classes, rules and, with keepBoardOnly, parts)."}) } /// A dialog button's centre (screen pixels) from its UI Automation rect.@@ -371,6 +387,89 @@ fn button_rect(hwnd: i64, name: &str) -> Option<(Value, Value)> { walk(&root, name).filter(|(x, y)| x.is_number() && y.is_number()) } +/// One visible row of the change order: its text (" True Remove TP1 From X", or a group " Remove Components(7)")+/// and where it sits on screen.+fn eco_page(hwnd: i64) -> Vec<(String, Value)> {+ fn walk(n: &Value, out: &mut Vec<(String, Value)>) {+ if n.get("role").and_then(|r| r.as_str()) == Some("ListItem") {+ if let (Some(v), Some(r)) = (n.get("value").and_then(|v| v.as_str()), n.get("rect")) {+ let v = v.split_whitespace().collect::<Vec<_>>().join(" ");+ if !v.is_empty() { out.push((v, r.clone())); }+ }+ }+ for c in n.get("children").and_then(|c| c.as_array()).into_iter().flatten() { walk(c, out); }+ }+ let tree = ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 14, "maxNodes": 600}), &[], 30);+ 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);+ let mut out = Vec::new();+ walk(&root, &mut out);+ out+}++/// Should this enabled row be unticked? Board-only parts (keep_parts) and, unless told otherwise, the board's own+/// net classes, differential pairs, rules and rooms, which the change order removes because no sheet has them.+pub fn eco_should_untick(group: &str, text: &str, keep_parts: &[String], keep_classes: bool) -> bool {+ let g = group.to_ascii_lowercase();+ let body = text.trim_start_matches("True ").trim_start_matches("False ");+ if keep_classes && ["remove net classes", "remove differential pairs", "remove rules", "remove rooms", "remove component classes", "remove differential pair classes"].iter().any(|k| g.starts_with(k)) {+ return true;+ }+ let des = body.strip_prefix("Remove ").and_then(|r| r.split_whitespace().next()).unwrap_or("");+ if g.starts_with("remove components") { return keep_parts.iter().any(|k| k.eq_ignore_ascii_case(des)); }+ if g.starts_with("remove pins from nets") {+ let part = des.rsplit_once('-').map(|(p, _)| p).unwrap_or(des);+ return keep_parts.iter().any(|k| k.eq_ignore_ascii_case(part));+ }+ false+}++/// Walk EVERY row of the change order (the grid is virtualized and UIA cannot scroll it: select a row and page+/// down), unticking what eco_should_untick names. Answers the groups with their rows, and what was unticked.+fn eco_walk(hwnd: i64, keep_parts: &[String], keep_classes: bool) -> (Vec<Value>, Vec<String>) {+ let mut seen: Vec<String> = Vec::new();+ let mut unticked = Vec::new();+ let mut groups: Vec<Value> = Vec::new();+ let mut selected = false;+ for _ in 0..60 {+ let page = eco_page(hwnd);+ if page.is_empty() { break; }+ // compared without the Enable column: a row unticked here reads False on the next page+ let key = |t: &str| t.trim_start_matches("True ").trim_start_matches("False ").to_string();+ let texts: Vec<String> = page.iter().map(|(t, _)| key(t)).collect();+ // the page continues what was seen: skip the overlap+ let mut k = 0;+ for cand in (1..=texts.len().min(seen.len())).rev() {+ if seen[seen.len() - cand..].iter().zip(texts[..cand].iter()).all(|(a, b)| a == b) { k = cand; break; }+ }+ if k == texts.len() && !seen.is_empty() { break; }+ for (t, r) in &page[k..] {+ seen.push(key(t));+ let data = t.starts_with("True ") || t.starts_with("False ");+ if !data { groups.push(json!({"group": t, "rows": []})); continue; }+ let group = groups.last().and_then(|g| g["group"].as_str()).unwrap_or("").to_string();+ let mut enabled = t.starts_with("True ");+ if enabled && eco_should_untick(&group, t, keep_parts, keep_classes) {+ let (x, y) = (r["x"].as_f64().unwrap_or(0.0) + 3.5 * r["h"].as_f64().unwrap_or(0.0), r["centerY"].as_f64().unwrap_or(0.0));+ let c = ab::call("desktop_click", json!({"hwnd": hwnd, "x": x.round() as i64, "y": y.round() as i64, "reason": "leave a board-only item out of the change order"}), &[], 20);+ if c.get("success").and_then(|b| b.as_bool()) == Some(true) { enabled = false; unticked.push(t.trim_start_matches("True ").to_string()); selected = true; }+ }+ if let Some(g) = groups.last_mut() {+ if let Some(a) = g["rows"].as_array_mut() { if a.len() < 300 { a.push(json!({"change": t.trim_start_matches("True ").trim_start_matches("False "), "enabled": enabled})); } }+ }+ }+ // select a row (its text, not its checkbox) so the keyboard pages the grid+ if !selected {+ if let Some((_, r)) = page.iter().find(|(t, _)| t.starts_with("True ") || t.starts_with("False ")) {+ let _ = ab::call("desktop_click", json!({"hwnd": hwnd, "x": (r["x"].as_f64().unwrap_or(0.0) + r["w"].as_f64().unwrap_or(0.0) * 0.45).round() as i64, "y": r["centerY"].as_f64().unwrap_or(0.0).round() as i64, "reason": "select a change-order row to page through it"}), &[], 20);+ selected = true;+ }+ }+ let _ = ab::call("desktop_press_key", json!({"key": "PageDown", "hwnd": hwnd, "reason": "page through the change order to read every row"}), &[], 20);+ std::thread::sleep(Duration::from_millis(400));+ }+ (groups, unticked)+}+ /// The change order's visible rows: their text, as the grid shows it. fn eco_rows(tree: &Value) -> Vec<String> { fn walk(n: &Value, out: &mut Vec<String>) {@@ -459,7 +558,7 @@ pub fn export_pdf(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()}); } let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0); let _ = std::fs::remove_dir_all(&tmp);- json!({"success": bytes > 0, "output": out, "outputBytes": bytes, "sheets": project.sheets.len(), "board": with_board, "healthCheck": r.get("healthCheck"), "elapsedSec": support::elapsed_round(t0),+ json!({"success": bytes > 0, "pdfPath": out, "outputBytes": bytes, "sheets": project.sheets.len(), "board": with_board, "healthCheck": r.get("healthCheck"), "elapsedSec": support::elapsed_round(t0), "windowEtiquette": "background", "_hint": "One PDF of every sheet (and the board's composite print with board:true). Pull it with ab's pull_file."}) } @@ -475,6 +574,18 @@ fn walk_pdf(dir: &Path) -> Option<std::path::PathBuf> { #[cfg(test)] mod tests {+ #[test]+ fn the_change_order_leaves_board_only_items_alone() {+ let keep = vec!["TP1".to_string()];+ assert!(eco_should_untick("Remove Components(7)", "True Remove TP1 From B.PcbDoc", &keep, true));+ assert!(!eco_should_untick("Remove Components(7)", "True Remove TP10 From B.PcbDoc", &keep, true));+ assert!(eco_should_untick("Remove Pins From Nets(7)", "True Remove TP1-1 from SW In B.PcbDoc", &keep, true));+ assert!(!eco_should_untick("Remove Pins From Nets(7)", "True Remove R1-1 from SW In B.PcbDoc", &keep, true));+ assert!(eco_should_untick("Remove Net Classes(2)", "True Remove Power From B.PcbDoc", &[], true));+ assert!(!eco_should_untick("Remove Net Classes(2)", "True Remove Power From B.PcbDoc", &[], false));+ assert!(!eco_should_untick("Add Components(1)", "True Add U3 To B.PcbDoc", &keep, true));+ }+ use super::*; #[test]
bridge-rs/src/surfaces.rs+2@@ -53,6 +53,8 @@ pub fn open_then(path: &str, commands: &[(&str, &str)], timeout: Duration, label std::thread::sleep(Duration::from_millis(600)); let dl = dialogs::scan_scoped(true); let frame = shot::shot_of_main();+ // a cached snapshot answered the PREVIOUS document's title (a SchDoc for a board just shown, 2026-10-22)+ instance::invalidate_snapshot(); let mw = instance::main_window().unwrap_or(json!({})); let ok = dl.is_empty(); json!({"success": ok, "surface": label, "path": path, "steps": steps,
bridge-rs/src/variants.rsadded+207@@ -0,0 +1,207 @@+//! Assembly variants (which parts are fitted on which build) written into the project file, since Altium's+//! script API reads variants but cannot make them. Only the [ProjectVariantN] sections change; Altium reloads+//! the project and the bridge reads back what Altium holds.+use crate::support::{self, s};+use crate::{agent, bom};+use serde_json::{json, Value};+use std::collections::HashMap;+use std::path::Path;+use std::time::Duration;++/// One [ProjectVariantN] block: its key=value lines in file order.+#[derive(Debug, Clone, PartialEq)]+pub struct Block { pub lines: Vec<(String, String)> }++impl Block {+ fn get(&self, k: &str) -> Option<&str> { self.lines.iter().find(|(a, _)| a == k).map(|(_, b)| b.as_str()) }+ pub fn name(&self) -> String { self.get("Description").unwrap_or("").to_string() }+ /// designator (upper case) -> the whole Variation value, in order+ pub fn variations(&self) -> Vec<(String, String)> {+ self.lines.iter().filter(|(k, _)| k.starts_with("Variation") && k.len() > 9 && k[9..].chars().all(|c| c.is_ascii_digit()))+ .filter_map(|(_, v)| {+ let f: HashMap<&str, &str> = v.split('|').filter_map(|x| x.split_once('=')).collect();+ f.get("Designator").map(|d| (d.to_ascii_uppercase(), v.clone()))+ }).collect()+ }+ fn has_param_variations(&self) -> bool { self.lines.iter().any(|(k, v)| k.starts_with("ParamVariation") && v != "0" || k == "ParameterCount" && v != "0") }+}++/// The file split around its variant blocks: (text before the first, the blocks, text after them with any+/// non-variant sections that sat between). Line endings are kept as CRLF on output.+pub fn split(text: &str) -> (Vec<String>, Vec<Block>, Vec<String>) {+ let mut before = Vec::new();+ let mut after = Vec::new();+ let mut blocks: Vec<Block> = Vec::new();+ let mut in_variant = false;+ let mut seen = false;+ for raw in text.lines() {+ let line = raw.trim_end_matches('\r');+ let t = line.trim();+ if t.starts_with('[') && t.ends_with(']') {+ in_variant = t[1..t.len() - 1].starts_with("ProjectVariant");+ if in_variant { blocks.push(Block { lines: vec![] }); seen = true; continue; }+ }+ if in_variant {+ if let Some((k, v)) = line.split_once('=') { blocks.last_mut().unwrap().lines.push((k.to_string(), v.to_string())); }+ continue;+ }+ if seen { after.push(line.to_string()) } else { before.push(line.to_string()) }+ }+ (before, blocks, after)+}++pub fn join(before: &[String], blocks: &[Block], after: &[String]) -> String {+ let mut out: Vec<String> = before.to_vec();+ while out.last().map(|l| l.trim().is_empty()).unwrap_or(false) { out.pop(); }+ for (i, b) in blocks.iter().enumerate() {+ out.push(String::new());+ out.push(format!("[ProjectVariant{}]", i + 1));+ for (k, v) in &b.lines { out.push(format!("{k}={v}")); }+ }+ let mut tail = after.to_vec();+ while tail.first().map(|l| l.trim().is_empty()).unwrap_or(false) { tail.remove(0); }+ if !tail.is_empty() { out.push(String::new()); out.extend(tail); }+ let mut s = out.join("\r\n");+ s.push_str("\r\n");+ s+}++/// A variant with these variations (designator, uniqueId path, kind, alternate part), keeping the block's+/// other keys when it already exists.+pub fn with_variations(existing: Option<&Block>, name: &str, vars: &[(String, String, i64, String)]) -> Block {+ let mut lines: Vec<(String, String)> = match existing {+ Some(b) => b.lines.iter().filter(|(k, _)| !(k.starts_with("Variation"))).cloned().collect(),+ None => vec![("Description".into(), name.into()), ("AllowFabrication".into(), "0".into()), ("ParameterCount".into(), "0".into())],+ };+ lines.push(("VariationCount".into(), vars.len().to_string()));+ for (i, (d, u, k, alt)) in vars.iter().enumerate() {+ lines.push((format!("Variation{}", i + 1), format!("Designator={d}|UniqueId={u}|Kind={k}|AlternatePart={alt}")));+ }+ Block { lines }+}++fn list_of(v: Option<&Value>) -> Vec<String> {+ match v {+ Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str()).map(|x| x.trim().to_ascii_uppercase()).filter(|x| !x.is_empty()).collect(),+ Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_ascii_uppercase()).filter(|x| !x.is_empty()).collect(),+ _ => vec![],+ }+}++fn summary(blocks: &[Block]) -> Vec<Value> {+ blocks.iter().map(|b| {+ let vars = b.variations();+ let kind_of = |v: &str| v.split('|').find_map(|x| x.strip_prefix("Kind=")).and_then(|k| k.parse::<i64>().ok()).unwrap_or(0);+ json!({"name": b.name(),+ "notFitted": vars.iter().filter(|(_, v)| kind_of(v) == 1).map(|(d, _)| d.clone()).collect::<Vec<_>>(),+ "alternates": vars.iter().filter(|(_, v)| kind_of(v) == 2).map(|(d, v)| json!({"designator": d, "part": v.split('|').find_map(|x| x.strip_prefix("AlternatePart=")).unwrap_or("")})).collect::<Vec<_>>()})+ }).collect()+}++fn read_back(prj: &str) -> Value {+ let r = agent::call("project_variants", &[("path", prj.to_string())], None, Duration::from_secs(60));+ r.get("data").cloned().unwrap_or(r)+}++/// altium_variants {projectPath, name?, notFitted?, fitted?, remove?}+pub fn variants(args: &Value) -> Value {+ let Some(prj) = s(args, "projectPath") else { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"}) };+ let Ok(bytes) = std::fs::read(&prj) else { return json!({"success": false, "errorCode": "not_found", "error": format!("cannot read {prj}")}) };+ let text = String::from_utf8_lossy(&bytes).to_string();+ let (before, mut blocks, after) = split(&text);+ let Some(name) = s(args, "name") else {+ return json!({"success": true, "projectPath": prj, "variants": summary(&blocks),+ "_hint": "Make or change one with {name, notFitted:[designators], fitted:[designators]}; altium_bom {variant} gives that build's BOM."});+ };+ if name.contains(['\n', '[', ']']) { return json!({"success": false, "errorCode": "bad_args", "error": "a variant name cannot hold brackets or newlines"}); }+ if let Some(g) = support::need_licensed() { return g; }+ let idx = blocks.iter().position(|b| b.name().eq_ignore_ascii_case(&name));+ let remove = args.get("remove").and_then(|b| b.as_bool()) == Some(true);+ let not_fitted = list_of(args.get("notFitted").or_else(|| args.get("dnp")));+ let fitted = list_of(args.get("fitted"));+ if !remove && not_fitted.is_empty() && fitted.is_empty() {+ return json!({"success": false, "errorCode": "bad_args", "error": "give notFitted and/or fitted designators, or remove:true"});+ }+ if let Some(i) = idx { if blocks[i].has_param_variations() && !remove {+ return json!({"success": false, "errorCode": "has_parameter_variations", "error": format!("variant {name} carries parameter variations; edit it in Altium's Variant Management"), "variants": summary(&blocks)});+ } }+ let project = bom::parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new(".")));+ // each part's unique id from its sheet (a variation names the part by it)+ let mut uid: HashMap<String, String> = HashMap::new();+ if !not_fitted.is_empty() {+ for sheet in &project.sheets {+ if let Some(e) = support::must_open(sheet, 60) { return e; }+ let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "0".into()), ("limit", "5000".into())], None, Duration::from_secs(120));+ for c in r.get("data").and_then(|d| d.get("components")).and_then(|c| c.as_array()).cloned().unwrap_or_default() {+ if let (Some(d), Some(u)) = (c["designator"].as_str(), c["uniqueId"].as_str()) { uid.entry(d.to_ascii_uppercase()).or_insert(u.to_string()); }+ }+ }+ let missing: Vec<&String> = not_fitted.iter().filter(|d| !uid.contains_key(*d)).collect();+ if !missing.is_empty() { return json!({"success": false, "errorCode": "designator_not_found", "notOnSheets": missing, "_hint": "Designators must be on the project's sheets (annotate '?' parts first)."}); }+ }+ if remove {+ if let Some(i) = idx { blocks.remove(i); } else { return json!({"success": true, "removed": false, "variants": summary(&blocks)}); }+ } else {+ let mut vars: Vec<(String, String, i64, String)> = idx.map(|i| blocks[i].variations()).unwrap_or_default().into_iter()+ .filter(|(d, _)| !fitted.contains(d) && !not_fitted.contains(d))+ .map(|(_, v)| {+ let f: HashMap<&str, &str> = v.split('|').filter_map(|x| x.split_once('=')).collect();+ (f.get("Designator").unwrap_or(&"").to_string(), f.get("UniqueId").unwrap_or(&"").to_string(), f.get("Kind").and_then(|k| k.parse().ok()).unwrap_or(1), f.get("AlternatePart").unwrap_or(&"").to_string())+ }).collect();+ for d in ¬_fitted { vars.push((d.clone(), format!("\\{}", uid[d]), 1, String::new())); }+ let b = with_variations(idx.map(|i| &blocks[i]), &name, &vars);+ match idx { Some(i) => blocks[i] = b, None => blocks.push(b) }+ }+ // Altium keeps the project in memory: save its documents and the project, close it, write, reopen+ for d in project.sheets.iter().chain(project.boards.iter()) { let _ = crate::surfaces::save(&json!({"filePath": d})); }+ let _ = crate::surfaces::save(&json!({"filePath": prj}));+ let _ = agent::call("close_document", &[("path", prj.clone())], None, Duration::from_secs(30));+ if let Err(e) = std::fs::write(&prj, join(&before, &blocks, &after)) {+ return json!({"success": false, "errorCode": "write_failed", "error": e.to_string()});+ }+ let reopened = crate::surfaces::open_document(&json!({"filePath": prj}));+ let altium = read_back(&prj);+ let took = altium.get("variants").and_then(|v| v.as_array()).map(|v| {+ let have = v.iter().any(|x| x["name"].as_str().map(|n| n.eq_ignore_ascii_case(&name)).unwrap_or(false));+ have != remove+ }).unwrap_or(false);+ json!({"success": took, "projectPath": prj, "variant": name, "removed": remove, "variants": summary(&blocks), "altium": altium,+ "reopened": reopened.get("success"), "errorCode": if took { Value::Null } else { json!("not_read_back") },+ "_hint": if took { "Altium holds the variant. altium_bom {variant} gives that build's BOM; pick it in an OutJob's variant to build its outputs. The board marks not-fitted parts when the variant is current (View > Variant in Altium)." }+ else { "The project file was written but Altium did not report the change: open the project in Altium (altium_open_project) and check Project > Variants." }})+}++#[cfg(test)]+mod tests {+ use super::*;+ const PRJ: &str = "[Design]\r\nVersion=1.0\r\n\r\n[Document1]\r\nDocumentPath=A.SchDoc\r\n\r\n[ProjectVariant1]\r\nDescription=Lite\r\nAllowFabrication=0\r\nParameterCount=0\r\nVariationCount=1\r\nVariation1=Designator=R2|UniqueId=\\ABC|Kind=1|AlternatePart=\r\n\r\n[Parameter1]\r\nName=Rev\r\nValue=A\r\n";++ #[test]+ fn variant_blocks_round_trip_and_the_rest_is_kept() {+ let (b, v, a) = split(PRJ);+ assert_eq!(v.len(), 1);+ assert_eq!(v[0].name(), "Lite");+ assert_eq!(join(&b, &v, &a), PRJ);+ }++ #[test]+ fn a_new_variant_is_numbered_after_the_others() {+ let (b, mut v, a) = split(PRJ);+ v.push(with_variations(None, "Full", &[("C1".into(), "\\XYZ".into(), 1, String::new())]));+ let out = join(&b, &v, &a);+ assert!(out.contains("[ProjectVariant2]\r\nDescription=Full\r\nAllowFabrication=0\r\nParameterCount=0\r\nVariationCount=1\r\nVariation1=Designator=C1|UniqueId=\\XYZ|Kind=1|AlternatePart=\r\n"));+ assert!(out.ends_with("[Parameter1]\r\nName=Rev\r\nValue=A\r\n"));+ let p = bom::parse_project(&out, Path::new("."));+ assert_eq!(p.variants.len(), 2);+ assert_eq!(p.variants[1].1["C1"].0, 1);+ }++ #[test]+ fn editing_keeps_the_variant_keys_and_renumbers_variations() {+ let (_, v, _) = split(PRJ);+ let b = with_variations(Some(&v[0]), "Lite", &[("R2".into(), "\\ABC".into(), 1, String::new()), ("R3".into(), "\\DEF".into(), 1, String::new())]);+ assert_eq!(b.name(), "Lite");+ assert_eq!(b.variations().len(), 2);+ assert_eq!(b.get("VariationCount"), Some("2"));+ }+}
bridge-rs/src/verbs.rs+86−9@@ -20,7 +20,7 @@ pub const KNOWN: &[&str] = &[ "read_board", "board_summary", "rules_by_kind", "layers_in_use", "read_schematic", "cross_check", "review_board", "list_designators", "component_data", "component_pins", "list_nets", "net_connections", "layers", "copper_layers", "read_rules",- "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack",+ "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack", "net_classes", "diff_pairs", "board_outline", "set_track_width", // the live-board lane AI Flow drives "routing_state", "route_net", "move_parts", "place_vias", "remove_items", "ripup_nets", "apply_pours", "pour_state", "remove_pours", "repour", "routing_drc", "build_board", "set_outline", "set_design_rules", "zoom_to", "add_rule", "build_schematic", "export_manufacturing", "select_net", "silk_state", "apply_silkscreen", // surfaces@@ -28,7 +28,7 @@ pub const KNOWN: &[&str] = &[ "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo", "animate_view", "foreground", // outputs / project- "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step", "bom", "sheet_params", "sch_place_part", "sch_connect", "sch_edit", "annotate", "doc_params", "update_pcb", "export_pdf", "sch_zoom", "autoroute",+ "compile", "run_erc", "run_drc", "run_outjob", "export_gerber", "export_bom", "export_step", "bom", "variants", "sheet_params", "sch_place_part", "sch_connect", "sch_edit", "annotate", "doc_params", "update_pcb", "export_pdf", "sch_zoom", "autoroute", "save", "test_board", "board_shape", "pcb_report", "import_kicad", "install_extension", "extension_ping", // library authoring@@ -83,11 +83,11 @@ fn describe() -> Value { json!({"count": "int", "instances": "[{pid, key, edition, licensed, agentAlive, ...}]"}), 15, "With several running, other verbs REFUSE without instance:<pid|key|word> - guessing means acting inside another AI thread's Altium.", &["status"], &["Develop writes NO licence tail when it HOLDS the seat - blank licenceState there means licensed."], json!({})),- verb_doc("eval", "Run DelphiScript STATEMENTS inside Altium (Begin ... End;).", json!({"text": "string - statements only, no Var block", "timeout": "int seconds"}),- json!({"success": "bool", "note": "string"}), 30,- "Single quotes only; no Var block. Capture output by writing a file, not by returning a value.",+ verb_doc("eval", "Run a DelphiScript snippet inside Altium (optional Var block, then Begin ... End;) on the CURRENT document, or on filePath opened first.", json!({"text": "string - [Var X : Integer;] Begin ... End;", "filePath": "optional: the document to make current first (a .PcbDoc for PCBServer.GetCurrentPCBBoard)", "timeout": "int seconds"}),+ json!({"success": "bool (false + errorShot when Altium raised an error box)", "errorCode": "script_error", "errorShot": "PNG of Altium's error box"}), 30,+ "Single quotes only (no double quotes). A Var block before Begin is allowed. Capture output by writing a file, not by returning a value. Pass filePath: after an export the OutJob is current and GetCurrentPCBBoard is Nil.", &["run_command"], &["An undeclared identifier HALTS the engine with a modal that Try/Except cannot catch.", "ShowMessage blocks the loop behind a modal - never use it."], json!({"text": "Begin PCBServer.GetCurrentPCBBoard.ViewManager_FullUpdate; End;"})),- verb_doc("run_command", "Run an Altium process (Server:Command) through the agent.", json!({"command": "string - e.g. PCB:Zoom", "params": "string or object"}),+ verb_doc("run_command", "Run an Altium process (Server:Command) through the agent, on the CURRENT document or on filePath opened first.", json!({"command": "string - e.g. PCB:Zoom", "params": "string or object", "filePath": "optional: the document to make current first (PCB: processes need a board current)"}), json!({"success": "bool"}), 60, "Params are pipe-joined key=value pairs. altium_list_commands searches the catalog.", &["eval", "list_commands"], &["Some processes open a modal dialog and block the loop until it is answered."], json!({"command": "PCB:Zoom", "params": {"Action": "All"}})), verb_doc("list_installed_libraries", "Every library in Altium's Installed Libraries list (read-only).", json!({}), json!({"count": "int", "libraries": "[path]"}), 30,@@ -664,6 +664,41 @@ fn describe() -> Value { "To find which sheet holds a part, altium_bom (or altium_schematic_data per sheet) lists every designator with its sheet.", &["zoom_to", "schematic_data", "sch_edit"], &[], json!({"sheet": "C:/proj/Main.SchDoc", "designators": ["U1", "L1"]})),+ verb_doc("variants", "Assembly variants of a project (which parts are fitted on which build): list them, make or change one with not-fitted parts (or put parts back), or remove one. Altium's script API cannot create variants, so the bridge writes them into the .PrjPcb, reloads the project and reads back what Altium holds.",+ json!({"projectPath": "path to the .PrjPcb (required)", "name": "variant name; omit to list", "notFitted": "[designators] to mark Not Fitted (alias dnp)", "fitted": "[designators] to put back (their variation is dropped)", "remove": "bool - delete the variant"}),+ json!({"variants": "[{name, notFitted, alternates}]", "altium": "{variants:[{name, variations:[{designator, kind}]}]} as Altium reads them (kind 1 = not fitted, 2 = alternate)"}), 300,+ "altium_bom {variant} then gives that build's BOM (not-fitted parts dropped). Saves the project's documents, closes and reopens the project to load the change.",+ &["bom", "export_manufacturing", "run_outjob"],+ &["Variants with parameter variations are refused (edit those in Altium's Variant Management).", "Parts are named by their sheet's unique id: on a multi-channel (repeated) sheet every channel gets the same variation.", "Alternate parts are listed but not created here."],+ json!({"projectPath": "C:/proj/board.PrjPcb", "name": "Lite", "notFitted": ["R5", "C9", "J2"]})),+ verb_doc("board_outline", "Draw the board shape as tracks and arcs on a mechanical layer: the outline a fab house reads from the Gerbers. A board built by script has its shape but nothing on an outline layer, so its GM1/GKO Gerbers are empty.",+ json!({"filePath": "path to the .PcbDoc - optional", "mech": "mechanical layer number, default 1", "widthMm": "line width, default 0.1", "replace": "bool - draw even when the layer already holds primitives"}),+ json!({"layer": "string", "drawn": "segments drawn", "alreadyThere": "primitives the layer held"}), 60,+ "altium_export_manufacturing checks the outline Gerbers and calls this itself when they are empty (addOutline:false stops it). altium_save keeps the outline.",+ &["export_manufacturing", "set_outline", "board_shape"],+ &["A layer that already holds anything is left alone unless replace:true (it may be the outline already, drawn by hand)."],+ json!({"mech": 1, "widthMm": 0.1})),+ verb_doc("set_track_width", "Set the width of the EXISTING tracks and arcs on nets or a net class (rules only steer new routing). Widen power nets after routing, or thin a net that fails clearance.",+ json!({"filePath": "path to the .PcbDoc - optional", "nets": "[string] board net names", "netClass": "class name (its nets)", "widthMm": "number (required)", "layer": "top | bottom | mid N - optional, default every layer"}),+ json!({"changed": "int", "byNet": "{NET: count}", "drcAfter": "{errors, unconnected}"}), 120,+ "Run altium_routing_drc {full:true} after: wider copper can break clearance to neighbours.",+ &["net_classes", "route_net", "routing_drc", "add_rule"],+ &["Copper inside footprints (pads' own tracks) is left alone."],+ json!({"netClass": "Power", "widthMm": 0.5})),+ verb_doc("net_classes", "Net classes on the current board: list them with their nets, or make one (replacing a class of that name) with an optional width and clearance rule scoped to it, or remove one with its rules. Power nets wider, a high-voltage class with more clearance.",+ json!({"filePath": "path to the .PcbDoc - optional, else the current board", "name": "class name; omit to list", "nets": "[string] board net names (required to make one)", "width": "mm (0.5) or {min, preferred, max} in mm - optional rule 'Adom <name> width'", "clearance": "mm - optional rule 'Adom <name> clearance' (class to All)", "remove": "bool - delete the class and its two rules"}),+ json!({"classes": "[{name, superClass, nets}] (list)", "members": "int", "notOnBoard": "[string] nets that were not found", "rules": "[{kind, success, ..}]", "scope": "InNetClass('<name>')"}), 90,+ "Rules land first among their kind, so they outrank the board-wide ones. Existing tracks keep their width: re-route the class's nets or check with altium_run_drc. altium_save keeps it.",+ &["add_rule", "list_nets", "read_rules", "diff_pairs", "set_design_rules"],+ &["Net names must exist on the board (altium_list_nets); missing ones are reported in notOnBoard, not created.", "A class made on the board, not in the schematic, can be removed by a later change order if the project's comparator removes board-only classes."],+ json!({"name": "Power", "nets": ["VIN", "VOUT", "GND", "SW"], "width": 0.5, "clearance": 0.3})),+ verb_doc("diff_pairs", "Differential pairs on the current board: list them, define one from two nets (replacing a pair of that name), or remove one. Altium's differential-pair router and rules work on these.",+ json!({"filePath": "path to the .PcbDoc - optional", "name": "pair name; omit to list", "pos": "positive net (e.g. USB_P)", "neg": "negative net (e.g. USB_N)", "remove": "bool"}),+ json!({"pairs": "[{name, pos, neg}] (list)", "pair": "string", "replaced": "bool"}), 90,+ "Pair impedance and gap come from Altium's Differential Pairs Routing rule; set width/clearance for the pair's nets with altium_net_classes or altium_add_rule.",+ &["net_classes", "list_nets", "add_rule"],+ &["Both nets must exist on the board."],+ json!({"name": "USB", "pos": "USB_DP", "neg": "USB_DN"})), verb_doc("autoroute", "Altium's own autorouter (Situs) on the current board: picks a strategy, keeps existing routing locked by default, routes, and answers the unconnected count and DRC errors before and after. Not saved unless save:true.", json!({"filePath": "path to the .PcbDoc - optional, else the current board", "strategy": "Default 2 Layer Board | Default Multi Layer Board | Default 2 Layer With Edge Connectors | General Orthogonal | Via Miser | Cleanup (default: the dialog's current)", "lockPreroutes": "bool, default true - existing tracks are kept", "ripupViolations": "bool, default false", "save": "bool, default false", "timeout": "seconds, default 600"}), json!({"before": "{unconnected, errors}", "after": "{unconnected, errors}", "steps": "[..]", "saved": "bool"}), 900,@@ -693,7 +728,7 @@ fn describe() -> Value { json!({"projectPath": "C:/proj/board.PrjPcb"})), verb_doc("export_pdf", "Every sheet of a project as one PDF (and the board's composite print with board:true), from an OutJob in a throwaway project; the user's project is not touched.", json!({"projectPath": "path to the .PrjPcb (required)", "output": "path of the .pdf, default <project>.pdf beside it", "board": "bool, default false", "timeout": "seconds, default 300"}),- json!({"output": "path", "outputBytes": "int", "sheets": "int"}), 600,+ json!({"pdfPath": "path", "outputBytes": "int", "sheets": "int"}), 600, "Pull the file with ab's pull_file.", &["run_outjob", "export_manufacturing"], &[], json!({"projectPath": "C:/proj/board.PrjPcb"})),@@ -1141,6 +1176,11 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> if let Some(hw) = args.get("hwnd").and_then(|h| h.as_i64()) { let r = dialogs::press(hw, &button); let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false);+ // the reaper may have answered it first: a dialog that is gone is not a failure to press+ if !ok && !dialogs::scan().iter().any(|d| d.get("hwnd").and_then(|h| h.as_i64()) == Some(hw)) {+ return json!({"success": true, "alreadyGone": true, "hwnd": hw, "dialogLog": dialogs::reaped_log(3),+ "_hint": "That dialog is no longer open (the bridge's reaper or the user answered it). dialogLog shows what the reaper pressed recently."});+ } return json!({"success": ok, "pressed": [{"hwnd": hw, "button": button, "ok": ok}], "errorCode": if ok { Value::Null } else { json!("button_not_pressed") }, "_hint": if ok { "Pressed on that one dialog only. altium_check_dialogs to confirm it is gone." } else { "UIA could not press that button on that window: read dialogs[].buttons from altium_check_dialogs for the exact label." }});@@ -1170,16 +1210,44 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> } "eval" => { if let Some(g) = need_licensed() { return g; }+ if let Some(p) = s(args, "filePath") { if let Some(e) = crate::support::must_open(&p, 60) { return e; } } let text = match s(args, "text").or_else(|| s(args, "code")) { Some(t) => t, None => return json!({"success": false, "error": "text is required (DelphiScript STATEMENTS: Begin ... End;)"}), }; if !agent::is_alive() { // not resident → the -R RunScriptText lane is available and simplest+ let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); let mut r = lifecycle::run_script_text_lane(&text, Duration::from_secs(timeout.max(30))); r["lane"] = json!("cli_run_script_text");+ // the helper returns once Altium has the text; a compile or runtime error shows up as a box+ // afterwards. Every failing eval used to answer success:true (2026-10-22): watch for it.+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ let t = std::time::Instant::now();+ while t.elapsed() < Duration::from_millis(3500) {+ if let Some(ev) = crate::dialogs::script_error_since(since) {+ return json!({"success": false, "errorCode": "script_error", "lane": "cli_run_script_text", "errorShot": ev.get("shot"),+ "_hint": "READ errorShot: Altium's error box (e.g. 'Undeclared identifier: X at Line N'). The bridge pressed it away. A Var block before Begin is allowed; strings take single quotes."});+ }+ for d in crate::dialogs::scan() {+ let title = d.get("title").and_then(|x| x.as_str()).unwrap_or("").trim().to_string();+ let labels: Vec<String> = d.get("buttons").and_then(|b| b.as_array()).map(|a| a.iter().filter_map(|x| x.as_str()).map(|x| x.trim().to_lowercase()).collect()).unwrap_or_default();+ if title == "X2" && labels == ["ok", "cancel"] {+ if let Some(hw) = d.get("hwnd").and_then(|h| h.as_i64()) {+ std::thread::sleep(Duration::from_millis(500));+ let shot = crate::ab::call("desktop_screenshot_base64", json!({"hwnd": hw}), &[], 10);+ let _ = crate::dialogs::press(hw, "OK");+ return json!({"success": false, "errorCode": "script_error", "lane": "cli_run_script_text",+ "errorShot": shot.get("image").or_else(|| shot.get("base64")).cloned(),+ "_hint": "READ errorShot (e.g. 'Undeclared identifier: X ... Continue execution?'); the bridge answered OK. A Var block before Begin is allowed."});+ }+ }+ }+ std::thread::sleep(Duration::from_millis(250));+ }+ } if r.get("_hint").is_none() {- r["_hint"] = json!("Ran via X2 -RScriptingSystem:RunScriptText (statements only). Errors surface as an 'Error'/'X2' modal — altium_check_dialogs.");+ r["_hint"] = json!("Ran via X2 -RScriptingSystem:RunScriptText; no error box appeared within 3.5 s. A Var block before Begin is allowed. Script on the CURRENT document: open the one you mean first (altium_open_document), and capture output by writing a file."); } return r; }@@ -1189,6 +1257,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> } "run_command" => { if let Some(g) = need_licensed() { return g; }+ if let Some(p) = s(args, "filePath") { if let Some(e) = crate::support::must_open(&p, 60) { return e; } } let c = s(args, "command").or_else(|| s(args, "process")).unwrap_or_default(); if !c.contains(':') { return json!({"success": false, "error": "command must be Server:Command, e.g. PCB:Zoom",@@ -1285,7 +1354,10 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> board_read("set_layer_visibility", vec![("layerId", l), ("displayed", if shown { "1".into() } else { "0".into() })], 60) } },- "layer_stack" => board_read("layer_stack", vec![], 60),+ "layer_stack" => {+ if ["add", "remove", "set", "totalMm"].iter().any(|k| args.get(*k).is_some()) { return crate::design::layer_stack_edit(args); }+ board_read("layer_stack", vec![], 60)+ } "footprint_primitives" => match s(args, "designator") { None => json!({"success": false, "error": "designator is required (a placed component, e.g. U1)"}), Some(d) => board_read("footprint_primitives", vec![("designator", d)], 120),@@ -1368,6 +1440,11 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "export_pdf" => crate::sch::export_pdf(args), "sch_zoom" => crate::sch::zoom(args), "autoroute" => crate::route::autoroute(args),+ "net_classes" => crate::design::net_classes(args),+ "set_track_width" => crate::design::set_track_width(args),+ "board_outline" => crate::design::board_outline(args),+ "variants" => crate::variants::variants(args),+ "diff_pairs" => crate::design::diff_pairs(args), "export_step" => outputs::export_step(args), "test_board" => library::test_board(args), "import_kicad" => outputs::import_kicad(args),
dev-skills/altium-bridge-dev/SKILL.md+1−1@@ -22,7 +22,7 @@ Everything you need to change the bridge without re-learning what this session p - **Resident DelphiScript agent** (`agent/AdomAgent.pas.tmpl`): bootstrapped once via `X2.EXE -RScriptingSystem:RunScript(...StartAgent)`, then a polling loop over a file-drop inbox (`request.txt` → `response_<id>.json` in the workspace). One script at a time; DO NOT- `run_script` while it is resident. `eval` runs STATEMENTS ONLY (no Var block).+ `run_script` while it is resident. `eval` takes an optional Var block, then Begin ... End; (verified AD 26.11.1, 2026-10-22). - **Per-digest generations**: the tmpl is rendered to `AdomAgent_<digest>.pas`; the digest is hashed from the RAW template (NOT the rendered source — that recursed and crashed the bridge once). The heartbeat carries `gen`; `agent_start` auto-restarts a stale in-Altium loop.
dev-skills/altium-bridge-test/run_verb_tests.py+12−1@@ -224,6 +224,13 @@ def matrix(w): M["silk_state"] = dict(phase=3.5, **ok()) M["apply_silkscreen"] = dict(phase=3.5, **ok(texts=[{"text": "BATTERY", "x": 20, "y": 18, "size": 1}])) M["routing_drc"] = dict(phase=3.5, timeout=300, **ok())+ # 0.2.60: board design data on the lane board. The class and the pair are board-only, so the change order+ # update_pcb runs further down must leave them alone (its removal rows are unticked by default).+ M["net_classes"] = dict(phase=3.5, **ok(filePath=LB, name="BatteryPwr", nets=["GND", "N1"], width=0.3, clearance=0.25),+ check=lambda d: d.get("members") == 2 and all(r.get("success") for r in d.get("rules") or []))+ M["diff_pairs"] = dict(phase=3.5, **ok(filePath=LB, name="BAT", pos="N1", neg="GND"))+ M["set_track_width"] = dict(phase=3.5, timeout=300, **ok(filePath=LB, netClass="BatteryPwr", widthMm=0.3))+ M["board_outline"] = dict(phase=3.5, **ok(filePath=LB, mech=1), check=lambda d: (d.get("drawn") or 0) > 0 or (d.get("alreadyThere") or 0) > 0) M["build_schematic"] = dict(phase=3.5, timeout=600, **ok(schPath=LS, libraryPath=SL, pcbLibPath=PL, pcbPath=LB, projectPath=LP, title="battery lane", parts=[{"ref": "U1", "symbol": "TST_SOIC8", "block": "core", "footprint": "TST_SOIC8"}, {"ref": "U2", "symbol": "TST_SOIC8", "block": "core", "footprint": "TST_SOIC8"}],@@ -245,9 +252,13 @@ def matrix(w): M["sch_edit"] = dict(phase=3.5, **ok(sheet=LS, edits=[{"designator": "U3", "action": "comment", "text": "BATTERY"}])) M["sch_zoom"] = dict(phase=3.5, **ok(sheet=LS, designators=["U3"], shot=False)) M["update_pcb"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, execute=True, timeout=600),- check=lambda d: "U3" in ((d.get("preview") or {}).get("addParts") or []) and d.get("executed") is True)+ check=lambda d: "U3" in ((d.get("preview") or {}).get("addParts") or []) and d.get("executed") is True+ and not any("BatteryPwr" in (r.get("change") or "") and r.get("enabled") for g in d.get("changeOrder") or [] for r in g.get("rows") or [])) M["export_pdf"] = dict(phase=3.5, timeout=650, **ok(projectPath=LP, output=D + "/lane.pdf", timeout=600), check=lambda d: (d.get("outputBytes") or 0) > 0) M["autoroute"] = dict(phase=3.5, timeout=650, **ok(filePath=LB, strategy="Default 2 Layer Board", timeout=600))+ # 0.2.60: a variant written into the project file and read back from Altium+ M["variants"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, name="BatteryLite", notFitted=["U2"], timeout=380),+ check=lambda d: any(v.get("name") == "BatteryLite" for v in (d.get("altium") or {}).get("variants") or [])) # ---- phase 4: outputs -------------------------------------------------------------------- M["export_step"] = dict(phase=4, timeout=300, **anyo("STEP of the battery board", filePath=PD, output=D + "/battery.step"))
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.59",+ "version": "0.2.60", "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.59",+ "version": "0.2.60", "hero": { "type": "image", "path": "screenshots/hero.png"
skills/altium-boards/SKILL.md+28@@ -177,3 +177,31 @@ altium_read_rules '{}' # every rule with its descriptor (the values) - `altium_read_rules` now carries each rule's `descriptor`, Altium's own wording with the values, for example `Clearance Constraint (Gap=6mil) (All),(All)`: what a DRC violation is measured against. +## Board setup: stack-up, net classes, pairs, widths (0.2.60)++```bash+# 2 layers -> 4: GND and power planes inside, 1.6 mm total+altium_layer_stack '{"filePath":"C:\\proj\\board.PcbDoc","add":[{"kind":"plane","net":"GND","name":"GND"},{"kind":"plane","net":"VIN","name":"PWR"}],"totalMm":1.6}'+altium_layer_stack '{"set":[{"name":"GND","pullbackUm":500},{"name":"Top Layer","copperUm":35}]}'+# power nets: a class, its width and clearance rules, and the copper already routed+altium_net_classes '{"name":"Power","nets":["VIN","VOUT","GND","SW"],"width":0.5,"clearance":0.3}'+altium_set_track_width '{"netClass":"Power","widthMm":0.5}'+altium_diff_pairs '{"name":"USB","pos":"USB_DP","neg":"USB_DN"}'+```++- `altium_layer_stack` with no args reads the stack; each layer reports `kind` (signal / plane), a plane's+ `net`, copper and the dielectric under it. `add` inserts planes or inner signal layers (Altium places them by+ layer number: planes come out Top, plane 1, plane 2, Bottom); `remove:[{name}]` takes one out (top and bottom+ stay); `set` changes copper (um), the dielectric below a layer (um, `dk`, `material`), a plane's `net` and+ `pullbackUm`, or `rename`; `totalMm` spreads the dielectrics evenly to that board thickness. Measured: the+ buck went from 2 layers to Top / GND / PWR / Bottom at 1.595 mm, and survived save and reopen.+- `altium_export_manufacturing` adds a board's planes and inner layers to its Gerbers (the template plots top,+ mid 1-2 and bottom only) and counts the copper files in `fabChecks.copperLayers`.+- `altium_net_classes` lists classes with their nets, or makes one (replacing a class of that name) with rules+ `Adom <name> width` / `Adom <name> clearance` scoped `InNetClass('<name>')`. Nets that are not on the board come+ back in `notOnBoard`. Width rules cover every copper layer.+- Rules steer NEW routing only: `altium_set_track_width` resizes the tracks and arcs already routed on nets or a+ class (pour and footprint copper untouched) and counts online DRC after; check clearance with+ `altium_routing_drc {full:true}`. DRC violations name both objects (`objects`).+- A class, pair or rule made on the board is not in the schematic, so Altium's change order proposes to REMOVE+ it: `altium_update_pcb` leaves those rows unticked unless `removeBoardOnlyClasses:true`.
skills/altium-bom/SKILL.md+25−1@@ -83,10 +83,34 @@ Each designator is found on whichever sheet holds it; a missing parameter is add sheet is saved (`save:false` to leave it). The placed parts change, not the library (`altium_set_params` changes a library part). The board picks the parameters up with Design > Update PCB Document. +## Variants (which parts are fitted on which build)++```bash+altium_variants '{"projectPath":"C:\\proj\\board.PrjPcb"}' # list+altium_variants '{"projectPath":"C:\\proj\\board.PrjPcb","name":"Lite","notFitted":["R5","C9","J2"]}'+altium_variants '{"projectPath":"C:\\proj\\board.PrjPcb","name":"Lite","fitted":["C9"]}' # put one back+altium_bom '{"projectPath":"C:\\proj\\board.PrjPcb","variant":"Lite"}' # that build's BOM+```++Altium's script API reads variants but cannot make them, so the bridge writes the `[ProjectVariantN]`+sections of the `.PrjPcb` (nothing else in the file changes), saves and closes the project, reopens it and+returns what Altium itself holds (`altium.variants`, kind 1 = not fitted). Measured on the buck: a Lite variant+with TP5 and C1 not fitted read back from Altium, and its BOM had 31 fitted parts instead of 33. Variants with+parameter variations are refused; alternate parts are listed, not created.++## Order readiness++- `checks.jlcpcb`: `ready` and `linesWithoutLcsc` (purchased lines JLCPCB cannot source by LCSC number: find+ one with adom-parts-search / adom-jlcpcb, or plan them as consigned).+- `checks.notPurchased`: parts whose MPN or manufacturer says nothing is bought ("none (PCB copper)" test+ pads, "n/a"). They stay in `bom.csv` (`purchase:false`) and out of the JLCPCB BOM and CPL and the+ distributor files.+- `altium_sheet_params` with `Description` also sets the part's own description, which the BOM, the pick and+ place and the board read.+ ## Limits - A repeated (multi-channel) sheet is read once: its copies' designators show in `boardNotOnSheet`. - A workspace-managed part's description is not read from its vault (that call hung on Altium's example content); a `Description` parameter is used when the part has one.-- Variant support follows the documented `.PrjPcb` format; no example project with variants was at hand. - `adom-parts-search` has no JLCPCB price; JLCPCB-only lines are not in the priced total.
skills/altium-bridge-plan/SKILL.md+1−1@@ -220,7 +220,7 @@ heartbeat and recover. Keep `handlers/dialog_classify.py` with title → categor |---|---| | Trial licence | `Start Free Trial` (WebView2, image click) → Edge opens `altium.com/365-intro/activate-add-eval` → workspace form (name, region, terms, **Activate**) → a workspace is provisioned at `<name>.365.altium.com` in ~60 s → back in AD: **Refresh**, then **Use License** on "Altium Designer Develop · On-Demand · Trial · 30 days". Title flips to `… - Trial`. | | `-R` forwarding | A second `X2.EXE -R<Server>:<Cmd>(…)` forwards to the running instance and exits in ~2–8 s. **If the instance is busy (any modal, or a script running) the helper hangs and shows its own "There is another instance … busy" Information dialog** — kill it. |-| `RunScriptText` | Works from the shell: `-RScriptingSystem:RunScriptText(Text="Begin RunApplication('…'); End;")`. **Statements only** — a `Var` block is "Unknown identifier: Var". |+| `RunScriptText` | Works from the shell: `-RScriptingSystem:RunScriptText(Text="Begin RunApplication('…'); End;")`. A `Var` block before `Begin` works on AD 26.11.1 (verified 2026-10-22; an earlier build answered "Unknown identifier: Var"). | | `RunScript` | `-RScriptingSystem:RunScript(ProjectName="…\X.PrjScr"\|ProcName="Main")` works with a plain `\|` when the arg is passed VERBATIM (PowerShell `Start-Process`, or Python `Popen("<one string>")`). `Popen([exe, arg])` re-quotes (the path has a space) and the helper never forwards. `^\|` is only for cmd.exe. | | Concurrency | **One script at a time.** A -R script while the agent loop is resident raises `Error: Another script executing now` on the MAIN window; every later -R is refused until it is dismissed. | | Late binding | An unknown identifier (`CreateOleObject`, `RunProcessWithParameters`) is a **runtime** error: `Error: Undeclared identifier …` modal + the Script editor opens with the **debugger halted** on that line. The halted script counts as executing. |
skills/altium-outputs/SKILL.md+13@@ -31,6 +31,19 @@ Parent skill: **altium-bridge** - `ok:true` with an empty files[] means the medium ran against the wrong project or the output path differs — read the OutJob's own configured folder. +## Fab checks on export_manufacturing (0.2.60)++- `fabChecks.outline`: the board outline Gerbers (GM1, GKO) must carry the board shape. A board built by script+ had its shape on no layer, so those files were empty and a fab house would reject the set. The bridge now+ draws the shape on Mechanical 1 (`altium_board_outline`, in memory: `altium_save` keeps it) and generates+ again; `addOutline:false` skips that and just reports.+- `fabChecks.copperLayers`: one copper Gerber per copper layer. The board's planes and inner layers are added+ to the Gerber plot set (Altium's template plotted top, mid 1-2 and bottom only). Measured: a 4-layer buck gave+ GTL, GP1, GP2, GBL.+- The `<project>.Manufacturing.OutJob` is the bridge's own and is rewritten on every run: customise outputs in+ another OutJob and run it with `altium_run_outjob`.+- `altium_export_pdf` answers the file in `pdfPath`.+ ## Caveats (current, honest) - **Headless STEP export works (0.2.46)**: `altium_export_step {filePath, output}` writes an
skills/altium-schematic-edit/SKILL.md+8−3@@ -52,7 +52,7 @@ altium_sch_zoom '{"sheet":"C:\\proj\\Main.SchDoc","designators":["U1","L1"]}' ``` Selects them, frames the view on them and returns a frame (`notOnSheet` names any that are not there).-On the board, `altium_zoom_to {designators}` does the same.+On the board, `altium_zoom_to {refs:["U1","L1"]}` (or `ref`, `net`, `rect`) does the same. ## Change parts @@ -76,8 +76,13 @@ Altium's Design > Update PCB Document (from the board: Import Changes), the engi - **Preview first (the default).** `preview.addParts` are on a sheet but not the board; `preview.removeParts` are on the board but on no sheet. Those are often board-only test pads, fiducials and mounting holes, and the change order would DELETE them.-- `execute:true` runs it, and refuses while `removeParts` is not empty unless `allowRemovals:true`.- Put board-only parts on a sheet instead when they should stay.+- `detail:true` opens Altium's change order, reads EVERY row (parameters, footprints, nets, classes, rules,+ grouped as Altium groups them), and closes it without running: `changeOrder` and `wouldLeaveOut`.+- `execute:true` runs it, and refuses while `removeParts` is not empty unless you choose:+ `keepBoardOnly:true` (run it, leave those parts on the board: their rows are unticked) or+ `allowRemovals:true` (delete them).+- Board-made net classes, differential pairs, rules and rooms are not in the schematic either: their removal+ rows are unticked by default (`removeBoardOnlyClasses:true` lets them go). `leftOut` lists what was unticked. - The first change order on a board that was built by script asks to link parts by designator; the verb answers it. It presses Execute Changes, waits for every row to finish, closes, and **saves the board** (the change order edits it in memory only).
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.59+0.2.60
src/agent/AdomAgent.pas.tmpl+391−20@@ -506,7 +506,7 @@ Begin ResetParameters; AddStringParameter('Text', Text); RunProcess('ScriptingSystem:RunScriptText');- Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'RunScriptText dispatched; statements only, no Var block; capture output by writing a file') + '}';+ Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'RunScriptText dispatched; an optional Var block, then Begin ... End;; capture output by writing a file') + '}'; End; @@ -4590,6 +4590,11 @@ Begin Begin If S <> '' Then S := S + ','; S := S + '{' + JStr('name', L.Name) + ',' + JRaw('copperUm', IntToStr(Round(CoordToMils(L.CopperThickness) * 25.4)));+ If (L.V6_LayerID >= eInternalPlane1) And (L.V6_LayerID <= eInternalPlane16) Then+ S := S + ',' + JStr('kind', 'plane') + ',' + JStr('net', L.NetName) + ',' + JRaw('plane', IntToStr(L.V6_LayerID - eInternalPlane1 + 1))+ Else If (L.V6_LayerID >= eMidLayer1) And (L.V6_LayerID <= eMidLayer30) Then+ S := S + ',' + JStr('kind', 'signal') + ',' + JRaw('mid', IntToStr(L.V6_LayerID - eMidLayer1 + 1))+ Else S := S + ',' + JStr('kind', 'signal'); Tot := Tot + L.CopperThickness; { a layer's dielectric is the one below it: the last copper layer has none in the board } If (L.Dielectric <> Nil) And (LS.NextLayer(L) <> Nil) Then@@ -4605,6 +4610,63 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('copperLayers', IntToStr(N)) + ',' + JRaw('totalUm', IntToStr(Round(CoordToMils(Tot) * 25.4))) + ',' + JRaw('layers', '[' + S + ']') + '}'; End; +{ layer_stack_edit: op=insert (layer=<V6 id: plane N = eInternalPlane1+N-1, mid N = eMidLayer1+N-1>, net for a plane,+ name) | remove (layer). Through Board.LayerStack_V7 (the classic LayerStack halts on AD 26). Answers the stack after. }+Function CmdLayerStackEdit(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ LS : IPCB_LayerStack_V7;+ L : IPCB_LayerObject_V7;+ Op, Net, Nm : String;+ Id : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Op := Param(Lines, 'op'); Net := Param(Lines, 'net'); Nm := Param(Lines, 'name');+ If Param(Lines, 'kind') = 'plane' Then Id := eInternalPlane1 + StrToIntDef(Param(Lines, 'n'), 1) - 1+ Else Id := eMidLayer1 + StrToIntDef(Param(Lines, 'n'), 1) - 1;+ LS := Board.LayerStack_V7;+ PCBServer.PreProcess;+ If Op = 'insert' Then+ Begin+ LS.InsertLayer(Id);+ L := LS.LayerObject(Id);+ If (L <> Nil) And (Nm <> '') Then L.Name := Nm;+ If (L <> Nil) And (Net <> '') Then L.NetName := Net;+ End+ Else If Op = 'remove' Then+ Begin+ L := LS.LayerObject(Id);+ If L <> Nil Then LS.RemoveFromStack(L);+ End+ Else If Op = 'set' Then+ Begin+ { by name: copper (um), the dielectric BELOW it (um, Dk x 1000, material), a plane's net and pullback (um) }+ L := LS.FirstLayer;+ While L <> Nil Do+ Begin+ If UpperCase(L.Name) = UpperCase(Param(Lines, 'target')) Then+ Begin+ If Param(Lines, 'copperUm') <> '' Then L.CopperThickness := MilsToCoord(StrToIntDef(Param(Lines, 'copperUm'), 35) / 25.4);+ If (L.Dielectric <> Nil) And (Param(Lines, 'dielectricUm') <> '') Then L.Dielectric.DielectricHeight := MilsToCoord(StrToIntDef(Param(Lines, 'dielectricUm'), 200) / 25.4);+ If (L.Dielectric <> Nil) And (Param(Lines, 'dk1000') <> '') Then L.Dielectric.DielectricConstant := StrToIntDef(Param(Lines, 'dk1000'), 4200) / 1000;+ If (L.Dielectric <> Nil) And (Param(Lines, 'material') <> '') Then L.Dielectric.DielectricMaterial := Param(Lines, 'material');+ If Nm <> '' Then L.Name := Nm;+ If (L.V6_LayerID >= eInternalPlane1) And (L.V6_LayerID <= eInternalPlane16) Then+ Begin+ If Net <> '' Then L.NetName := Net;+ If Param(Lines, 'pullbackUm') <> '' Then L.PullBackDistance := MilsToCoord(StrToIntDef(Param(Lines, 'pullbackUm'), 500) / 25.4);+ End;+ End;+ L := LS.NextLayer(L);+ End;+ End;+ PCBServer.PostProcess;+ Board.ViewManager_UpdateLayerTabs;+ Board.ViewManager_FullUpdate;+ Result := CmdLayerStack(Lines);+End;+ { layers: the fixed layer set with names and visibility (stackup detail is a later verb) } Function CmdLayers(Lines : TStringList) : String; Var@@ -4846,7 +4908,13 @@ Begin Begin K := Trim(Copy(Spec, 1, P - 1)); V := Trim(Copy(Spec, P + 1, Length(Spec)));- If K <> '' Then Begin SetSchParam(Comp, K, V); NSet := NSet + 1; End;+ If K <> '' Then+ Begin+ SetSchParam(Comp, K, V); NSet := NSet + 1;+ { the part's own description is what the BOM, pick and place and the board read: a+ 'Description' parameter alone left them on the library's text (2026-10-22) }+ If UpperCase(K) = 'DESCRIPTION' Then Comp.ComponentDescription := V;+ End; End; End; SchServer.RobotManager.SendMessage(Comp.I_ObjectAddress, c_BroadCast, SCHM_EndModify, c_NoEventData);@@ -4989,33 +5057,327 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('layerId', IntToStr(L)) + ',' + JStr('name', LName) + ',' + JRaw('displayed', JBool(Board.LayerIsDisplayed[L])) + '}'; End; -{ create_net_class: name=<class> nets=<A;B;C> }-Function CmdCreateNetClass(Lines : TStringList) : String;+{ project_variants: the variants Altium itself holds for an open project (path=<.PrjPcb>), each with its+ variations (designator, kind: 1 not fitted, 2 alternate). The bridge writes variants into the project+ file; this is the read-back that Altium took them. }+Function CmdProjectVariants(Lines : TStringList) : String;+Var+ WS : IWorkspace;+ Prj : IProject;+ V : IProjectVariant;+ CV : IComponentVariation;+ I, J : Integer;+ Want, S, M : String;+Begin+ Want := UpperCase(Param(Lines, 'path'));+ WS := GetWorkspace; Prj := Nil;+ If WS <> Nil Then+ For I := 0 To WS.DM_ProjectCount - 1 Do+ If UpperCase(WS.DM_Projects(I).DM_ProjectFullPath) = Want Then Prj := WS.DM_Projects(I);+ If Prj = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','project_not_open') + ',' + JStr('error','that project is not open in Altium') + '}'; Exit; End;+ S := '';+ For I := 0 To Prj.DM_ProjectVariantCount - 1 Do+ Begin+ V := Prj.DM_ProjectVariants(I);+ M := '';+ For J := 0 To V.DM_VariationCount - 1 Do+ Begin+ CV := V.DM_Variations(J);+ If M <> '' Then M := M + ',';+ M := M + '{' + JStr('designator', CV.DM_PhysicalDesignator) + ',' + JRaw('kind', IntToStr(CV.DM_VariationKind)) + '}';+ End;+ If S <> '' Then S := S + ',';+ S := S + '{' + JStr('name', V.DM_Description) + ',' + JRaw('variations', '[' + M + ']') + '}';+ End;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('variants', '[' + S + ']') + '}';+End;++{ pcb_postprocess: close a board edit the script engine left open. A runtime error between PCBServer.PreProcess+ and PostProcess leaves Altium sure a command is still running: every save then asks 'A command is currently+ active and save cannot be completed' (measured 2026-10-22). The bridge sends this after such an error. }+Function CmdPcbPostProcess(Lines : TStringList) : String;+Begin+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + '}';+End;++{ outline_tracks: the board shape drawn as tracks and arcs on a mechanical layer (mech=N, width raw), the+ outline a fab house reads from the Gerbers. A scripted board has its shape but nothing on any outline layer,+ so its GM1/GKO Gerbers came out empty (2026-10-22). op=check counts what the layer holds; op=draw draws+ unless the layer already has primitives (replace=1 draws anyway). }+Function CmdOutlineTracks(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BO : IPCB_BoardOutline;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ Seg, Nx : TPolySegment;+ Trk : IPCB_Track;+ Arc : IPCB_Arc;+ Lyr, W, K, Have, Drawn : Integer;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Lyr := eMechanical1 + StrToIntDef(Param(Lines, 'mech'), 1) - 1;+ W := StrToIntDef(Param(Lines, 'width'), 39370);+ Have := 0;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject, eArcObject));+ BIt.AddFilter_LayerSet(MkSet(Lyr));+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do Begin Have := Have + 1; Prim := BIt.NextPCBObject; End;+ Board.BoardIterator_Destroy(BIt);+ Drawn := 0;+ BO := Board.BoardOutline;+ If (Param(Lines, 'op') = 'draw') And (BO <> Nil) And ((Have = 0) Or (Param(Lines, 'replace') = '1')) Then+ Begin+ PCBServer.PreProcess;+ For K := 0 To BO.PointCount - 1 Do+ Begin+ Seg := BO.Segments[K];+ Nx := BO.Segments[(K + 1) Mod BO.PointCount];+ If Seg.Kind = ePolySegmentArc Then+ Begin+ Arc := PCBServer.PCBObjectFactory(eArcObject, eNoDimension, eCreate_Default);+ Arc.Layer := Lyr; Arc.XCenter := Seg.cx; Arc.YCenter := Seg.cy; Arc.Radius := Seg.Radius;+ Arc.LineWidth := W; Arc.StartAngle := Seg.Angle1; Arc.EndAngle := Seg.Angle2;+ Board.AddPCBObject(Arc);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Arc.I_ObjectAddress);+ End+ Else+ Begin+ Trk := PCBServer.PCBObjectFactory(eTrackObject, eNoDimension, eCreate_Default);+ Trk.Layer := Lyr; Trk.X1 := Seg.vx; Trk.Y1 := Seg.vy; Trk.X2 := Nx.vx; Trk.Y2 := Nx.vy; Trk.Width := W;+ Board.AddPCBObject(Trk);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Trk.I_ObjectAddress);+ End;+ Drawn := Drawn + 1;+ End;+ PCBServer.PostProcess;+ Board.ViewManager_FullUpdate;+ End;+ Result := '{' + JRaw('ok','true') + ',' + JStr('layer', Board.LayerStack.LayerObject[Lyr].Name) + ',' + JRaw('alreadyThere', IntToStr(Have)) + ',' ++ JRaw('drawn', IntToStr(Drawn)) + ',' + JRaw('outlineVertices', IntToStr(BO.PointCount)) + '}';+End;++{ track_width: set the width of every track and arc on the named nets (nets=A;B, width raw, layer=<V6 id> optional).+ Answers how many changed per net. }+Function CmdTrackWidth(Lines : TStringList) : String; Var Board : IPCB_Board;- Cls : IPCB_ObjectClass;- Name, Nets, N : String;+ BIt : IPCB_BoardIterator;+ Prim : IPCB_Primitive;+ Hits : TInterfaceList;+ Want, Cnt : TStringList;+ W, Lyr, I : Integer;+ Nm, S : String;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Want := TStringList.Create; Want.Delimiter := ';'; Want.StrictDelimiter := True; Want.DelimitedText := UpperCase(Param(Lines, 'nets'));+ Cnt := TStringList.Create;+ Hits := TInterfaceList.Create;+ W := StrToIntDef(Param(Lines, 'width'), 0);+ Lyr := StrToIntDef(Param(Lines, 'layer'), 0);+ { collect first, change after: a primitive that changes size moves in the board's spatial index, and changing it+ mid-iteration crashed Altium (2026-10-22) }+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eTrackObject, eArcObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Prim := BIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ { routed copper only: a pour's own tracks (InPolygon) and a footprint's are left alone }+ If (Prim.Net <> Nil) And (Not Prim.InComponent) And (Not Prim.InPolygon) And ((Lyr = 0) Or (Prim.Layer = Lyr)) Then+ If Want.IndexOf(UpperCase(Prim.Net.Name)) >= 0 Then Hits.Add(Prim);+ Prim := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ PCBServer.PreProcess;+ For I := 0 To Hits.Count - 1 Do+ Begin+ Prim := Hits[I];+ Nm := UpperCase(Prim.Net.Name);+ Prim.BeginModify;+ If Prim.ObjectId = eTrackObject Then Prim.Width := W Else Prim.LineWidth := W;+ Prim.EndModify;+ Cnt.Values[Nm] := IntToStr(StrToIntDef(Cnt.Values[Nm], 0) + 1);+ End;+ PCBServer.PostProcess;+ Board.ViewManager_FullUpdate;+ S := '';+ For I := 0 To Cnt.Count - 1 Do Begin If S <> '' Then S := S + ','; S := S + JRaw(Cnt.Names[I], Cnt.ValueFromIndex[I]); End;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('changed', IntToStr(Hits.Count)) + ',' + JRaw('byNet', '{' + S + '}') + '}';+ Want.Free; Cnt.Free; Hits.Free;+End;++{ every copper layer of a width rule (top, mid 1-30, bottom): a rule set on top and bottom only left inner+ layers at Altium's default once a board had them }+Procedure SetWidthAllLayers(Rule : IPCB_Rule; A, B, C : Integer);+Var+ L : Integer;+Begin+ For L := eTopLayer To eBottomLayer Do+ Begin+ Rule.MinWidth[L] := A; Rule.FavoredWidth[L] := B; Rule.MaxWidth[L] := C;+ End;+End;++Function NetObjNamed(Board : IPCB_Board; Name : String) : IPCB_Net;+Var+ BIt : IPCB_BoardIterator;+ N : IPCB_Net;+Begin+ Result := Nil;+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eNetObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ N := BIt.FirstPCBObject;+ While N <> Nil Do+ Begin+ If UpperCase(N.Name) = UpperCase(Name) Then Begin Result := N; Break; End;+ N := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+End;++{ net_classes: op=list | set (name, nets=A;B;C: replaces a class of that name) | remove (name).+ list answers every net class with its member nets; Altium's own 'All Nets' is flagged superClass. }+Function CmdNetClasses(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt, NIt : IPCB_BoardIterator;+ Cls, Old : IPCB_ObjectClass;+ Net : IPCB_Net;+ Op, Name, Nets, N, S, M, Missing : String; P, K : Integer; Begin Board := PCBServer.GetCurrentPCBBoard;- If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;- Name := Param(Lines, 'name');- Nets := Param(Lines, 'nets');- If (Name = '') Or (Nets = '') Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('error','name and nets (semicolon list) are required') + '}'; Exit; End;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Op := Param(Lines, 'op'); Name := Param(Lines, 'name');+ Old := Nil; S := '';+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eClassObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Cls := BIt.FirstPCBObject;+ While Cls <> Nil Do+ Begin+ If Cls.MemberKind = eClassMemberKind_Net Then+ Begin+ If (Name <> '') And (UpperCase(Cls.Name) = UpperCase(Name)) Then Old := Cls;+ If Op = 'list' Then+ Begin+ M := '';+ If Not Cls.SuperClass Then+ Begin+ NIt := Board.BoardIterator_Create;+ NIt.AddFilter_ObjectSet(MkSet(eNetObject));+ NIt.AddFilter_LayerSet(AllLayers);+ NIt.AddFilter_Method(eProcessAll);+ Net := NIt.FirstPCBObject;+ While Net <> Nil Do+ Begin+ If Cls.IsMember(Net) Then Begin If M <> '' Then M := M + ','; M := M + '"' + JsonEsc(Net.Name) + '"'; End;+ Net := NIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(NIt);+ End;+ If S <> '' Then S := S + ',';+ S := S + '{' + JStr('name', Cls.Name) + ',' + JRaw('superClass', JBool(Cls.SuperClass)) + ',' + JRaw('nets', '[' + M + ']') + '}';+ End;+ End;+ Cls := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ If Op = 'list' Then Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('classes', '[' + S + ']') + '}'; Exit; End;+ If Name = '' Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','bad_args') + ',' + JStr('error','name is required') + '}'; Exit; End; PCBServer.PreProcess;- Cls := PCBServer.PCBClassFactory(eClassMemberKind_Net);+ If Old <> Nil Then Board.RemovePCBObject(Old);+ If Op = 'remove' Then+ Begin+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('removed', JBool(Old <> Nil)) + '}';+ Exit;+ End;+ Cls := PCBServer.PCBClassFactoryByClassMember(eClassMemberKind_Net);+ Cls.SuperClass := False; Cls.Name := Name;- K := 0;+ Nets := Param(Lines, 'nets'); K := 0; Missing := ''; While Nets <> '' Do Begin P := Pos(';', Nets); If P = 0 Then Begin N := Nets; Nets := ''; End Else Begin N := Copy(Nets, 1, P - 1); Nets := Copy(Nets, P + 1, Length(Nets)); End;- If N <> '' Then Begin Cls.AddMemberByName(N); K := K + 1; End;+ If N <> '' Then+ Begin+ Net := NetObjNamed(Board, N);+ If Net = Nil Then Begin If Missing <> '' Then Missing := Missing + ','; Missing := Missing + '"' + JsonEsc(N) + '"'; End+ Else Begin Cls.AddMemberByName(Net.Name); K := K + 1; End;+ End; End; Board.AddPCBObject(Cls);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Cls.I_ObjectAddress);+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('members', IntToStr(K)) + ',' + JRaw('replaced', JBool(Old <> Nil)) + ',' + JRaw('notOnBoard', '[' + Missing + ']') + '}';+End;++{ diff_pairs: op=list | set (name, pos, neg: board nets; replaces a pair of that name) | remove (name) }+Function CmdDiffPairs(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ DP, Old : IPCB_DifferentialPair;+ PN, NN : IPCB_Net;+ Op, Name, S, PS, NS : String;+Begin+ Board := PCBServer.GetCurrentPCBBoard;+ If Board = Nil Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_board') + ',' + JStr('error','no current PCB board (open a .PcbDoc first)') + '}'; Exit; End;+ Op := Param(Lines, 'op'); Name := Param(Lines, 'name');+ Old := Nil; S := '';+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eDifferentialPairObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ DP := BIt.FirstPCBObject;+ While DP <> Nil Do+ Begin+ If (Name <> '') And (UpperCase(DP.Name) = UpperCase(Name)) Then Old := DP;+ PS := ''; NS := '';+ If DP.PositiveNet <> Nil Then PS := DP.PositiveNet.Name;+ If DP.NegativeNet <> Nil Then NS := DP.NegativeNet.Name;+ If S <> '' Then S := S + ',';+ S := S + '{' + JStr('name', DP.Name) + ',' + JStr('pos', PS) + ',' + JStr('neg', NS) + '}';+ DP := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ If Op = 'list' Then Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('pairs', '[' + S + ']') + '}'; Exit; End;+ If Name = '' Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','bad_args') + ',' + JStr('error','name is required') + '}'; Exit; End;+ If Op = 'set' Then+ Begin+ PN := NetObjNamed(Board, Param(Lines, 'pos'));+ NN := NetObjNamed(Board, Param(Lines, 'neg'));+ If (PN = Nil) Or (NN = Nil) Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','net_not_on_board') + ',' + JRaw('posFound', JBool(PN <> Nil)) + ',' + JRaw('negFound', JBool(NN <> Nil)) + '}'; Exit; End;+ End;+ PCBServer.PreProcess;+ If Old <> Nil Then Board.RemovePCBObject(Old);+ If Op = 'remove' Then+ Begin+ PCBServer.PostProcess;+ Result := '{' + JRaw('ok','true') + ',' + JStr('pair', Name) + ',' + JRaw('removed', JBool(Old <> Nil)) + '}';+ Exit;+ End;+ DP := PCBServer.PCBObjectFactory(eDifferentialPairObject, eNoDimension, eCreate_Default);+ DP.Name := Name;+ DP.PositiveNet := PN;+ DP.NegativeNet := NN;+ Board.AddPCBObject(DP);+ PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, DP.I_ObjectAddress); PCBServer.PostProcess;- Result := '{' + JRaw('ok','true') + ',' + JStr('class', Name) + ',' + JRaw('members', IntToStr(K)) + '}';+ Result := '{' + JRaw('ok','true') + ',' + JStr('pair', Name) + ',' + JStr('pos', PN.Name) + ',' + JStr('neg', NN.Name) + ',' + JRaw('replaced', JBool(Old <> Nil)) + '}'; End; { footprint_primitives: designator=<board component> -> geometry dump (tracks, arcs, pads, texts, others counted) }@@ -6059,7 +6421,7 @@ Var V : IPCB_Violation; R : TCoordRect; LV : TStringList;- Rule : String;+ Rule, O1, O2 : String; Unrouted, N : Integer; Begin Board := PCBServer.GetCurrentPCBBoard;@@ -6083,8 +6445,12 @@ Begin Rule := ''; If V.Rule <> Nil Then Rule := V.Rule.Name; R := V.BoundingRectangle;+ { the two objects that clash: a description can be empty (a Clearance one was, 2026-10-22) }+ O1 := ''; O2 := '';+ If V.Primitive1 <> Nil Then O1 := V.Primitive1.Descriptor;+ If V.Primitive2 <> Nil Then O2 := V.Primitive2.Descriptor; LV.Add('["' + JsonEsc(Rule) + '","' + JsonEsc(V.Description) + '",' + IntToStr(R.Left) + ',' + IntToStr(R.Bottom) + ',' +- IntToStr(R.Right) + ',' + IntToStr(R.Top) + ']');+ IntToStr(R.Right) + ',' + IntToStr(R.Top) + ',"' + JsonEsc(O1) + '","' + JsonEsc(O2) + '"]'); End; End; Prim := BIt.NextPCBObject;@@ -6384,8 +6750,7 @@ Begin If Kind = 'clearance' Then Rule.Gap := A Else If Kind = 'width' Then Begin- Rule.MinWidth[eTopLayer] := A; Rule.FavoredWidth[eTopLayer] := B; Rule.MaxWidth[eTopLayer] := C;- Rule.MinWidth[eBottomLayer] := A; Rule.FavoredWidth[eBottomLayer] := B; Rule.MaxWidth[eBottomLayer] := C;+ SetWidthAllLayers(Rule, A, B, C); End Else If Kind = 'via' Then Begin@@ -6489,8 +6854,7 @@ Begin If Kind = 'clearance' Then Rule.Gap := A Else Begin- Rule.MinWidth[eTopLayer] := A; Rule.FavoredWidth[eTopLayer] := B; Rule.MaxWidth[eTopLayer] := C;- Rule.MinWidth[eBottomLayer] := A; Rule.FavoredWidth[eBottomLayer] := B; Rule.MaxWidth[eBottomLayer] := C;+ SetWidthAllLayers(Rule, A, B, C); End; Board.AddPCBObject(Rule); PCBServer.SendMessageToRobots(Board.I_ObjectAddress, c_Broadcast, PCBM_BoardRegisteration, Rule.I_ObjectAddress);@@ -7716,6 +8080,13 @@ Begin Else If Cmd = 'sch_place_part' Then Result := CmdSchPlacePart(Lines) Else If Cmd = 'doc_params' Then Result := CmdDocParams(Lines) Else If Cmd = 'sch_rename_at' Then Result := CmdSchRenameAt(Lines, Payload)+ Else If Cmd = 'net_classes' Then Result := CmdNetClasses(Lines)+ Else If Cmd = 'track_width' Then Result := CmdTrackWidth(Lines)+ Else If Cmd = 'outline_tracks' Then Result := CmdOutlineTracks(Lines)+ Else If Cmd = 'layer_stack_edit' Then Result := CmdLayerStackEdit(Lines)+ Else If Cmd = 'pcb_postprocess' Then Result := CmdPcbPostProcess(Lines)+ Else If Cmd = 'project_variants' Then Result := CmdProjectVariants(Lines)+ Else If Cmd = 'diff_pairs' Then Result := CmdDiffPairs(Lines) Else If Cmd = 'sch_zoom' Then Result := CmdSchZoom(Lines) Else If Cmd = 'copper_layers' Then Result := CmdCopperLayers(Lines) Else If Cmd = 'read_rules' Then Result := CmdReadRules(Lines)
src/bridge.json+12−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.59",+ "version": "0.2.60", "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",@@ -46,6 +46,7 @@ "altium_apply_pours", "altium_apply_silkscreen", "altium_autoroute",+ "altium_board_outline", "altium_board_shape", "altium_board_summary", "altium_bom",@@ -72,6 +73,7 @@ "altium_describe", "altium_diagnostics", "altium_dialog_log",+ "altium_diff_pairs", "altium_dismiss_dialogs", "altium_doc_params", "altium_ensure_licensed",@@ -105,6 +107,7 @@ "altium_list_library", "altium_list_nets", "altium_move_parts",+ "altium_net_classes", "altium_net_connections", "altium_normalize_bodies", "altium_open_document",@@ -153,6 +156,7 @@ "altium_set_models", "altium_set_outline", "altium_set_params",+ "altium_set_track_width", "altium_set_visibility", "altium_setup_trial", "altium_sheet_params",@@ -176,6 +180,7 @@ "altium_uninstall", "altium_uninstall_library", "altium_update_pcb",+ "altium_variants", "altium_window_info", "altium_xcopy_footprint", "altium_zoom_to"@@ -289,7 +294,12 @@ "annotate": 300, "update_pcb": 600, "export_pdf": 600,- "autoroute": 900+ "autoroute": 900,+ "net_classes": 90,+ "diff_pairs": 90,+ "variants": 300,+ "board_outline": 60,+ "set_track_width": 180 }, "prefixes": [ {