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.61 source (not yet released, needs signing): altium_workspace_search/workspace_place (Altium 365 workspace parts via the Components panel), find_space (free spots, overlap check), sch_swap_part (keeps wiring); update_pcb detail walk fix; skills cleanup backport (disable-model-invocation on altium-bridge-plan/altium-uia, dead refs removed, descriptions <=300 chars)
29 files changed
+755−118
CHANGELOG.md+16PLAN.md+1−1SKILL.md+9−4bridge-rs/src/design.rs+124bridge-rs/src/main.rs+2−1bridge-rs/src/sch.rs+68−1bridge-rs/src/verbs.rs+34−2bridge-rs/src/win32.rs+59bridge-rs/src/workspace.rs+229dev-skills/altium-bridge-test/run_verb_tests.py+8package.json+1−1page.json+1−1skills/altium-a365/SKILL.md+4−8skills/altium-boards/SKILL.md+16−8skills/altium-bom/SKILL.md+3−11skills/altium-bridge-plan/SKILL.md+2−2skills/altium-install/SKILL.md+7−3skills/altium-instances/SKILL.md+3−8skills/altium-kicad-import/SKILL.md+4−9skills/altium-libraries/SKILL.md+4−9skills/altium-licensing/SKILL.md+4−9skills/altium-outputs/SKILL.md+3−6skills/altium-part-import/SKILL.md+4−11skills/altium-schematic-edit/SKILL.md+29−11skills/altium-uia/SKILL.md+1−1skills/altium-wiki-import/SKILL.md+3−8src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+105src/bridge.json+10−2CHANGELOG.md+16@@ -1,5 +1,21 @@ # Changelog +## 0.2.61 (2026-10-22)+**Parts from the Altium 365 workspace, placement help, and part swaps.**+- `altium_workspace_search {query}` and `altium_workspace_place {sheet, query, pick, x, y, designator}`:+ - They search the signed-in Altium 365 workspace (the shared cloud library) and place a result on a sheet, without API access. They drive Altium's own Components panel: the search box takes real keystrokes, and results come from the grid's UI tree. Placement double-clicks the result and clicks the sheet, so the part keeps its workspace link (`itemGuid`), parameters and footprint. It then moves the part to x,y and names it.+ - The resident agent loop is parked while the panel is driven, and the panel is opened through Altium's `-R` command line rather than the agent, so the loop stays parked. With the loop running, the WPF panel answered UI Automation in 100+ s instead of about 15.+ - Altium comes to the front first: an unpinned (side-bar) Components panel opens and answers only then. On Altium Designer Develop the panel lived in the side bar and slid away before the double-click, so each placement attempt reopens it and re-reads the row's position (two attempts at most).+ - Measured on AD 26.11.1 and Develop: a search takes 5-40 s and a placement 15-70 s, depending on whether the panel is already open. A workspace 100 nF 0402 cap reached the board with its workspace footprint through `altium_update_pcb`.+- `altium_find_space {near, part | sizeMm, clearanceMm}`: free spots for a part near another. They are clear of other parts (by Altium's Component Clearance extent: 3D bodies and pads, else every primitive but text), of that side's tracks and vias, and kept inside the outline, nearest first. `check:true` lists parts that overlap or leave the outline, and matches Altium's online DRC on the buck.+- `altium_sch_swap_part {sheet, designator, library, symbol}`: replace a placed part with another library part at the same place, designator and rotation. Pins are matched by number. Where a new pin ends somewhere else, a wire joins it to the old pin end, so the nets hold. Measured: a vertical 0603 cap became a horizontal 0805 one, and the change order listed no net change.+- Agent: `comp_rects` (component extents) and `sch_wires` (polyline wires).+- `altium_update_pcb {detail}` reads every row: the walk no longer stops on the first page that only moved the selection.+- Skills cleanup (backport patch):+ - `altium-bridge-plan` and `altium-uia` are `disable-model-invocation: true`.+ - The dead `grok-bot-setup` and `adom-bridge-sdk-new-app` references are gone.+ - 13 descriptions are trimmed to 300 characters or less. The schematic-edit, boards and BOM descriptions then name the 0.2.60-0.2.61 verbs (workspace parts, swaps, stack-up, net classes, free space, variants).+ ## 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.
PLAN.md+1−1@@ -15,7 +15,7 @@ description: >- altium add-in, altium plan. --- -Parent skill: **adom-bridge-sdk-new-app** (the method) and **adom-bridge-sdk** (the contract).+Parent skill: **adom-bridge-sdk** (the contract). Written 2026-08-29 from live probes of Altium Designer 26.9.1 on a Windows test machine (driven from an Adom container) plus the vendor docs and the community MCP bridges.
SKILL.md+9−4@@ -2,7 +2,10 @@ name: altium-bridge public: true user-invocable: true-description: "Drive Altium Designer on the user's Windows PC from this container via the adom-bridge CLI (verbs altium_*). A resident DelphiScript agent inside Altium gives you eval (arbitrary DelphiScript statements), the 1,100-command process launcher (altium_run_command), open/show of every surface (project, schematic, 2D/3D board, library, symbol, footprint, 3D part) with a returned frame, compile/ERC with the Messages panel, DRC with violations, OutJob runs (Gerber, ODB++, NC drill, BOM, pick-and-place), dialog detection/dismissal by PID, and a full-state screenshot array. Trigger words: altium, altium designer, open in altium, show me the board in altium, altium schematic, altium pcb, altium 3d, altium gerber, altium outjob, altium bom, altium drc, altium erc, compile altium project, altium library, altium symbol, altium footprint, run delphiscript, altium_eval, altium_run_command, altium_state, altium demo, altium bridge."+description: >-+ Drive Altium Designer on the user's Windows PC through adom-bridge (altium_* verbs): DelphiScript+ eval, the process launcher, open any surface with a frame, compile/ERC, DRC, OutJobs, dialogs. Use+ when asked to open in altium, show the board in altium, altium drc or erc, run delphiscript. --- # altium-bridge — drive Altium Designer from a container@@ -20,6 +23,7 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | 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, 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**) |+| Replace a part with another library part, keeping its wiring | `altium_sch_swap_part` (**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` (JLCPCB readiness in `checks.jlcpcb`), `altium-bom price` / `fill` (**altium-bom**) |@@ -28,11 +32,12 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | 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**) |+| 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_find_space` (free spots near a part; overlap check) (**altium-boards**) | | 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**) |+| Parts from the Altium 365 workspace (shared cloud library) | `altium_workspace_search {query}`, `altium_workspace_place {sheet, query, pick, x, y}` (through Altium's own Components panel; no API access needed) (**altium-schematic-edit**) |+| Altium 365 API | `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, 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`.@@ -134,7 +139,7 @@ adom-bridge --ai-thread "board review" altium_export_gerber '{"outjobPath":"C:/d - `altium_import_kicad` / `altium_install_extension` (and `altium_export_step {lane:"extension"}`) ship Barrett's AdomBridge .NET extension, but on AD 26.9.1 the server never loads into the running instance (measured; see the plan skill). They fail loudly with `extension_timeout` until it is rebuilt for AD 26. - Headless STEP IS real: `altium_export_step {filePath, output}` (default lane `outjob`) runs Altium's own ExportSTEP output from a throwaway project in %TEMP%, with no dialog and no click (measured on AD 26.9.1, 2026-10-09). Altium names every part by its DESIGNATOR; `suffixType` none/board/custom/id only appends a suffix, and `products[]` in the reply shows the names.-- `altium_a365_*` need API access: Altium's 365 API is a closed beta (enroll at developer.altium.com); a workspace on the Altium Develop plan shows no Admin → Developer page. The signed-in Altium Designer itself reaches the workspace: its Components panel searches it (driven through UI Automation; a verb is in progress).+- `altium_a365_*` need API access: Altium's 365 API is a closed beta (enroll at developer.altium.com); a workspace on the Altium Develop plan shows no Admin → Developer page. The signed-in Altium Designer itself reaches the workspace: `altium_workspace_search` and `altium_workspace_place` drive its Components panel. ## DelphiScript facts you need for `altium_eval`
bridge-rs/src/design.rs+124@@ -235,3 +235,127 @@ pub fn set_track_width(args: &Value) -> Value { "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."}) }++/// raw Altium units (1e-4 mil) -> mm+fn raw_mm(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) / 10000.0 * 0.0254 }++fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {+ let mut c = false;+ let n = poly.len();+ for i in 0..n {+ let (xi, yi) = poly[i];+ let (xj, yj) = poly[(i + n - 1) % n];+ if (yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi { c = !c; }+ }+ c+}++fn seg_dist(px: f64, py: f64, (ax, ay): (f64, f64), (bx, by): (f64, f64)) -> f64 {+ let (dx, dy) = (bx - ax, by - ay);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((px - ax) * dx + (py - ay) * dy) / l2).clamp(0.0, 1.0) };+ ((px - ax - t * dx).powi(2) + (py - ay - t * dy).powi(2)).sqrt()+}++/// Rect [l, b, r, t] grown by g.+fn grow(r: [f64; 4], g: f64) -> [f64; 4] { [r[0] - g, r[1] - g, r[2] + g, r[3] + g] }+fn hits(a: [f64; 4], b: [f64; 4]) -> bool { a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3] }++/// altium_find_space {near?|at?, part?|sizeMm?, side?, clearanceMm?, edgeMm?, count?, radiusMm?, check?}: free spots for a part+/// near another (clear of every other part on that side, of that side's copper, and inside the outline), or with+/// check:true the parts that already overlap or leave the outline.+pub fn find_space(args: &Value) -> Value {+ if let Some(e) = open_board(args) { return e; }+ let rects = match agent_data(&agent::call("comp_rects", &[], None, Duration::from_secs(120))) { Ok(d) => d, Err(e) => return e };+ let st = crate::routing::routing_state(&json!({}));+ let poly: Vec<(f64, f64)> = st["outline"].as_array().cloned().unwrap_or_default().iter().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect();+ let parts: Vec<(String, [f64; 4], bool)> = rects["rects"].as_array().cloned().unwrap_or_default().iter()+ .map(|r| (r[0].as_str().unwrap_or("").to_string(), [raw_mm(&r[1]), raw_mm(&r[2]), raw_mm(&r[3]), raw_mm(&r[4])], r[5].as_bool().unwrap_or(false))).collect();+ let clr = args.get("clearanceMm").and_then(|v| v.as_f64()).unwrap_or(0.25);+ let edge = args.get("edgeMm").and_then(|v| v.as_f64()).unwrap_or(0.3);+ let in_outline = |r: [f64; 4]| -> bool {+ if poly.len() < 3 { return true; }+ let corners = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])];+ corners.iter().all(|&(x, y)| inside(&poly, x, y) && (0..poly.len()).all(|i| seg_dist(x, y, poly[i], poly[(i + 1) % poly.len()]) >= edge))+ };+ if args.get("check").and_then(|b| b.as_bool()) == Some(true) {+ let mut overlaps = Vec::new();+ for i in 0..parts.len() { for j in i + 1..parts.len() {+ if parts[i].2 == parts[j].2 && hits(parts[i].1, parts[j].1) { overlaps.push(json!([parts[i].0, parts[j].0])); }+ } }+ let outside: Vec<&String> = parts.iter().filter(|p| !in_outline(p.1)).map(|p| &p.0).collect();+ return json!({"success": true, "overlaps": overlaps, "outsideOutline": outside, "parts": parts.len(),+ "_hint": "Overlaps compare each part's extent as Altium's Component Clearance rule does: 3D bodies and pads when it has a body, else every primitive but text (silkscreen outline included). altium_find_space {near, part} finds a free spot; altium_move_parts moves it."});+ }+ let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());+ // what to place: an existing part (its extent and origin) or a size+ let me = s(args, "part").and_then(|p| parts.iter().find(|x| x.0.eq_ignore_ascii_case(&p)).cloned());+ let (w, h, off, my_bottom) = match (&me, args.get("sizeMm").and_then(|v| v.as_array())) {+ (Some((d, r, b)), _) => {+ let c = comp(d).unwrap_or(Value::Null);+ let (ox, oy) = (c["x"].as_f64().unwrap_or((r[0] + r[2]) / 2.0), c["y"].as_f64().unwrap_or((r[1] + r[3]) / 2.0));+ (r[2] - r[0], r[3] - r[1], (ox - (r[0] + r[2]) / 2.0, oy - (r[1] + r[3]) / 2.0), *b)+ }+ (None, Some(sz)) if sz.len() == 2 => (sz[0].as_f64().unwrap_or(1.0), sz[1].as_f64().unwrap_or(1.0), (0.0, 0.0), false),+ _ => return json!({"success": false, "errorCode": "bad_args", "error": "part (a designator on the board) or sizeMm [w, h] is required"}),+ };+ let bottom = match s(args, "side").as_deref() { Some("bottom") => true, Some("top") => false, _ => my_bottom };+ let (cx, cy) = if let Some(n) = s(args, "near") {+ match parts.iter().find(|x| x.0.eq_ignore_ascii_case(&n)) { Some((_, r, _)) => ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0), None => return json!({"success": false, "errorCode": "near_not_found", "error": format!("{n} is not on the board")}) }+ } else if let Some(a) = args.get("at") {+ (a["x"].as_f64().unwrap_or(0.0), a["y"].as_f64().unwrap_or(0.0))+ } else { return json!({"success": false, "errorCode": "bad_args", "error": "near (a designator) or at {x, y} is required"}) };+ let mine = me.as_ref().map(|m| m.0.clone()).unwrap_or_default();+ let mut blocks: Vec<[f64; 4]> = parts.iter().filter(|p| p.2 == bottom && !p.0.eq_ignore_ascii_case(&mine)).map(|p| grow(p.1, clr)).collect();+ if args.get("copper").and_then(|b| b.as_bool()) != Some(false) {+ let layer = if bottom { 32 } else { 1 };+ for t in st["tracks"].as_array().cloned().unwrap_or_default() {+ if t["layerId"].as_i64() != Some(layer) { continue; }+ let (a, b, hw) = ((t["start"]["x"].as_f64().unwrap_or(0.0), t["start"]["y"].as_f64().unwrap_or(0.0)), (t["end"]["x"].as_f64().unwrap_or(0.0), t["end"]["y"].as_f64().unwrap_or(0.0)), t["width"].as_f64().unwrap_or(0.2) / 2.0);+ blocks.push(grow([a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)], hw + clr));+ }+ for v in st["vias"].as_array().cloned().unwrap_or_default() {+ let (x, y, r) = (v["position"]["x"].as_f64().unwrap_or(0.0), v["position"]["y"].as_f64().unwrap_or(0.0), v["size"].as_f64().unwrap_or(0.6) / 2.0);+ blocks.push(grow([x - r, y - r, x + r, y + r], clr));+ }+ }+ let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(5) as usize;+ let radius = args.get("radiusMm").and_then(|v| v.as_f64()).unwrap_or(10.0);+ let step = 0.25;+ let mut cands: Vec<(f64, f64, f64)> = Vec::new();+ let n = (radius / step).ceil() as i64;+ for i in -n..=n { for j in -n..=n {+ let (x, y) = (cx + i as f64 * step, cy + j as f64 * step);+ let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();+ if d <= radius { cands.push((d, x, y)); }+ } }+ cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+ let mut spots: Vec<Value> = Vec::new();+ let mut taken: Vec<[f64; 4]> = Vec::new();+ for (d, x, y) in cands {+ let r = [x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0];+ if blocks.iter().any(|b| hits(r, *b)) || taken.iter().any(|t| hits(r, *t)) || !in_outline(r) { continue; }+ let round = |v: f64| (v * 1000.0).round() / 1000.0;+ spots.push(json!({"x": round(x + off.0), "y": round(y + off.1), "distanceMm": round(d), "extent": [round(r[0]), round(r[1]), round(r[2]), round(r[3])]}));+ taken.push(grow(r, clr));+ if spots.len() >= count { break; }+ }+ json!({"success": !spots.is_empty(), "errorCode": if spots.is_empty() { json!("no_space") } else { Value::Null }, "spots": spots,+ "part": if mine.is_empty() { Value::Null } else { json!(mine) }, "sizeMm": [((w * 1000.0).round() / 1000.0), ((h * 1000.0).round() / 1000.0)], "side": if bottom { "bottom" } else { "top" },+ "_hint": "spots[].x/y are where the part's origin goes: altium_move_parts {parts:[{ref, x, y, rotation}]}, then altium_repour (copper pours are not avoided: a pour clears around the new pads when repoured). Each spot keeps clearanceMm between part extents (3D bodies and pads, or every primitive but text for a part with no body, as Altium's Component Clearance does), from that side's tracks and vias, and edgeMm from the outline; nearest first. altium_find_space {check:true} lists parts that already overlap."})+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn free_space_geometry() {+ let sq = vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+ assert!(inside(&sq, 5.0, 5.0));+ assert!(!inside(&sq, 11.0, 5.0));+ assert!((seg_dist(5.0, 5.0, (0.0, 0.0), (10.0, 0.0)) - 5.0).abs() < 1e-9);+ assert!(hits([0.0, 0.0, 2.0, 2.0], [1.0, 1.0, 3.0, 3.0]));+ assert!(!hits([0.0, 0.0, 1.0, 1.0], grow([1.2, 0.0, 2.0, 1.0], 0.1)));+ assert!(hits([0.0, 0.0, 1.0, 1.0], grow([1.2, 0.0, 2.0, 1.0], 0.25)));+ }+}
bridge-rs/src/main.rs+2−1@@ -29,6 +29,7 @@ mod taskbar; mod variants; mod verbs; mod win32;+mod workspace; use std::time::Instant; use tiny_http::{Header, Method, Response, Server};@@ -36,7 +37,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.60";+pub const BRIDGE_VERSION: &str = "0.2.61"; 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/sch.rs+68−1@@ -430,6 +430,7 @@ fn eco_walk(hwnd: i64, keep_parts: &[String], keep_classes: bool) -> (Vec<Value> let mut unticked = Vec::new(); let mut groups: Vec<Value> = Vec::new(); let mut selected = false;+ let mut idle = 0; for _ in 0..60 { let page = eco_page(hwnd); if page.is_empty() { break; }@@ -441,7 +442,8 @@ fn eco_walk(hwnd: i64, keep_parts: &[String], keep_classes: bool) -> (Vec<Value> 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; }+ // the first PageDown only moves the selection to the page's last row; stop after two pages with nothing new+ if k == texts.len() && !seen.is_empty() { idle += 1; if idle >= 2 { break; } } else { idle = 0; } for (t, r) in &page[k..] { seen.push(key(t)); let data = t.starts_with("True ") || t.starts_with("False ");@@ -617,3 +619,68 @@ mod tests { assert!(pdf_outjob("X.pdf", true).contains("OutputType2=Composite")); } }++/// A pin's electrical end (where wires attach): its location plus its length along its orientation.+fn pin_end(p: &Value) -> (i64, i64) {+ let (x, y, l) = (p["xMil"].as_f64().unwrap_or(0.0), p["yMil"].as_f64().unwrap_or(0.0), p["lengthMil"].as_f64().unwrap_or(0.0));+ let (ex, ey) = match p["orientation"].as_i64().unwrap_or(0) { 0 => (x + l, y), 1 => (x, y + l), 2 => (x - l, y), _ => (x, y - l) };+ (ex.round() as i64, ey.round() as i64)+}++/// altium_sch_swap_part {sheet, designator, library, symbol, footprint?, pcbLib?, keepComment?, save?}: replace a placed part+/// with another library part at the same place, designator and rotation, and keep its connections: where a pin of the+/// new symbol ends somewhere else, a wire joins its end to where the old pin ended.+pub fn swap_part(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let sheet = match need_sheet(args) { Ok(x) => x, Err(e) => return e };+ let (Some(des), Some(lib), Some(sym)) = (s(args, "designator"), s(args, "library"), s(args, "symbol")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "designator, library (.SchLib) and symbol are required"});+ };+ let read = |d: &str| -> Vec<Value> {+ if support::must_open(&sheet, 60).is_some() { return vec![]; }+ let r = agent::call("schematic_data", &[("pins", "1".into()), ("params", "0".into()), ("designator", d.to_string()), ("limit", "50".into())], None, Duration::from_secs(120));+ r.get("data").and_then(|d| d.get("components")).and_then(|c| c.as_array()).cloned().unwrap_or_default()+ };+ let old = read(&des);+ if old.is_empty() { return json!({"success": false, "errorCode": "designator_not_found", "error": format!("{des} is not on {sheet}")}); }+ if old.len() > 1 { return json!({"success": false, "errorCode": "multi_part", "error": format!("{des} has {} parts on this sheet; swap single-part components", old.len())}); }+ let o = &old[0];+ let old_ends: Vec<(String, (i64, i64))> = o["pins"].as_array().cloned().unwrap_or_default().iter().map(|p| (st(&p["number"]), pin_end(p))).collect();+ // remove the old part, place the new one where it was+ let del = agent::call("sch_edit", &[], Some(&format!("{}|delete||", clean(&des))), Duration::from_secs(60));+ if let Err(e) = agent_data(&del) { return json!({"success": false, "errorCode": "delete_failed", "reply": e}); }+ let mut pa = json!({"sheet": sheet, "library": lib, "symbol": sym, "designator": des, "x": o["xMil"], "y": o["yMil"],+ "rotation": o["orientation"].as_i64().unwrap_or(0) * 90, "save": false});+ if args.get("keepComment").and_then(|b| b.as_bool()) == Some(true) { pa["comment"] = o["comment"].clone(); }+ for k in ["footprint", "pcbLib"] { if let Some(v) = args.get(k) { pa[k] = v.clone(); } }+ let placed = place_part(&pa);+ if placed.get("success").and_then(|b| b.as_bool()) != Some(true) {+ return json!({"success": false, "errorCode": "place_failed", "reply": placed, "_hint": format!("The old {des} was deleted: undo in Altium (Ctrl+Z) or place it back with altium_sch_place_part.")});+ }+ let new = read(&des);+ let new_ends: Vec<(String, (i64, i64))> = new.first().and_then(|n| n["pins"].as_array().cloned()).unwrap_or_default().iter().map(|p| (st(&p["number"]), pin_end(p))).collect();+ let mut wires = Vec::new();+ let mut joined = Vec::new();+ let mut unmatched_old = Vec::new();+ for (num, (ox, oy)) in &old_ends {+ match new_ends.iter().find(|(n, _)| n.eq_ignore_ascii_case(num)) {+ Some((_, (nx, ny))) if (nx, ny) != (ox, oy) => {+ // an L: along x first, then y+ wires.push(format!("{ox}|{oy}|{nx}|{oy}|{nx}|{ny}"));+ joined.push(json!({"pin": num, "from": [ox, oy], "to": [nx, ny]}));+ }+ Some(_) => {}+ None => unmatched_old.push(num.clone()),+ }+ }+ let unmatched_new: Vec<String> = new_ends.iter().filter(|(n, _)| !old_ends.iter().any(|(o, _)| o.eq_ignore_ascii_case(n))).map(|(n, _)| n.clone()).collect();+ if !wires.is_empty() {+ let _ = support::must_open(&sheet, 60);+ let _ = agent::call("sch_wires", &[], Some(&wires.join("\n")), Duration::from_secs(60));+ }+ let saved = maybe_save(args, &sheet);+ json!({"success": true, "sheet": sheet, "designator": des, "was": {"libReference": o["libReference"], "comment": o["comment"]},+ "now": {"libReference": placed["libref"], "footprints": placed["footprints"]}, "pinsJoined": joined,+ "oldPinsNotOnNew": unmatched_old, "newPinsNotOnOld": unmatched_new, "saved": saved,+ "_hint": "Pins are matched by number; pinsJoined got a wire from the old pin end to the new one, so the nets hold. oldPinsNotOnNew lost their connection: rewire them with altium_sch_connect. altium_update_pcb carries the new footprint and parameters to the board."})+}
bridge-rs/src/verbs.rs+34−2@@ -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", "net_classes", "diff_pairs", "board_outline", "set_track_width",+ "set_layer_visibility", "footprint_primitives", "schematic_data", "layer_stack", "net_classes", "diff_pairs", "board_outline", "set_track_width", "find_space", // 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", "variants", "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", "workspace_search", "workspace_place", "sheet_params", "sch_place_part", "sch_swap_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@@ -658,6 +658,13 @@ fn describe() -> Value { &["sch_connect", "update_pcb", "sheet_params"], &["Every part of a multi-part component with that designator is changed together."], json!({"sheet": "C:/proj/Main.SchDoc", "edits": [{"designator": "R5", "action": "comment", "text": "10k"}, {"designator": "C9", "action": "delete"}]})),+ verb_doc("sch_swap_part", "Replace a placed part with another library part (a different value, package or symbol) at the same place, designator and rotation, keeping its connections: where a new pin ends elsewhere, a wire joins it to where the old pin ended.",+ json!({"sheet": "path to the .SchDoc (required)", "designator": "the part to replace (required)", "library": "path to the .SchLib (required)", "symbol": "the new part's name in it (required)", "footprint": "optional footprint name", "pcbLib": "optional .PcbLib for that footprint", "keepComment": "bool, default false (the new part's own comment)", "save": "bool, default true"}),+ json!({"pinsJoined": "[{pin, from, to}]", "oldPinsNotOnNew": "[pin]", "newPinsNotOnOld": "[pin]", "now": "{libReference, footprints}"}), 120,+ "Then altium_update_pcb carries the new part's footprint and parameters to the board. A workspace part: altium_workspace_place it beside, connect, then altium_sch_edit delete the old one.",+ &["sch_place_part", "sch_edit", "sch_connect", "update_pcb"],+ &["Pins are matched by number.", "Multi-part components are refused."],+ json!({"sheet": "C:/proj/Main.SchDoc", "designator": "C10", "library": "C:/proj/Lib.SchLib", "symbol": "CC0402-100NF"})), verb_doc("sch_zoom", "Find parts on a sheet: select them (everything else deselected), frame the view on them and return a proof frame. The schematic half of cross-probing (the board half: altium_zoom_to).", json!({"sheet": "path to the .SchDoc (required)", "designators": "[string] or 'R1,U2' (required)", "shot": "bool, default true"}), json!({"parts": "[{designator, xMil, yMil}]", "notOnSheet": "[string]", "shot": "frame of Altium's main window"}), 60,@@ -685,6 +692,27 @@ fn describe() -> Value { &["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("workspace_search", "Search the Altium 365 workspace's components (the shared cloud library Altium is signed in to) through Altium's own Components panel: name, description and footprint per result.",+ json!({"query": "text: an MPN, a value and package ('100nF 0402'), a part name (required)", "limit": "int, default 25"}),+ json!({"results": "[{index, name, description, footprint}]", "count": "int"}), 180,+ "Then altium_workspace_place {sheet, query, pick} puts one on a sheet. Altium's 365 API is a closed beta; the signed-in Altium reaches the workspace itself (altium_signin_status).",+ &["workspace_place", "signin_status", "bom"],+ &["Takes 5-40 s (Altium's WPF panel answers UI Automation slowly; slower when the panel must first be opened).", "It types into the panel's search box: Altium comes to the front."],+ json!({"query": "100nF 0402"})),+ verb_doc("workspace_place", "Put a part from the Altium 365 workspace on a sheet: search, pick a result, place it (Altium's own placement, so the part keeps its workspace link, parameters and footprint), move it to x,y and name it.",+ json!({"sheet": "path to the .SchDoc (required)", "query": "the search (required; an MPN finds one part)", "pick": "result index (0-based) or text its name contains; default the first", "x": "mils", "y": "mils", "designator": "optional, else Altium's ('C?')", "save": "bool, default true"}),+ json!({"picked": "{name, description, footprint}", "placed": "{designator, libReference, comment, itemGuid, sourceLibrary, xMil, yMil}"}), 300,+ "Then altium_annotate (for a '?'), altium_sch_connect, altium_update_pcb. A part from a local .SchLib goes on with altium_sch_place_part instead.",+ &["workspace_search", "sch_place_part", "annotate", "sch_connect", "update_pcb"],+ &["Takes 15-70 s. Uses real clicks (double-click the result, click the sheet): Altium comes to the front.", "The part lands near the view's lower-left corner first, then moves to x,y: wires do not follow, so connect it after."],+ json!({"sheet": "C:/proj/Main.SchDoc", "query": "100nF 0402 50V", "x": 3000, "y": 2000})),+ verb_doc("find_space", "Free spots on the board for a part near another (clear of every other part on that side, of that side's tracks and vias, and inside the outline), nearest first; or check:true lists parts that already overlap or leave the outline.",+ json!({"near": "designator to place beside (or at: {x, y} mm)", "part": "designator of the part to place (its own size and origin), or sizeMm: [w, h]", "side": "top | bottom (default the part's)", "clearanceMm": "default 0.25", "edgeMm": "from the outline, default 0.3", "count": "default 5", "radiusMm": "search radius, default 10", "copper": "bool, default true - avoid that side's tracks and vias", "check": "bool - report overlaps instead"}),+ json!({"spots": "[{x, y (the part origin, for move_parts), distanceMm, extent}]", "overlaps": "[[a, b]] (check)", "outsideOutline": "[designator] (check)"}), 180,+ "Move the part with altium_move_parts {parts:[{ref, x, y, rotation}]} to a spot, then altium_routing_drc. Extents follow Altium's Component Clearance rule: 3D bodies and pads, or every primitive but text for a part with no body.",+ &["move_parts", "routing_state", "routing_drc"],+ &["A part with no 3D body is measured by its silkscreen outline too, as Altium does: dense boards report silk-to-silk overlaps.", "Rotations are kept: rotate first if the part should sit another way."],+ json!({"near": "U1", "part": "C10", "clearanceMm": 0.3})), 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,@@ -1439,8 +1467,12 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "update_pcb" => crate::sch::update_pcb(args), "export_pdf" => crate::sch::export_pdf(args), "sch_zoom" => crate::sch::zoom(args),+ "sch_swap_part" => crate::sch::swap_part(args), "autoroute" => crate::route::autoroute(args), "net_classes" => crate::design::net_classes(args),+ "find_space" => crate::design::find_space(args),+ "workspace_search" => crate::workspace::workspace_search(args),+ "workspace_place" => crate::workspace::workspace_place(args), "set_track_width" => crate::design::set_track_width(args), "board_outline" => crate::design::board_outline(args), "variants" => crate::variants::variants(args),
bridge-rs/src/win32.rs+59@@ -183,6 +183,65 @@ mod proc_imp { } } +#[cfg(windows)]+mod child_imp {+ //! The child windows of a RESPONSIVE window, with titles and physical-pixel rects: finding a docked+ //! panel's host window (the Components panel's WPF host) and the schematic view to click into. Unlike+ //! the freeze-safe module above this sends WM_GETTEXT, so it is for normal operation only.+ use std::sync::Mutex;+ type Hwnd = isize;+ #[repr(C)]+ struct Rect { l: i32, t: i32, r: i32, b: i32 }+ #[link(name = "user32")]+ extern "system" {+ fn EnumChildWindows(parent: Hwnd, cb: extern "system" fn(Hwnd, isize) -> i32, lparam: isize) -> i32;+ fn GetClassNameW(hwnd: Hwnd, buf: *mut u16, len: i32) -> i32;+ fn GetWindowTextW(hwnd: Hwnd, buf: *mut u16, len: i32) -> i32;+ fn IsWindowVisible(hwnd: Hwnd) -> i32;+ fn GetWindowRect(hwnd: Hwnd, r: *mut Rect) -> i32;+ fn GetParent(hwnd: Hwnd) -> Hwnd;+ fn SetThreadDpiAwarenessContext(ctx: isize) -> isize;+ }+ static LOCK: Mutex<()> = Mutex::new(());+ static KIDS: Mutex<Vec<super::ChildWin>> = Mutex::new(Vec::new());+ extern "system" fn on_child(hwnd: Hwnd, _l: isize) -> i32 {+ unsafe {+ let mut c = [0u16; 128];+ let n = GetClassNameW(hwnd, c.as_mut_ptr(), c.len() as i32);+ let mut t = [0u16; 256];+ let m = GetWindowTextW(hwnd, t.as_mut_ptr(), t.len() as i32);+ let mut r = Rect { l: 0, t: 0, r: 0, b: 0 };+ GetWindowRect(hwnd, &mut r);+ if let Ok(mut k) = KIDS.lock() {+ k.push(super::ChildWin { hwnd: hwnd as i64, parent: GetParent(hwnd) as i64,+ class: String::from_utf16_lossy(&c[..n.max(0) as usize]), title: String::from_utf16_lossy(&t[..m.max(0) as usize]),+ visible: IsWindowVisible(hwnd) != 0, rect: (r.l, r.t, r.r, r.b) });+ }+ }+ 1+ }+ pub fn child_windows(parent: i64) -> Vec<super::ChildWin> {+ let _g = LOCK.lock();+ if let Ok(mut k) = KIDS.lock() { k.clear(); }+ unsafe {+ // physical pixels, the space ab's clicks and UIA rects use (-4 = per-monitor aware v2)+ let old = SetThreadDpiAwarenessContext(-4);+ EnumChildWindows(parent as Hwnd, on_child, 0);+ if old != 0 { SetThreadDpiAwarenessContext(old); }+ }+ KIDS.lock().map(|k| k.clone()).unwrap_or_default()+ }+}++/// A child window: its parent, class, title, visibility and rect (left, top, right, bottom; physical pixels).+#[derive(Clone, Debug)]+pub struct ChildWin { pub hwnd: i64, pub parent: i64, pub class: String, pub title: String, pub visible: bool, pub rect: (i32, i32, i32, i32) }++#[cfg(windows)]+pub use child_imp::child_windows;+#[cfg(not(windows))]+pub fn child_windows(_parent: i64) -> Vec<ChildWin> { vec![] }+ #[cfg(windows)] pub use imp::{post_message_close, post_quit_to_thread, visible_windows, WinRow}; #[cfg(windows)]
bridge-rs/src/workspace.rsadded+229@@ -0,0 +1,229 @@+//! Parts from the signed-in Altium 365 workspace (the shared cloud library), through Altium's own Components+//! panel: Altium's 365 API is a closed beta, but the running Altium already searches the workspace. The panel is+//! WPF in a docked host window: its search box takes real typing, its results grid reads through UI Automation,+//! and a result goes onto the sheet the way a person does it (double-click the row, click the sheet).+use crate::support::{self, s};+use crate::{ab, agent, instance};+use serde_json::{json, Value};+use std::collections::HashSet;+use std::time::Duration;++fn main_hwnd() -> Option<i64> {+ instance::invalidate_snapshot();+ instance::main_window().and_then(|w| w.get("hwnd").and_then(|h| h.as_i64()))+}++/// The Components panel's WPF host window, showing the panel first when it is not on screen.+/// What the panel search saw, for a failure reply.+fn panel_diag() -> Value {+ let Some(main) = main_hwnd() else { return json!({"main": null}) };+ let kids = crate::win32::child_windows(main);+ json!({"main": main, "children": kids.len(),+ "panels": kids.iter().filter(|k| k.class == "TXPPanel").map(|k| json!([k.hwnd, k.title, k.visible])).collect::<Vec<_>>(),+ "wpfHosts": kids.iter().filter(|k| k.class.starts_with("HwndWrapper")).map(|k| json!([k.hwnd, k.parent, k.visible])).collect::<Vec<_>>()})+}++/// Altium comes to the front first: an unpinned (pop-out) panel does not open, and its WPF tree does not answer,+/// while another app is in front (measured on the laptop with Hydrogen in front: the tree read timed out).+fn panel_hwnd() -> Option<i64> {+ if let Some(m) = main_hwnd() {+ let _ = ab::call("desktop_bring_to_front", json!({"hwnd": m, "reason": "the workspace search types into Altium's Components panel"}), &[], 20);+ std::thread::sleep(Duration::from_millis(600));+ }+ for attempt in 0..2 {+ let main = main_hwnd()?;+ let kids = crate::win32::child_windows(main);+ // the WPF host hangs under the panel's view windows, all titled "Components" (TLazyLoadViewComponent ->+ // WinForms "Components" -> elementHost), not under the TXPPanel itself (measured AD 26.11.1)+ let panels: Vec<i64> = kids.iter().filter(|k| k.visible && k.title == "Components").map(|k| k.hwnd).collect();+ let host = kids.iter().find(|k| k.visible && k.class.starts_with("HwndWrapper") && {+ // a descendant of the Components panel (the host sits a few windows down)+ let mut p = k.parent;+ let mut hit = false;+ for _ in 0..8 { if panels.contains(&p) { hit = true; break; } match kids.iter().find(|x| x.hwnd == p) { Some(x) => p = x.parent, None => break } }+ hit+ });+ if let Some(h) = host { return Some(h.hwnd); }+ if attempt == 0 {+ // through Altium's -R command line, not the agent: an agent call would restart the loop this verb parked,+ // and with the loop running the panel answers UI Automation in minutes (measured)+ if let Some(exe) = crate::lifecycle::x2_exe_pub() {+ if let Ok(mut child) = crate::lifecycle::spawn_helper_pub(&exe, "-RClient:CustomizeResources(Action=Show|ObjectKind=Panel|ID=Altium.Edp.ComponentSearch.Plugin)") {+ let t = std::time::Instant::now();+ while t.elapsed() < Duration::from_secs(10) { if let Ok(Some(_)) = child.try_wait() { break; } std::thread::sleep(Duration::from_millis(200)); }+ let _ = child.kill();+ }+ }+ std::thread::sleep(Duration::from_secs(3));+ }+ }+ None+}++/// The results grid: one row per DataItem, named "Name; Description; Footprint".+fn read_results(hwnd: i64) -> (Vec<Value>, Option<i64>, Option<(i64, i64)>) {+ fn walk(n: &Value, rows: &mut Vec<Value>, count: &mut Option<i64>, bx: &mut Option<(i64, i64)>, in_grid: bool) {+ let role = n.get("role").and_then(|r| r.as_str()).unwrap_or("");+ let name = n.get("name").and_then(|r| r.as_str()).unwrap_or("");+ // results are the DataItems of the grid; the Details pane below it has DataItems of its own+ let in_grid = in_grid || n.get("automationId").and_then(|a| a.as_str()) == Some("ComponentsGrid");+ if n.get("automationId").and_then(|a| a.as_str()) == Some("TextBoxSearchAdv") {+ if let (Some(x), Some(y)) = (n["rect"]["centerX"].as_i64(), n["rect"]["centerY"].as_i64()) { *bx = Some((x, y)); }+ }+ if role == "DataItem" && in_grid {+ let f: Vec<&str> = name.splitn(3, "; ").collect();+ if f.first().map(|x| !x.trim().is_empty()).unwrap_or(false) {+ rows.push(json!({"name": f[0].trim(), "description": f.get(1).map(|x| x.trim()).unwrap_or(""), "footprint": f.get(2).map(|x| x.trim()).unwrap_or(""), "rect": n.get("rect")}));+ }+ return;+ }+ if role == "Text" { if let Some(c) = name.strip_prefix("Results: ") { *count = c.trim().parse().ok(); } }+ for c in n.get("children").and_then(|c| c.as_array()).into_iter().flatten() { walk(c, rows, count, bx, in_grid); }+ }+ let tree = ab::call("desktop_ui_tree", json!({"hwnd": hwnd, "maxDepth": 8, "maxNodes": 600, "timeout": 150}), &[], 160);+ 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 rows = Vec::new();+ let mut count = None;+ let mut bx = None;+ walk(&root, &mut rows, &mut count, &mut bx, false);+ (rows, count, bx)+}++/// Type the query into the panel's search box (it takes real keystrokes, not a set value) and read the results.+/// UIA lookups by automation id take 30-70 s in this WPF panel (measured), so the box is found in one tree read+/// and clicked, and the old text is replaced with Ctrl+A.+fn search(hwnd: i64, query: &str) -> Result<Vec<Value>, Value> {+ // a panel that has just been shown loads its content lazily: read again until the search box is there+ let mut bx = None;+ for wait in [0u64, 3, 5] {+ std::thread::sleep(Duration::from_secs(wait));+ bx = read_results(hwnd).2;+ if bx.is_some() { break; }+ }+ let Some((x, y)) = bx else {+ return Err(json!({"success": false, "errorCode": "search_box_not_found", "error": "the Components panel's search box is not in its UI tree"}));+ };+ let main = main_hwnd().unwrap_or(0);+ let _ = ab::call("desktop_click", json!({"hwnd": main, "x": x, "y": y, "reason": "focus the Components panel search"}), &[], 20);+ let _ = ab::call("desktop_press_key", json!({"key": "ctrl+a", "hwnd": main, "reason": "select the old search text"}), &[], 20);+ let _ = ab::call("desktop_type", json!({"hwnd": main, "text": query, "reason": "search the Altium workspace library"}), &[], 30);+ let _ = ab::call("desktop_press_key", json!({"key": "Enter", "hwnd": main, "reason": "run the workspace search"}), &[], 20);+ let mut rows = Vec::new();+ for wait in [3u64, 4, 6] {+ std::thread::sleep(Duration::from_secs(wait));+ let (r, count, _) = read_results(hwnd);+ rows = r;+ if !rows.is_empty() || count == Some(0) { break; }+ }+ Ok(rows)+}++fn public_rows(rows: &[Value], limit: usize) -> Vec<Value> {+ rows.iter().take(limit).enumerate().map(|(i, r)| json!({"index": i, "name": r["name"], "description": r["description"], "footprint": r["footprint"]})).collect()+}++/// altium_workspace_search {query, limit?}+pub fn workspace_search(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let Some(q) = s(args, "query").filter(|q| !q.trim().is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "query is required (e.g. '100nF 0402', an MPN, 'TPS54202')"}) };+ let _ = crate::lifecycle::stop_all_live();+ let Some(hwnd) = panel_hwnd() else {+ return json!({"success": false, "errorCode": "components_panel_not_found", "error": "the Components panel did not come up", "seen": panel_diag(), "_hint": "Open a schematic first (altium_show_schematic); the panel needs a document."});+ };+ let t0 = support::now();+ // the resident agent loop keeps Altium's UI thread busy, and this WPF panel then answers UI Automation in+ // 100+ s instead of ~15 (measured): park the loop while the panel is driven; the next verb restarts it+ let rows = match search(hwnd, &q) { Ok(r) => r, Err(e) => return e };+ let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(25) as usize;+ json!({"success": true, "query": q, "count": rows.len(), "results": public_rows(&rows, limit), "elapsedSec": support::elapsed_round(t0),+ "_hint": if rows.is_empty() { "No results in the workspace for that text: try fewer words, the MPN, or the value and package ('100nF 0402')." }+ else { "These come from the Altium workspace Altium is signed in to (and whatever library the panel's selector shows). altium_workspace_place {sheet, query, pick} puts one on a sheet with its workspace link, parameters and footprint." }})+}++fn sheet_parts(sheet: &str) -> Vec<Value> {+ if support::must_open(sheet, 60).is_some() { return vec![]; }+ let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "0".into()), ("limit", "5000".into())], None, Duration::from_secs(120));+ r.get("data").and_then(|d| d.get("components")).and_then(|c| c.as_array()).cloned().unwrap_or_default()+}++fn mil(v: &Value) -> i64 { v.as_f64().map(|f| f.round() as i64).unwrap_or(0) }++/// altium_workspace_place {sheet, query, pick?, x?, y?, designator?, save?}+pub fn workspace_place(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let Some(sheet) = s(args, "sheet").or_else(|| s(args, "filePath")).filter(|p| p.to_lowercase().ends_with(".schdoc")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "sheet (.SchDoc path) is required"});+ };+ let Some(q) = s(args, "query").filter(|q| !q.trim().is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "query is required: the search that finds the part (an MPN is best)"}) };+ let t0 = support::now();+ let before: HashSet<String> = sheet_parts(&sheet).iter().filter_map(|c| c["uniqueId"].as_str().map(str::to_string)).collect();+ let _ = crate::lifecycle::stop_all_live(); // see workspace_search: the panel answers fast only with the loop parked+ let Some(hwnd) = panel_hwnd() else { return json!({"success": false, "errorCode": "components_panel_not_found", "error": "the Components panel did not come up", "seen": panel_diag()}) };+ let rows = match search(hwnd, &q) { Ok(r) => r, Err(e) => return e };+ if rows.is_empty() { return json!({"success": false, "errorCode": "no_results", "query": q, "_hint": "altium_workspace_search with other words; an MPN finds one part."}); }+ // pick: a row index, or text the row's name contains; default the first row+ let row = match args.get("pick") {+ Some(Value::Number(n)) => rows.get(n.as_u64().unwrap_or(0) as usize).cloned(),+ Some(Value::String(t)) => rows.iter().find(|r| r["name"].as_str().map(|n| n.to_lowercase().contains(&t.to_lowercase())).unwrap_or(false)).cloned(),+ _ => rows.first().cloned(),+ };+ let Some(row) = row else { return json!({"success": false, "errorCode": "pick_not_found", "results": public_rows(&rows, 25)}) };+ let (Some(rx), Some(ry)) = (row["rect"]["centerX"].as_i64(), row["rect"]["centerY"].as_i64()) else {+ return json!({"success": false, "errorCode": "row_not_on_screen", "results": public_rows(&rows, 25)});+ };+ let Some(main) = main_hwnd() else { return json!({"success": false, "errorCode": "altium_not_running"}) };+ // the drop point: near the schematic view's lower-left corner, usually blank, so the part lands on no wire+ let view = crate::win32::child_windows(main).into_iter().find(|k| k.visible && k.class == "TSCHView_GraphicalForm");+ let Some(view) = view else { return json!({"success": false, "errorCode": "no_schematic_view", "_hint": "The sheet must be the document on screen (altium_show_schematic)."}) };+ let (l, _, _, b) = view.rect;+ let mut steps = Vec::new();+ let mut new = None;+ // twice at most: a double-click that only brought Altium to the front placed nothing (intermittent on+ // Altium Designer Develop, 2026-10-22)+ for attempt in 1..=2 {+ let _ = ab::call("desktop_bring_to_front", json!({"hwnd": main, "reason": "the placement clicks go to Altium"}), &[], 20);+ std::thread::sleep(Duration::from_millis(500));+ // a pop-out (unpinned) Components panel slides away when Altium takes focus: show it again and read where+ // the row is NOW (on Altium Develop the panel lived in the side bar and the first clicks hit the sheet)+ let (mut px, mut py) = (rx, ry);+ if let Some(h2) = panel_hwnd() {+ let (now, _, _) = read_results(h2);+ if let Some(r) = now.iter().find(|r| r["name"] == row["name"]) {+ if let (Some(x), Some(y)) = (r["rect"]["centerX"].as_i64(), r["rect"]["centerY"].as_i64()) { px = x; py = y; }+ }+ }+ let d1 = ab::call("desktop_double_click", json!({"hwnd": main, "x": px, "y": py, "reason": "pick a workspace part in Altium's Components panel"}), &[], 20);+ steps.push(json!({"attempt": attempt, "pickRow": row["name"], "ok": d1.get("success")}));+ std::thread::sleep(Duration::from_millis(2500));+ let d2 = ab::call("desktop_click", json!({"hwnd": main, "x": l + 160, "y": b - 160, "reason": "drop the workspace part on the sheet"}), &[], 20);+ steps.push(json!({"attempt": attempt, "drop": [l + 160, b - 160], "ok": d2.get("success")}));+ std::thread::sleep(Duration::from_millis(1500));+ for _ in 0..2 { let _ = ab::call("desktop_press_key", json!({"key": "Escape", "hwnd": main, "reason": "end Altium's part placement"}), &[], 20); std::thread::sleep(Duration::from_millis(500)); }+ let after = sheet_parts(&sheet);+ new = after.iter().find(|c| c["uniqueId"].as_str().map(|u| !before.contains(u)).unwrap_or(false)).cloned();+ if new.is_some() { break; }+ let _ = crate::lifecycle::stop_all_live();+ }+ let Some(new) = new else {+ return json!({"success": false, "errorCode": "not_placed", "steps": steps, "_hint": "Altium did not put the part on the sheet after two tries: the part may have no symbol, or a dialog took the clicks. altium_check_dialogs, then try again."});+ };+ // where it should go, and its designator: a temporary unique name lets the edit verbs find exactly this part+ let des0 = new["designator"].as_str().unwrap_or("").to_string();+ let (nx, ny) = (mil(&new["xMil"]), mil(&new["yMil"]));+ let tmp = "ADOMNEW1".to_string();+ let _ = agent::call("sch_rename_at", &[], Some(&format!("{des0}|{tmp}|{nx}|{ny}")), Duration::from_secs(60));+ let (tx, ty) = (args.get("x").and_then(|v| v.as_f64()).map(|v| v.round() as i64), args.get("y").and_then(|v| v.as_f64()).map(|v| v.round() as i64));+ let (fx, fy) = match (tx, ty) {+ (Some(x), Some(y)) => { let _ = agent::call("sch_edit", &[], Some(&format!("{tmp}|move|{x}|{y}")), Duration::from_secs(60)); (x, y) }+ _ => (nx, ny),+ };+ let des = s(args, "designator").unwrap_or(des0.clone());+ let _ = agent::call("sch_rename_at", &[], Some(&format!("{tmp}|{des}|{fx}|{fy}")), Duration::from_secs(60));+ let saved = if args.get("save").and_then(|b| b.as_bool()) == Some(false) { json!(false) } else { json!(crate::surfaces::save(&json!({"filePath": sheet})).get("success")) };+ let placed = sheet_parts(&sheet).into_iter().find(|c| c["uniqueId"] == new["uniqueId"]).unwrap_or(new);+ json!({"success": true, "sheet": sheet, "query": q, "picked": {"name": row["name"], "description": row["description"], "footprint": row["footprint"]},+ "placed": {"designator": placed["designator"], "libReference": placed["libReference"], "comment": placed["comment"], "itemGuid": placed["itemGuid"],+ "sourceLibrary": placed["sourceLibrary"], "xMil": placed["xMil"], "yMil": placed["yMil"]},+ "steps": steps, "saved": saved, "elapsedSec": support::elapsed_round(t0),+ "_hint": "The workspace part is on the sheet with its workspace link (itemGuid), parameters and footprint. Number it with altium_annotate (a '?' designator), wire it with altium_sch_connect, carry it to the board with altium_update_pcb."})+}
dev-skills/altium-bridge-test/run_verb_tests.py+8@@ -231,6 +231,8 @@ def matrix(w): 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)+ # 0.2.61: free space beside U1 for U2's footprint, and the overlap check+ M["find_space"] = dict(phase=3.5, timeout=200, **ok(filePath=LB, near="U1", part="U2", count=3), check=lambda d: len(d.get("spots") or []) >= 1) 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"}],@@ -251,6 +253,12 @@ def matrix(w): check=lambda d: d.get("connected") == 2) 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))+ # 0.2.61: swap U1 for the same library part (connections must hold: pins matched by number); the Altium 365+ # workspace verbs need a signed-in workspace, so a named refusal also passes+ M["sch_swap_part"] = dict(phase=3.5, timeout=200, **ok(sheet=LS, designator="U1", library=SL, symbol="TST_SOIC8"),+ check=lambda d: not d.get("oldPinsNotOnNew"))+ M["workspace_search"] = dict(phase=3.5, timeout=200, **anyo("needs Altium signed in to a workspace", query="10k 0402", timeout=190))+ M["workspace_place"] = dict(phase=3.5, timeout=320, **anyo("needs Altium signed in to a workspace", sheet=LS, query="10k 0402", x=9000, y=3000, designator="R90", timeout=300)) 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 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 []))
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.60",+ "version": "0.2.61", "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.60",+ "version": "0.2.61", "hero": { "type": "image", "path": "screenshots/hero.png"
skills/altium-a365/SKILL.md+4−8@@ -1,14 +1,10 @@ --- name: altium-a365 description: >-- The Altium 365 cloud lane of the altium-bridge: the eleven `altium_a365_*` verbs. Store a- workspace and a Bearer token, or sign in with OAuth (PKCE) using an Altium-issued public- client ID; run GraphQL against the workspace; refresh and revoke tokens; read the workspace- library overview and the library-related schema; upload a file to the Files Service. None of- it needs Altium Designer running. Use when the user asks about their Altium 365 workspace,- projects or cloud library, or when a verb returns a365_not_configured. Trigger words — altium- 365, a365, altium workspace graphql, altium oauth, altium cloud library, a365 token,- altium_a365_query, altium_a365_oauth_begin.+ The Altium 365 cloud lane of altium-bridge (altium_a365_* verbs): workspace and token or OAuth+ sign-in, GraphQL, the cloud library, Files Service upload; no Altium Designer needed. Use when+ asked about an Altium 365 workspace, projects or cloud library, or a verb returns+ a365_not_configured. --- Parent skill: **altium-bridge**
skills/altium-boards/SKILL.md+16−8@@ -1,14 +1,9 @@ --- name: altium-boards description: >-- Boards through the altium-bridge: create a wired test board sized to a part, define a real- board shape, place library parts on schematics and boards (both coordinate frames- normalized), swap footprints on a placed component with the wiring surviving, read ground- truth back (pads/bodies/nets/tracks), and capture 2D/3D frames incl. the bottom view — plus- the measured 3D camera facts (ResetCamera presets work, SetCamera is a no-op, orbiting needs- a synthetic drag). Use for test boards, placement, footprint swaps, board frames. Trigger- words — altium test board, place part on board, swap footprint, altium board shape, altium- 3d camera, board bottom view, pcb report.+ Altium boards through altium-bridge: test boards, shapes, stack-up (planes, 4 layers), net classes,+ diff pairs, track widths, free space for a part, footprint swaps, readback, 2D/3D frames. Use when+ asked for an altium stack-up, net class, where a part fits, or a 3D view. --- Parent skill: **altium-bridge**@@ -205,3 +200,16 @@ altium_diff_pairs '{"name":"USB","pos":"USB_DP","neg":"USB_DN"}' `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`.++## Where does a part fit? (0.2.61)++```bash+altium_find_space '{"near":"U1","part":"C20","clearanceMm":0.25,"count":3}' # spots[].x/y -> altium_move_parts+altium_find_space '{"check":true}' # overlapping parts, parts off the outline+```++Spots are clear of every other part on that side (by Altium's Component Clearance extent: 3D bodies and pads, or+every primitive but text for a part with no body), of that side's tracks and vias, and `edgeMm` inside the outline,+nearest first. Copper pours are not avoided: `altium_repour` after the move clears them around the new pads.+Measured: the first version measured pads only and put C20 against TP2's silkscreen, which Altium's DRC flagged;+the Altium-matched extent avoids it.
skills/altium-bom/SKILL.md+3−11@@ -1,17 +1,9 @@ --- name: altium-bom description: >-- The Altium bill of materials an AI can work with: altium_bom reads every schematic sheet of a- project (device sheets included) with its parts' parameters, merges the board's placement and a- variant, groups one line per manufacturer part number, checks what an order needs (missing MPNs and- distributor numbers, a part number on two footprints, board-only or sheet-only parts, DNP), and- writes bom.csv, a JLCPCB BOM + CPL, and Mouser and DigiKey BOM-upload files. altium-bom (container- CLI) prices it with adom-parts-search (stock, price, lifecycle, JLCPCB-only parts) and writes the- missing distributor numbers back onto the sheets with altium_sheet_params. Use when asked for an- Altium BOM, to price or source an Altium board, to export for JLCPCB, Mouser or DigiKey, or to fill- in part numbers. Trigger words: altium bom, bill of materials, price the bom, source the parts,- jlcpcb bom, cpl, pick and place, mouser bom, digikey bom, missing part numbers, altium_bom,- altium_sheet_params, altium-bom.+ The Altium BOM an AI can work with: altium_bom reads every sheet, merges placement and variants,+ groups by MPN, flags order and JLCPCB gaps, writes JLCPCB, Mouser and DigiKey files; altium_variants+ makes variants. Use for an altium bom, pricing, variants (DNP) or JLCPCB. --- Parent skill: **altium-bridge**
skills/altium-bridge-plan/SKILL.md+2−2@@ -1,6 +1,6 @@ --- name: altium-bridge-plan-user-invocable: false+disable-model-invocation: true description: >- THE plan of record for the Altium Designer bridge (keyword `altium`, verbs `altium_*`) — every automation surface Altium exposes, measured on a real AD 26.9.1 install, ranked into@@ -15,7 +15,7 @@ description: >- altium add-in, altium plan. --- -Parent skill: **adom-bridge-sdk-new-app** (the method) and **adom-bridge-sdk** (the contract).+Parent skill: **adom-bridge-sdk** (the contract). Written 2026-08-29 from live probes of Altium Designer 26.9.1 on a Windows test machine (driven from an Adom container) plus the vendor docs and the community MCP bridges.
skills/altium-install/SKILL.md+7−3@@ -1,6 +1,10 @@ --- name: altium-install-description: "Install Altium Designer on the user's Windows PC from an Adom container via adom-bridge — sign in to altium.com (incl. Google SSO + account linking), pull the gated online installer, verify its Authenticode signature, launch it, and drive the wizard. Documents the hard UIPI wall: the installer elevates itself, so a non-elevated adom-bridge CANNOT click the wizard past the EULA page. Also the altium-setup-trial recipe: the account/MSA gate, the free-trial route, and Google SSO account linking. Trigger words — install altium, altium install, altium setup, altium setup trial, altium trial, altium free trial, altium designer, AltiumDesignerSetup, altium sign in, altium account, altium download, altium license, altium EULA, altium installer stuck, altium wizard not clicking, altium UIA."+description: >-+ Install Altium Designer on the user's Windows PC via adom-bridge: altium.com sign-in (incl. Google+ SSO), the gated installer and its signature, the wizard and its UIPI wall, the free trial. Use+ when asked to install altium, altium setup or trial, altium download, or the altium installer is+ stuck. --- Parent skill: **altium-bridge**@@ -205,7 +209,7 @@ text, scoped by `hwnd` or by `window` title, with or without `role`. controls that publish no UIA/MSAA provider. There is nothing to find, at any integrity level. This is *not* UIPI; do not "fix" it by chasing elevation. -Contrast with the `grok-bot-setup` skill, where UIA automation of an NSIS wizard+Contrast with an NSIS wizard, where UIA automation works with real automationIds: NSIS uses standard Win32 controls that expose themselves to UIA. Toolkit, not privilege, decides whether UIA can see a wizard. @@ -632,7 +636,7 @@ authenticator.cursor.sh/grok-bot?client_id=...&redirect_uri=https://cursor.com/a ``` — xAI/Cursor's **Grok Bot** sign-in, belonging to an entirely unrelated workflow-(see the `grok-bot-setup` skill), which happened to pop while the Altium+which happened to pop while the Altium installer sat idle on the EULA page. Clicking "Sign in with Grok" / "Sign in with Cursor" would have authenticated the user into a different commercial product.
skills/altium-instances/SKILL.md+3−8@@ -1,14 +1,9 @@ --- name: altium-instances description: >-- More than one Altium can run at once (Altium Designer beside Altium Designer Develop), often- driven by two different AI threads on one machine. How to address a specific one with- instance:<pid|key|word>, why a verb REFUSES with ambiguous_instance instead of guessing, the- per-instance agent workspace that stops two resident loops eating each other's requests, and the- three places single-instance assumptions used to hide. Read before driving Altium on a machine- where another AI thread may also be working, or when a verb returns ambiguous_instance. Trigger- words — two altiums, altium designer develop, ADD vs AD, ambiguous_instance, instance selector,- which altium, second instance, parallel ai threads, per-instance workspace.+ Several Altiums at once (Altium Designer beside Altium Designer Develop, often two AI threads):+ address one with instance:<pid|key|word>, why a verb refuses with ambiguous_instance. Use when two+ altiums are running, which altium, a second instance, or a verb returns ambiguous_instance. --- Parent skill: **altium-bridge**
skills/altium-kicad-import/SKILL.md+4−9@@ -1,15 +1,10 @@ --- name: altium-kicad-import description: >-- Import KiCad parts into Altium Designer correctly, especially KiCad 10. Altium 26's own- importer supports KiCad through 9 only — a raw v10 import silently drops property- visibility (hidden fields arrive visible, piled on the symbol) and imports text in the- wrong font. Two lanes: downgrade v10→v9 with altium-kicad-downgrade then use Altium's- importer, or convert natively with altium-kicad2altium + altium_build_footprint /- altium_build_symbol_modes (no importer at all). Carries barrett-land/kicad10-to-altium's- measured findings into the master altium-bridge skillpack. Trigger words — kicad to altium,- kicad 10 altium import, overlapping fields after import, hidden fields visible, imported- symbol wrong font, downgrade kicad 10 library.+ Import KiCad parts, especially KiCad 10, into Altium Designer correctly: downgrade v10 to v9 for+ Altium's importer, or convert natively with altium-kicad2altium. Use when asked for kicad to+ altium, a kicad 10 altium import, hidden fields visible after import, or an imported symbol in the+ wrong font. --- Parent skill: **altium-bridge**
skills/altium-libraries/SKILL.md+4−9@@ -1,15 +1,10 @@ --- name: altium-libraries description: >-- Author native Altium libraries through the altium-bridge: symbols with N display modes- (alternate symbols), footprints with via pads / rounded-rect pads / named mechanical- layers, STEP or extruded 3D bodies that persist and sit on the pads, footprint variants as- models on one component, parameters with the tri-vendor rule (Mouser preferred + DigiKey +- LCSC), and the measured pin-1 check. Everything here was proven on the CH340G and LAN7800- imports. Use when creating or editing .SchLib/.PcbLib content, binding 3D bodies, building- variant sets, or verifying pin-1. Trigger words — altium library authoring, altium display- modes, alternate symbol altium, footprint variants, altium 3d body, solder ball layer,- heatsink vias altium, pin-1 check.+ Author native Altium libraries through altium-bridge: symbols with display modes, footprints and+ variants, persistent 3D bodies, Mouser + DigiKey + LCSC parameters, the pin-1 check. Use when+ creating or editing .SchLib/.PcbLib content, binding 3D bodies, footprint variants, or verifying+ pin-1. --- Parent skill: **altium-bridge**
skills/altium-licensing/SKILL.md+4−9@@ -1,15 +1,10 @@ --- name: altium-licensing description: >-- Altium's On-Demand licence seat, and the ONE mistake that costs an hour: killing Altium strands- the seat, after which the licence page reports it FREE (Seats 0/1) while refusing every claim- with "License maximum usage reached". Covers the one-seat-per-machine rule (Altium Designer and- Altium Designer Develop share it), how to swap the seat between apps, how to recover a stranded- one, and why Develop's window title does not say it is licensed. Read when a verb returns- not_licensed, when the title says "No License" or "Viewer", when a claim fails, or BEFORE closing- or killing any Altium. Trigger words — altium licence, altium license, no license, viewer mode,- license maximum usage reached, license renewal failed, seat, on-demand, use license, release- license, stranded seat, altium_close force, two altiums one seat.+ Altium's On-Demand licence seat: killing Altium strands it; one seat per machine shared by+ Designer and Develop; how to swap or recover it. Read before closing or killing any Altium. Use+ when a verb returns not_licensed, the title says No License or Viewer, or license maximum usage+ reached. --- Parent skill: **altium-bridge**
skills/altium-outputs/SKILL.md+3−6@@ -1,12 +1,9 @@ --- name: altium-outputs description: >-- Checks and manufacturing outputs through the altium-bridge: compile/ERC with the Messages- panel read back, DRC with violations, OutJob runs (Gerber, ODB++, NC drill, BOM,- pick-and-place) with the files actually written, headless STEP export, and the current- caveats (PDF jobs, A365 token gating). Use when running design checks or generating- fab/assembly outputs from Altium. Trigger words — altium drc, altium erc, compile altium,- altium gerber, altium outjob, altium bom, pick and place, odb++, nc drill, altium step+ Checks and manufacturing outputs through altium-bridge: compile/ERC with Messages read back, DRC+ violations, OutJob runs (Gerber, ODB++, NC drill, BOM, pick-and-place), headless STEP export. Use+ when asked for altium drc, erc, compile altium, altium gerbers, an outjob, odb++, or altium step export. ---
skills/altium-part-import/SKILL.md+4−11@@ -1,17 +1,10 @@ --- name: altium-part-import description: >-- Add any catalog component (resistor, capacitor, inductor, IC, connector, diode…) to the- user's Altium Designer library with Mouser, DigiKey AND LCSC/JLCPCB part numbers attached —- Mouser is Adom's preferred vendor; the exact `LCSC Part #` parameter keeps JLCPCB BOM +- pick-and-place round-tripping. Drives Altium through the adom/altium-bridge verbs (resident- DelphiScript agent). Three paths, cheapest first: REUSE an existing symbol for a new value- (clone + parametric footprint, no fetch), CREATE from a downloaded .IntLib or a wiki part- page, APPEND into an existing library (cross-lib copy). Supersedes- barrett-land/altium-part-importer (same workflow, this bridge's verbs). Use when the user- wants to "add a part/component/resistor/IC/chip to Altium", "import a footprint or symbol- into Altium", or build out an Altium component library. For Adom's OWN machine pins/contacts- use adom-library-importer instead.+ Add any catalog part to the user's Altium library with Mouser, DigiKey and LCSC/JLCPCB numbers via+ altium-bridge: reuse a symbol, create from an .IntLib or wiki part page, or append across+ libraries. Use when asked to add a part, resistor or IC to Altium, or import a footprint or+ symbol. --- Parent skill: **altium-bridge**
skills/altium-schematic-edit/SKILL.md+29−11@@ -1,17 +1,9 @@ --- name: altium-schematic-edit description: >-- Edit an existing Altium design's schematic and push it to the board, the way an engineer does at the- sheet: place a library part (altium_sch_place_part), connect pins with net labels or power ports- (altium_sch_connect), move/rotate/rename/re-comment/delete parts (altium_sch_edit), number '?'- designators (altium_annotate), set the title block (altium_doc_params), write part parameters- (altium_sheet_params), carry the changes to the board with a previewed engineering change order- (altium_update_pcb), and print the schematic to PDF (altium_export_pdf). Use when asked to add a part,- wire something up, change a value, rename or delete a component, annotate, update the PCB from the- schematic, fill in the title block or export a schematic PDF in Altium. Trigger words: add a resistor- in altium, place a part, connect pin, net label, power port, gnd symbol, change value, rename designator,- delete component, annotate, update pcb, engineering change order, eco, import changes, title block,- schematic pdf, altium_sch_connect, altium_update_pcb.+ Edit an Altium schematic and push it to the board: place library or Altium 365 workspace parts, swap+ parts, wire pins, edit, annotate, title block, a previewed ECO, PDF. Use when asked to add a part in+ altium, use a workspace part, change or swap a part, or update pcb. --- Parent skill: **altium-bridge**@@ -45,6 +37,32 @@ altium_update_pcb '{"projectPath":"C:\\proj\\board.PrjPcb","execute":true,"t top-left first. `dryRun:true` shows the plan. Multi-part components with `?` are left to Altium's own Tools > Annotation (`skippedMultiPart`). +## Parts from the Altium 365 workspace (the shared cloud library)++```bash+altium_workspace_search '{"query":"100nF 0402"}' # name, description, footprint per result+altium_workspace_place '{"sheet":"C:\\proj\\Main.SchDoc","query":"100nF 0402 50V","pick":0,"x":3000,"y":1500,"designator":"C20"}'+```++Altium's 365 API is a closed beta, but the signed-in Altium searches its workspace itself: these verbs drive its+Components panel. `workspace_place` places the result the way a person does (double-click it, click the sheet),+so the part keeps its workspace link (`itemGuid`), parameters and footprint, then moves it to x,y and names it.+Measured on AD 26.11.1 and Altium Designer Develop: a search answers in 5-40 s and a placement in 15-70 s (fast+when the Components panel is already open and pinned, slower when it has to be opened from the side bar); the+part reached the board with its workspace footprint through `altium_update_pcb`. Altium comes to the front for+both: an unpinned panel only opens, and only answers, while Altium is in front. Pass `pick` (an index or text the name contains) when the query finds several parts; an MPN finds one.++## Swap a part for another library part++```bash+altium_sch_swap_part '{"sheet":"C:\\proj\\Main.SchDoc","designator":"C4","library":"C:\\proj\\Lib.SchLib","symbol":"CL21A226MPQNNNE"}'+```++Same place, designator and rotation, the new part's own parameters and footprint. Pins are matched by number;+where a new pin ends somewhere else, a wire joins it to where the old pin ended (`pinsJoined`), so the nets+hold. Measured: C4 from a vertical 0603 cap to a horizontal 0805 one; the change order then listed only the+footprint, comment and parameters, no net change. `oldPinsNotOnNew` lists pins that lost their connection.+ ## Find parts on the sheet (cross-probe) ```bash
skills/altium-uia/SKILL.md+1−1@@ -1,6 +1,6 @@ --- name: altium-uia-user-invocable: false+disable-model-invocation: true description: >- DEVELOPER-only. Driving Altium Designer through Windows UI Automation (ab's desktop_ui_* verbs) instead of synthetic clicks, and the measured catalog of what AD26 actually exposes: dialogs,
skills/altium-wiki-import/SKILL.md+3−8@@ -1,14 +1,9 @@ --- name: altium-wiki-import description: >-- Import a component from the Adom wiki into Altium — one part page, or a whole Cloud Library- (Colby's component cart) — with symbol, footprint, 3D body and Mouser/DigiKey/LCSC identity, in- seconds, without foregrounding Altium. Covers the Cloud Library's own Altium export (export --format- altium: libraries without distributor numbers), the bridge pipeline that adds them, plus the- background proof captures the wiki front-end needs. Read when asked to "add this wiki part to- Altium", "install my cloud library into Altium", or to prove an import worked. Trigger words —- wiki part to altium, cloud library, adom-wiki library, import component altium, kicad_sym to- altium, kicad_mod to altium, import_part, library export, prove the import.+ Import a component from the Adom wiki into Altium (one part page or a whole Cloud Library) with+ symbol, footprint, 3D body and Mouser/DigiKey/LCSC identity, in the background. Use when asked to+ add this wiki part to Altium, install my cloud library into Altium, or prove an import worked. --- Parent skill: **altium-bridge**
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.60+0.2.61
src/agent/AdomAgent.pas.tmpl+105@@ -4420,6 +4420,70 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('count', IntToStr(N)) + ',' + JRaw('comps', '[' + S + ']') + '}'; End; +{ comp_rects: every component's extent the way Altium's Component Clearance rule sees it: its 3D bodies and pads+ when it has a body, else every primitive but text (pads, silkscreen and other lines, fills, regions; measured+ 2026-10-22: a part with no body clashed by its silkscreen outline). Raw units relative to the board origin:+ [des, l, b, r, t, bottom, hasBody] }+Function CmdCompRects(Lines : TStringList) : String;+Var+ Board : IPCB_Board;+ BIt : IPCB_BoardIterator;+ GIt : IPCB_GroupIterator;+ Comp : IPCB_Component;+ Prim : IPCB_Primitive;+ R : TCoordRect;+ S : String;+ L, B, Rt, T, L2, B2, R2, T2 : Integer;+ AnyAll, AnyBody, HasBody : Boolean;+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;+ S := '';+ BIt := Board.BoardIterator_Create;+ BIt.AddFilter_ObjectSet(MkSet(eComponentObject));+ BIt.AddFilter_LayerSet(AllLayers);+ BIt.AddFilter_Method(eProcessAll);+ Comp := BIt.FirstPCBObject;+ While Comp <> Nil Do+ Begin+ AnyAll := False; AnyBody := False; HasBody := False;+ L := 0; B := 0; Rt := 0; T := 0; L2 := 0; B2 := 0; R2 := 0; T2 := 0;+ GIt := Comp.GroupIterator_Create;+ GIt.AddFilter_ObjectSet(MkSet(ePadObject, eTrackObject, eArcObject, eFillObject, eRegionObject, eComponentBodyObject));+ Prim := GIt.FirstPCBObject;+ While Prim <> Nil Do+ Begin+ R := Prim.BoundingRectangle;+ If Not AnyAll Then Begin L := R.Left; B := R.Bottom; Rt := R.Right; T := R.Top; AnyAll := True; End+ Else Begin+ If R.Left < L Then L := R.Left; If R.Bottom < B Then B := R.Bottom;+ If R.Right > Rt Then Rt := R.Right; If R.Top > T Then T := R.Top;+ End;+ If (Prim.ObjectId = ePadObject) Or (Prim.ObjectId = eComponentBodyObject) Then+ Begin+ If Prim.ObjectId = eComponentBodyObject Then HasBody := True;+ If Not AnyBody Then Begin L2 := R.Left; B2 := R.Bottom; R2 := R.Right; T2 := R.Top; AnyBody := True; End+ Else Begin+ If R.Left < L2 Then L2 := R.Left; If R.Bottom < B2 Then B2 := R.Bottom;+ If R.Right > R2 Then R2 := R.Right; If R.Top > T2 Then T2 := R.Top;+ End;+ End;+ Prim := GIt.NextPCBObject;+ End;+ Comp.GroupIterator_Destroy(GIt);+ If HasBody Then Begin L := L2; B := B2; Rt := R2; T := T2; End;+ If AnyAll Then+ Begin+ If S <> '' Then S := S + ',';+ S := S + '["' + JsonEsc(Comp.Name.Text) + '",' + IntToStr(L - Board.XOrigin) + ',' + IntToStr(B - Board.YOrigin) + ',' ++ IntToStr(Rt - Board.XOrigin) + ',' + IntToStr(T - Board.YOrigin) + ',' + JBool(Comp.Layer = eBottomLayer) + ',' + JBool(HasBody) + ']';+ End;+ Comp := BIt.NextPCBObject;+ End;+ Board.BoardIterator_Destroy(BIt);+ Result := '{' + JRaw('ok','true') + ',' + JRaw('rects', '[' + S + ']') + '}';+End;+ { list_designators: every component designator on the current board } Function CmdListDesignators(Lines : TStringList) : String; Var@@ -7340,6 +7404,45 @@ Begin Result := '{' + JRaw('ok','true') + ',' + JRaw('results', '[' + S + ']') + '}'; End; +{ sch_wires: wires on the current sheet, one per payload line: x1|y1|x2|y2[|x3|y3...] in mils (a polyline). }+Function CmdSchWires(Lines : TStringList; Payload : String) : String;+Var+ Doc : ISch_Document;+ Ops : TStringList;+ W : ISch_Wire;+ I, K, N, Made : Integer;+ Op : String;+Begin+ Doc := SchServer.GetCurrentSchDocument;+ If (Doc = Nil) Or (Doc.ObjectID = eSchLib) Then Begin Result := '{' + JRaw('ok','false') + ',' + JStr('errorCode','no_current_schematic') + ',' + JStr('error','no current schematic sheet (open a .SchDoc first)') + '}'; Exit; End;+ Ops := TStringList.Create; Ops.Text := Payload;+ Made := 0;+ SchServer.ProcessControl.PreProcess(Doc, '');+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Trim(Ops[I]);+ If Op = '' Then Continue;+ N := 0;+ While FieldP(Op, 2 * N + 1) <> '' Do N := N + 1;+ If N < 2 Then Continue;+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(MilsToCoord(StrToIntDef(FieldP(Op, 1), 0)), MilsToCoord(StrToIntDef(FieldP(Op, 2), 0)));+ For K := 1 To N Do+ Begin+ W.InsertVertex := K;+ W.SetState_Vertex(K, Point(MilsToCoord(StrToIntDef(FieldP(Op, 2 * K - 1), 0)), MilsToCoord(StrToIntDef(FieldP(Op, 2 * K), 0))));+ End;+ W.LineWidth := eSmall;+ Doc.RegisterSchObjectInContainer(W);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ Made := Made + 1;+ End;+ SchServer.ProcessControl.PostProcess(Doc, '');+ Doc.GraphicallyInvalidate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('wires', IntToStr(Made)) + '}';+End;+ { sch_zoom: select the named parts on the current sheet (everything else deselected) and zoom the view to them. designators=R1,U2 } Function CmdSchZoom(Lines : TStringList) : String;@@ -8079,7 +8182,9 @@ 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 = 'sch_wires' Then Result := CmdSchWires(Lines, Payload) Else If Cmd = 'net_classes' Then Result := CmdNetClasses(Lines)+ Else If Cmd = 'comp_rects' Then Result := CmdCompRects(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)
src/bridge.json+10−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.60",+ "version": "0.2.61", "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",@@ -85,6 +85,7 @@ "altium_export_step", "altium_extension_ping", "altium_extract_intlib",+ "altium_find_space", "altium_footprint_primitives", "altium_foreground", "altium_import_kicad",@@ -147,6 +148,7 @@ "altium_sch_connect", "altium_sch_edit", "altium_sch_place_part",+ "altium_sch_swap_part", "altium_sch_zoom", "altium_schematic_data", "altium_select_net",@@ -182,6 +184,8 @@ "altium_update_pcb", "altium_variants", "altium_window_info",+ "altium_workspace_place",+ "altium_workspace_search", "altium_xcopy_footprint", "altium_zoom_to" ],@@ -299,7 +303,11 @@ "diff_pairs": 90, "variants": 300, "board_outline": 60,- "set_track_width": 180+ "set_track_width": 180,+ "workspace_search": 180,+ "workspace_place": 300,+ "find_space": 180,+ "sch_swap_part": 180 }, "prefixes": [ {