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.66 source (not yet released, needs signing): multi-part symbols end to end (placed parts keep their part, annotate numbers U4A/U4B, hidden power pins, pin objects), altium_board_features (mounting holes, fiducials, stitching vias), sch_connect noerc, crash-box handling, mask-expansion lint; 174 verbs; batteries 173/0 on AD 26.11.1 and Altium Designer Develop
20 files changed
+569−58
CHANGELOG.md+14README.md+4−3SKILL.md+1bridge-rs/src/dialogs.rs+8bridge-rs/src/features.rs+265bridge-rs/src/library.rs+41−1bridge-rs/src/main.rs+2−1bridge-rs/src/routing.rs+3−3bridge-rs/src/sch.rs+70−14bridge-rs/src/verbs.rs+12−4dev-skills/altium-bridge-test/run_verb_tests.py+16−3package.json+1−1page.json+1−1scripts/release.py+2skills/altium-boards/SKILL.md+22−3skills/altium-libraries/SKILL.md+7−2skills/altium-schematic-edit/SKILL.md+5−2src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+91−18src/bridge.json+3−1CHANGELOG.md+14@@ -1,5 +1,19 @@ # Changelog +## 0.2.66 (2026-10-23)+**Multi-part symbols end to end.** 0.2.65 could make a dual op-amp, but the sheet read part 2 back as part 1, so annotate could not number it.+- `altium_sch_place_part {partId}`: the placed part carries the symbol's part count (it was clamped to one part), and every part's objects with their own part numbers, the way Altium's own placement builds it. Shared power pins are no longer copied onto each part.+- `altium_schematic_data`: each part reports `partCount`, and lists only its own pins plus the shared ones, each with `part`, `hidden` and `hiddenNet`. It used to list every pin of the symbol, or the pins of other display modes.+- `altium_annotate` numbers multi-part `?` parts: the parts of one symbol on a sheet share a number (U4A + U4B), grouped top-left first. They used to be left to Altium's Tools > Annotation.+- `altium_create_symbol`: a pin's 6th field hides it on a net (`8,V+,left,300,0,VCC`). Visible shared power pins make the compiler report duplicate pins and duplicate net names (measured, also with Altium's own placement); hidden, the project compiles clean. Pins given as objects (`{number, name, side, offsetMil?, part?, hiddenNet?}`) work. They used to reach the agent as their JSON text and come out as one garbage pin, which the battery did not notice.+- New verb `altium_board_features`: mounting holes (at points, or `{corners:{inset}}`; unplated bare holes or plated on a net), fiducials (1 mm copper, 2 mm mask opening, no paste) and a stitching-via grid on a net, computed by the bridge from the board: clear of other nets' copper, every pad, the edge, this call's holes and fiducials, and out from under parts. `dryRun` returns the plan. Measured on the battery board: 4 holes, 2 fiducials and 38 GND vias in one undo step, every one landed; the fiducials read back mask +0.5 mm and paste -1 mm. The reply's `holeRule` says how to widen a fab hole-size rule that stops below the holes. 174 verbs.+- A pad's mask and paste expansion are written through the pad cache. Writing `Pad.SolderMaskExpansion` directly crashed Altium Develop (an access violation in ScriptingSystem.DLL); the release lint now refuses it, and `MidXSize` stays refused.+- Altium's crash-report box ("Access violation ...", TShowException) is catalogued. The bridge closes it like its title-bar X and never presses Send and Close, which would send a report to Altium. (Escape closed it on the laptop; the close path in the bridge has not met a live crash box yet.)+- `altium_sch_connect {kind:"noerc"}`: a No ERC marker on a pin's outer end, for a pin left open on purpose (no wire, no net). Measured: two markers on U3, compile reported neither (a marker placed off any pin was reported as "Floating No ERC").+- Verb battery: `sch_connect` adds a No ERC marker (3 connections must land).+- Verb battery: `board_features` on the lane board (2 mm corner holes, a fiducial, 4 stitching vias; all 9 must land).+- Verb battery: `create_symbol` makes a dual op-amp from pin objects with hidden power pins and checks all 8 pins and both parts arrive.+ ## 0.2.65 (2026-10-22) **Everything coffeenmusic/altium-mcp does, and the Designer + Develop machine.** Colby asked to compare with that open-source Altium MCP server (43 tools) and make sure we do everything it does. The gaps: - `altium_selection {select?}`: what is selected in the current board (mm, rotation, side, footprint, size) or sheet (mils), or select exactly the named parts to show the user.
README.md+4−3@@ -11,7 +11,7 @@ and the verb prefix `altium_`. ## What it can do -Tested on Altium Designer 26.11.1 and Altium Designer Develop. 173 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 174 verbs; each row names the ones behind it (prefix `altium_`). | Area | What the AI can do | Verbs |@@ -23,6 +23,7 @@ behind it (prefix `altium_`). | Checks | Compile and ERC with the Messages panel read back, DRC with each violation's location, a 14-check design review, schematic-to-board cross-check | `compile`, `run_erc`, `run_drc`, `routing_drc`, `review_board`, `cross_check` | | Schematic to board | Update PCB with the whole change order previewed row by row first, keeping board-only parts, net classes and rules | `update_pcb` | | Board setup | Build a board from a netlist, shape the outline, edit the stack-up (add planes and inner layers, set copper, dielectrics and total thickness), write a fab's design rules, add scoped rules, net classes and differential pairs | `build_board`, `set_outline`, `board_outline`, `layer_stack`, `set_design_rules`, `add_rule`, `net_classes`, `diff_pairs` |+| Board furniture | Mounting holes (at points or the corners), fiducials with a mask opening and no paste, a stitching-via grid that keeps clear of other nets, pads and parts | `board_features` | | Placement | Move and rotate parts or shift them by an offset (read back to 0.001 mm), find free space for a part near another, suggest rotations that shorten connections, copy one channel's layout to the others, swap footprints, read or set the selection | `move_parts`, `find_space`, `check_orientation`, `replicate_layout`, `swap_footprint`, `selection` | | Routing and copper | Route a net with waypoints and vias, run Altium's autorouter, rip up nets, resize routed tracks, pour polygons with keepouts, repour, place vias, clean up the silkscreen | `route_net`, `autoroute`, `ripup_nets`, `set_track_width`, `apply_pours`, `repour`, `place_vias`, `apply_silkscreen` | | BOM | Bill of materials straight from Altium, grouped by MPN with a variant applied, JLCPCB / Mouser / DigiKey files, ordering gaps flagged; write parameters back to the sheets; assembly variants | `bom`, `sheet_params`, `variants` |@@ -47,7 +48,7 @@ Current, heat and EMC analysis of an Altium board runs through [AI Flow](https:/ Altium Designer is the user's **host app**. The bridge *detects* an existing install (`%ProgramFiles%\Altium\AD*\X2.EXE`), launches it in the background, bootstraps a **resident-DelphiScript agent** inside it (the REPL lane), and hands the AI 173 `altium_*` verbs.+DelphiScript agent** inside it (the REPL lane), and hands the AI 174 `altium_*` verbs. - **Page / docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -155,7 +156,7 @@ A halted engine (DelphiScript runtime errors are modal) recovers with `altium_ag **Board & schematic introspection (0.1.11):** `list_designators`, `component_data`, `component_pins`, `list_nets`, `net_connections`, `layers`, `copper_layers`, `read_rules`, `set_layer_visibility`, `footprint_primitives`, `schematic_data` — read the live design as data (pads→nets, rules, layers, sheet components with pins), all in tens of milliseconds, no dialogs. -All 173 verbs (prefix `altium_`). Call `altium_describe` for the live catalog.+All 174 verbs (prefix `altium_`). Call `altium_describe` for the live catalog. **Readiness & lifecycle** — `readiness`, `status`, `launch`, `close`, `diagnostics`, `progress`, `agent_start/_stop/_status/_recover`, `license`, `signin_status`, `connect_workspace`, `uninstall`.
SKILL.md+1@@ -33,6 +33,7 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | 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_find_space` (free spots near a part; overlap check) (**altium-boards**) |+| Mounting holes, fiducials, stitching vias | `altium_board_features {mountingHoles, fiducials, stitching, dryRun}` (**altium-boards**) | | What is selected in Altium; select parts to show the user | `altium_selection {select?}` (**altium-boards**) | | Rotations, channel copies, a precise clearance check | `altium_check_orientation`, `altium_replicate_layout`, `altium_find_space {check, precise}` (**altium-boards**) | | Find a symbol or footprint by name; multi-part symbols; batches | `altium_find_symbol`, `altium_create_symbol {pins with part}`, `{symbols:[..]}` (**altium-libraries**) |
bridge-rs/src/dialogs.rs+8@@ -58,6 +58,8 @@ const CATALOG: &[(&str, &str, &str, &str)] = &[ "The licence (Develop entitlement or a lease) is dead server-side: Altium runs but refuses scripting and can wedge in a modal loop on close. The bridge cannot renew an entitlement - the human renews or re-activates it in the Altium workspace. NOTE: Develop shows NO title licence tail in this state either, so the absent-tail 'Licensed' inference is WRONG while this box is up (measured 2026-09-21)."), ("license renewal failed", "licence_seat_stranded", "OK", "Altium could not renew the On-Demand lease. Same cause and fix as 'maximum usage reached': a killed instance is still holding the seat. This dialog RE-FIRES on a timer until the seat is reclaimed, so clearing it is not the fix."),+ ("access violation", "altium_internal", "Send and Close",+ "Altium's crash-report box after an internal fault (an access violation in ScriptingSystem.DLL when a script wrote a pad's mask expansion directly, 2026-10-23). The bridge closes it WITHOUT sending a report. Altium keeps running; the script that faulted stopped part-way: read the document back before trusting what landed."), ("cannot focus a disabled or invisible window", "altium_internal", "Send and Close", "Altium's own crash-report dialog, usually raised when a window's state changed while it was focusing a document view. Its only button SENDS a report to Altium — try the title-bar X first if the user's data matters."), ("unsaved changes", "save_prompt", "",@@ -242,6 +244,12 @@ fn is_licence_chooser(btns: &[String]) -> bool { /// WM_CLOSE on the dialog: the same dismissal its titlebar X performs. Never for any other /// button: closing a question box would silently answer it with its cancel path. pub fn press(hwnd: i64, button: &str) -> Value {+ // Altium's crash-report box (TShowException): its only button SENDS a report to Altium. Close it like its title-bar+ // X instead, which sends nothing (Escape did the same on 2026-10-23).+ if button.eq_ignore_ascii_case("send and close") && crate::win32::post_message_close(hwnd) {+ return json!({"success": true, "clicked": "close (no report sent)", "hwnd": hwnd, "via": "wm_close",+ "note": "Altium's crash-report box was closed without sending a report"});+ } let r = ab::call("desktop_ui_click", json!({"hwnd": hwnd, "name": button, "role": "button"}), &[], 15); let ok = r.get("success").and_then(|b| b.as_bool()).unwrap_or(false); if !ok && button.eq_ignore_ascii_case("ok") && crate::win32::post_message_close(hwnd) {
bridge-rs/src/features.rsadded+265@@ -0,0 +1,265 @@+//! Board furniture an AI adds to every real board: mounting holes, fiducials and stitching vias. The stitching plan is+//! computed here from altium_routing_state (outline, and every piece of copper on another net), so it needs no dialog.+use serde_json::{json, Value};++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn pt(v: &Value) -> (f64, f64) { (f(&v[0]), f(&v[1])) }++fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {+ let mut c = false;+ let n = poly.len();+ for i in 0..n {+ let (a, b) = (poly[i], poly[(i + n - 1) % n]);+ if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+ }+ c+}++fn seg_dist(px: f64, py: f64, a: (f64, f64), b: (f64, f64)) -> f64 {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l2 = dx * dx + dy * dy;+ let t = if l2 == 0.0 { 0.0 } else { (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0) };+ ((px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2)).sqrt()+}++/// Copper that a stitching via on `net` must keep clear of: (kind, geometry, half-width) as segments and discs.+enum Ob { Seg((f64, f64), (f64, f64), f64), Disc(f64, f64, f64) }++fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) {+ let other = |v: &Value| !v["net"].as_str().unwrap_or("").eq_ignore_ascii_case(net);+ let mut ob = Vec::new();+ let mut same_vias = Vec::new();+ for t in state["tracks"].as_array().cloned().unwrap_or_default() {+ if other(&t) { ob.push(Ob::Seg((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)); }+ }+ for a in state["arcs"].as_array().cloned().unwrap_or_default() {+ if !other(&a) { continue; }+ // an arc as short chords+ let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+ let (mut a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+ let mut span = a2 - a1;+ if span <= 0.0 { span += 360.0; }+ let n = ((span / 10.0).ceil() as usize).max(1);+ let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+ for _ in 0..n { let b = a1 + span / n as f64; ob.push(Ob::Seg(p(a1), p(b), f(&a["width"]) / 2.0)); a1 = b; }+ }+ for v in state["vias"].as_array().cloned().unwrap_or_default() {+ let (x, y) = (f(&v["position"]["x"]), f(&v["position"]["y"]));+ if other(&v) { ob.push(Ob::Disc(x, y, f(&v["size"]) / 2.0)); } else { same_vias.push((x, y)); }+ }+ for p in state["pads"].as_array().cloned().unwrap_or_default() {+ // a pad on the net still gets clearance: a via in a pad is not stitching. Its circumscribed circle keeps it+ // conservative at any rotation.+ let r = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+ ob.push(Ob::Disc(f(&p["x"]), f(&p["y"]), r.max(f(&p["drill"]) / 2.0)));+ }+ (ob, same_vias)+}++/// Where stitching vias for `net` go: a grid at `pitch` inside the outline (or `region`), `edge` mm clear of the+/// board edge, `clearance` mm clear of copper on other nets and of every pad, half a pitch from vias the net already+/// has, at most `max`. Pure, for the tests.+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {+ plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true)+}++/// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with+/// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance).+#[allow(clippy::too_many_arguments)]+pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,+ extra: &[(f64, f64, f64)], avoid_parts: bool) -> Vec<(f64, f64)> {+ let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+ if outline.len() < 3 || pitch <= 0.0 { return vec![]; }+ let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());+ let (mut ob, same) = obstacles(state, net);+ for (x, y, r) in extra { ob.push(Ob::Disc(*x, *y, *r)); }+ // each part's pad box: a via between a chip's pad rows sits under its body+ let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = Default::default();+ if avoid_parts {+ for p in state["pads"].as_array().cloned().unwrap_or_default() {+ let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };+ let h = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;+ let (x, y) = (f(&p["x"]), f(&p["y"]));+ let b = boxes.entry(r.to_string()).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN));+ *b = (b.0.min(x - h), b.1.min(y - h), b.2.max(x + h), b.3.max(y + h));+ }+ }+ let (x0, x1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ let r = size / 2.0;+ let mut out = Vec::new();+ let mut y = y0 + pitch / 2.0;+ while y < y1 && out.len() < max {+ let mut x = x0 + pitch / 2.0;+ while x < x1 && out.len() < max {+ let ok = inside(&outline, x, y) && inside(&area, x, y)+ && (0..outline.len()).all(|k| seg_dist(x, y, outline[k], outline[(k + 1) % outline.len()]) >= edge + r)+ && ob.iter().all(|o| match o {+ Ob::Seg(a, b, hw) => seg_dist(x, y, *a, *b) - hw - r >= clearance,+ Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,+ })+ && same.iter().all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= pitch / 2.0)+ && boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3);+ if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }+ x += pitch;+ }+ y += pitch;+ }+ out+}++/// The four corner positions of the outline's bounding box, `inset` mm in.+pub fn corners(state: &Value, inset: f64) -> Vec<(f64, f64)> {+ let o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();+ if o.is_empty() { return vec![]; }+ let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));+ let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));+ vec![(x0 + inset, y0 + inset), (x1 - inset, y0 + inset), (x1 - inset, y1 - inset), (x0 + inset, y1 - inset)]+}++pub fn region_of(v: Option<&Value>) -> Option<Vec<(f64, f64)>> {+ let a = v?.as_array()?;+ let r: Vec<(f64, f64)> = a.iter().map(pt).collect();+ if r.len() >= 3 { Some(r) } else { None }+}++pub fn summary(points: &[(f64, f64)]) -> Value {+ json!(points.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>())+}++fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(d) }++/// altium_board_features {mountingHoles?, fiducials?, stitching?, dryRun?, expectPath?, expectedRevision?}+pub fn board_features(args: &Value) -> Value {+ use crate::routing::{apply, guarded, raw, BOTTOM, TOP};+ if let Some(g) = crate::support::need_licensed() { return g; }+ // the board named (filePath, or the expectPath guard) is opened first: after schematic work the current document is+ // a sheet and the verb answered no_current_board (battery, 2026-10-23)+ if let Some(p) = crate::support::s(args, "filePath").or_else(|| crate::support::s(args, "expectPath")) {+ if let Some(e) = crate::support::must_open(&p, 60) { return e; }+ }+ let st = match guarded(args) { Ok(s) => s, Err(e) => return e };+ let mut ops: Vec<String> = Vec::new();+ let mut planned = serde_json::Map::new();+ // P|x|y|padSize|hole|plated|layer|net|name|maskExpansion (raw units; layer 74 = multi-layer)+ const MULTI: i64 = 74;+ // mounting holes: a list of {x, y}, or {corners: {inset}} for the four corners of the outline+ let holes = args.get("mountingHoles");+ let (hole_pts, hole_spec): (Vec<(f64, f64)>, Value) = match holes {+ Some(Value::Array(a)) => (a.iter().map(|h| (num(h, "x", 0.0), num(h, "y", 0.0))).collect(), a.first().cloned().unwrap_or(json!({}))),+ Some(o @ Value::Object(_)) => {+ let inset = o.get("corners").map(|c| if c.is_object() { num(c, "inset", 4.0) } else { num(o, "inset", 4.0) }).unwrap_or(4.0);+ (corners(&st, inset), o.clone())+ }+ _ => (vec![], json!({})),+ };+ let mut hole_list = Vec::new();+ for (i, (x, y)) in hole_pts.iter().enumerate() {+ let h = match holes { Some(Value::Array(a)) => a[i].clone(), _ => hole_spec.clone() };+ let drill = num(&h, "drill", 3.2);+ let plated = h.get("plated").and_then(|b| b.as_bool()).unwrap_or(false);+ // unplated: the copper is the hole itself (no ring); plated: a ring, M3 default 6 mm+ let pad = if plated { num(&h, "pad", drill + 2.8) } else { drill };+ let net = h.get("net").and_then(|n| n.as_str()).unwrap_or("");+ if plated && !net.is_empty() && !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false))).unwrap_or(false) {+ return json!({"success": false, "errorCode": "no_such_net", "error": format!("{net} is not a net on this board"), "nets": st["nets"]});+ }+ ops.push(format!("P|{}|{}|{}|{}|{}|{}|{}|MH{}|", raw(*x), raw(*y), raw(pad), raw(drill), if plated { 1 } else { 0 }, MULTI, net, i + 1));+ hole_list.push(json!({"x": x, "y": y, "drill": drill, "pad": pad, "plated": plated, "net": net}));+ }+ if !hole_list.is_empty() { planned.insert("mountingHoles".into(), json!(hole_list.clone())); }+ // fiducials: bare copper dots with a wider mask opening+ let mut fid_list = Vec::new();+ for (i, fd) in args.get("fiducials").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter().enumerate() {+ let (x, y) = (num(fd, "x", 0.0), num(fd, "y", 0.0));+ let size = num(fd, "size", 1.0);+ let opening = num(fd, "opening", size * 2.0);+ let layer = if fd.get("side").and_then(|s| s.as_str()) == Some("bottom") { BOTTOM } else { TOP };+ ops.push(format!("P|{}|{}|{}|0|0|{}||FID{}|{}", raw(x), raw(y), raw(size), layer, i + 1, raw((opening - size) / 2.0)));+ fid_list.push(json!({"x": x, "y": y, "size": size, "opening": opening, "side": if layer == BOTTOM { "bottom" } else { "top" }}));+ }+ if !fid_list.is_empty() { planned.insert("fiducials".into(), json!(fid_list.clone())); }+ // stitching: a via grid on one net, clear of everything else+ if let Some(sv) = args.get("stitching") {+ let Some(net) = sv.get("net").and_then(|n| n.as_str()) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "stitching needs net (e.g. GND)"});+ };+ if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(net)).unwrap_or(false))).unwrap_or(false) {+ return json!({"success": false, "errorCode": "no_such_net", "error": format!("{net} is not a net on this board"), "nets": st["nets"],+ "_hint": "Stitching ties copper of a net that already has pads on the board (its pours connect through the vias)."});+ }+ let (pitch, size, drill) = (num(sv, "pitch", 5.0), num(sv, "size", 0.6), num(sv, "drill", 0.3));+ let (clearance, edge, max) = (num(sv, "clearance", 0.3), num(sv, "edge", 1.0), num(sv, "max", 400.0) as usize);+ let region = region_of(sv.get("region"));+ // the holes and fiducials of this same call (a fiducial's whole mask opening stays bare)+ let mut extra: Vec<(f64, f64, f64)> = hole_list.iter().map(|h| (num(h, "x", 0.0), num(h, "y", 0.0), num(h, "pad", 3.2) / 2.0)).collect();+ extra.extend(fid_list.iter().map(|fd| (num(fd, "x", 0.0), num(fd, "y", 0.0), num(fd, "opening", 2.0) / 2.0)));+ let avoid_parts = sv.get("underParts").and_then(|b| b.as_bool()) != Some(true);+ let pts = plan_stitching_with(&st, net, pitch, size, clearance, edge, region.as_deref(), max, &extra, avoid_parts);+ for (x, y) in &pts { ops.push(format!("V|{}|{}|{}|{}|{}|{}|{}", raw(*x), raw(*y), raw(size), raw(drill), TOP, BOTTOM, net)); }+ planned.insert("stitching".into(), json!({"net": net, "pitch": pitch, "size": size, "drill": drill, "clearance": clearance, "edge": edge, "count": pts.len(), "points": summary(&pts)}));+ }+ if ops.is_empty() {+ return json!({"success": false, "errorCode": "nothing_to_do", "error": "pass mountingHoles, fiducials and/or stitching",+ "_hint": "mountingHoles: [{x,y,drill?,plated?,pad?,net?}] or {corners:{inset:4}, drill:3.2}; fiducials: [{x,y,size?:1,opening?:2,side?}]; stitching: {net, pitch?:5, size?:0.6, drill?:0.3, clearance?:0.3, edge?:1, region?:[[x,y]..], max?:400}"});+ }+ if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "dryRun": true, "planned": planned, "_hint": "Nothing placed. Call again without dryRun to place exactly this."});+ }+ let mut r = apply(&ops, &st, "board_features");+ if let Some(maxd) = hole_list.iter().map(|h| num(h, "drill", 0.0)).reduce(f64::max) {+ // a fab's hole-size rule usually stops below a mounting hole (the battery board's flagged 3.2 mm, 2026-10-23)+ r["holeRule"] = json!(format!("Mounting holes are {maxd} mm. If altium_run_drc reports HoleSize on them, widen the rule: altium_set_design_rules {{hole: {{min: <your fab's min>, max: {maxd}}}}}."));+ }+ r["planned"] = json!(planned);+ if r.get("success").and_then(|b| b.as_bool()) == Some(true) {+ r["_hint"] = json!("Placed as one undo step. Pours on the stitched net connect through the vias after altium_repour; run altium_run_drc to confirm clearances.");+ }+ r+}++#[cfg(test)]+mod tests {+ use super::*;++ fn board() -> Value {+ json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],+ "tracks": [{"start": {"x": 0.0, "y": 5.0}, "end": {"x": 20.0, "y": 5.0}, "width": 0.25, "net": "SIG"}],+ "arcs": [], "vias": [{"position": {"x": 2.5, "y": 2.5}, "size": 0.6, "net": "GND"}],+ "pads": [{"x": 17.5, "y": 7.5, "sizeX": 1.0, "sizeY": 1.0, "drill": 0.0, "net": "GND"}]})+ }++ #[test]+ fn stitching_keeps_clear_of_other_nets_pads_edges_and_existing_vias() {+ let p = plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 100);+ // the 5 mm grid is 2.5/7.5/12.5/17.5 by 2.5/7.5; (2.5,2.5) has a GND via already, (17.5,7.5) is a pad+ assert_eq!(p, vec![(7.5, 2.5), (12.5, 2.5), (17.5, 2.5), (2.5, 7.5), (7.5, 7.5), (12.5, 7.5)]);+ // a track of another net through the grid row blocks it+ let mut b = board();+ b["tracks"][0]["start"]["y"] = json!(2.5);+ b["tracks"][0]["end"]["y"] = json!(2.5);+ assert!(plan_stitching(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100).iter().all(|p| p.1 != 2.5));+ // a region limits it, max caps it+ let reg = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];+ assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, Some(®), 100), vec![(7.5, 2.5), (2.5, 7.5), (7.5, 7.5)]);+ assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 2).len(), 2);+ }++ #[test]+ fn stitching_stays_off_holes_and_out_from_under_parts() {+ let mut b = board();+ // a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body+ b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},+ {"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);+ let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true);+ assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));+ let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false);+ assert!(p.contains(&(7.5, 7.5)));+ }++ #[test]+ fn corner_holes_sit_inset_from_the_outline() {+ assert_eq!(corners(&board(), 3.0), vec![(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (3.0, 7.0)]);+ }+}
bridge-rs/src/library.rs+41−1@@ -4,6 +4,7 @@ //! Installed Libraries, hardened for untrusted zips). use crate::support::{self, s, s_or, timeout_of}; use crate::{ab, agent, shot};+use std::collections::HashMap; use serde_json::{json, Value}; use std::sync::Mutex; use std::time::{Duration, Instant};@@ -64,6 +65,33 @@ pub fn create_footprint(args: &Value) -> Value { r } +/// The pins argument as the agent's 'des,name,side,offset[,part[,hiddenNet]];...' text. Pins given as objects+/// ({number|designator, name, side, offsetMil?, part?, hiddenNet?}) used to reach the agent as their JSON text and+/// came out as one garbage pin (found 2026-10-23); without an offset they stack down their side 100 mil apart.+pub fn pins_spec(pins: Option<&Value>) -> String {+ match pins {+ Some(Value::Array(a)) => {+ let mut next: HashMap<String, i64> = HashMap::new();+ a.iter().map(|p| {+ let g = |k: &str| p.get(k).map(|v| match v { Value::String(x) => x.clone(), o => o.to_string() }).unwrap_or_default();+ let num = if g("number").is_empty() { g("designator") } else { g("number") };+ let side = if g("side").is_empty() { "left".to_string() } else { g("side").to_lowercase() };+ let off = p.get("offsetMil").or_else(|| p.get("offset")).and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|x| x.parse().ok())));+ let slot = next.entry(side.clone()).or_insert(100);+ let off = off.unwrap_or(*slot);+ *slot = off + 100;+ let mut f = vec![num, g("name"), side, off.to_string()];+ let (part, hidden) = (g("part"), g("hiddenNet"));+ if !part.is_empty() || !hidden.is_empty() { f.push(if part.is_empty() { "1".into() } else { part }); }+ if !hidden.is_empty() { f.push(hidden); }+ f.iter().map(|x| x.replace([',', ';'], " ")).collect::<Vec<_>>().join(",")+ }).collect::<Vec<_>>().join(";")+ }+ Some(Value::String(x)) => x.clone(),+ _ => String::new(),+ }+}+ pub fn create_symbol(args: &Value) -> Value { if let Some(g) = support::need_licensed() { return g;@@ -78,7 +106,7 @@ pub fn create_symbol(args: &Value) -> Value { &[("name", s_or(args, "name", "")), ("libraryPath", s_or(args, "libraryPath", "")), ("description", s_or(args, "description", "")), ("designator", s_or(args, "designator", "U")), ("body_w_mil", s_or(args, "bodyWidthMil", "800")), ("body_h_mil", s_or(args, "bodyHeightMil", "600")),- ("pins", s_or(args, "pins", ""))],+ ("pins", pins_spec(args.get("pins")))], None, timeout_of(args, 90)); let mut r = with_shot(r); if r.get("_hint").is_none() {@@ -1099,3 +1127,15 @@ mod tests { assert_eq!(sanitise_lib_name("a b/c"), "a_b_c"); } }++#[cfg(test)]+mod pin_tests {+ use super::*;+ #[test]+ fn pin_objects_become_the_agent_spec() {+ let p = json!([{"number": "1", "name": "A", "side": "left"}, {"number": "2", "name": "B", "side": "left"},+ {"designator": "8", "name": "V+", "side": "right", "offsetMil": 300, "part": 0, "hiddenNet": "VCC"}]);+ assert_eq!(pins_spec(Some(&p)), "1,A,left,100;2,B,left,200;8,V+,right,300,0,VCC");+ assert_eq!(pins_spec(Some(&json!("1,A,left,100"))), "1,A,left,100");+ }+}
bridge-rs/src/main.rs+2−1@@ -11,6 +11,7 @@ mod design; mod dialogs; mod health; mod install;+mod features; mod inspect; mod instance; mod library;@@ -38,7 +39,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.65";+pub const BRIDGE_VERSION: &str = "0.2.66"; 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/routing.rs+3−3@@ -267,7 +267,7 @@ pub fn routing_state(args: &Value) -> Value { out } -fn raw(mm: f64) -> i64 {+pub(crate) fn raw(mm: f64) -> i64 { (mm * PER_MM).round() as i64 } @@ -290,7 +290,7 @@ fn err(code: &str, msg: String, hint: &str) -> Value { /// Read the live board and check the caller's guard: the right board and the revision it last /// read. A board changed behind the caller's back is refused, never edited blind.-fn guarded(args: &Value) -> Result<Value, Value> {+pub(crate) fn guarded(args: &Value) -> Result<Value, Value> { let st = routing_state(&json!({"expectPath": args.get("expectPath").cloned().unwrap_or(Value::Null)})); if st.get("success").and_then(|v| v.as_bool()) != Some(true) { return Err(st);@@ -310,7 +310,7 @@ fn guarded(args: &Value) -> Result<Value, Value> { } /// Send one batch to the agent's edit_board and read the board back.-fn apply(ops: &[String], before: &Value, what: &str) -> Value {+pub(crate) fn apply(ops: &[String], before: &Value, what: &str) -> Value { if ops.is_empty() { return err("nothing_to_do", format!("{what}: no edits in the request"), "Pass at least one item."); }
bridge-rs/src/sch.rs+70−14@@ -42,7 +42,7 @@ pub fn connect(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(list) = args.get("connections").and_then(|c| c.as_array()).filter(|a| !a.is_empty()) else {- return json!({"success": false, "errorCode": "bad_args", "error": "connections is required: [{designator, pin, net, kind?}]", "_hint": "kind: label (a net label, default), power (a power port; GND-like nets get a ground symbol), wire (a stub only)"});+ return json!({"success": false, "errorCode": "bad_args", "error": "connections is required: [{designator, pin, net, kind?}]", "_hint": "kind: label (a net label, default), power (a power port; GND-like nets get a ground symbol), wire (a stub only), noerc (a No ERC marker on a pin left open on purpose; no net)"}); }; let lines: Vec<String> = list.iter().map(|c| format!("{}|{}|{}|{}|{}", clean(&st(&c["designator"])), clean(&st(&c["pin"])), clean(&st(&c["net"])), clean(&st(&c["kind"])), c.get("stubMil").and_then(|v| v.as_i64()).unwrap_or(200))).collect();@@ -167,6 +167,43 @@ pub fn plan_annotation(sheets: &[(String, Vec<(String, i64, i64)>)]) -> Vec<(Str out } +/// A multi-part component's `?` parts and the numbers they get: on each sheet, parts of one symbol are grouped (top-left+/// first) so that no group holds the same part twice or more parts than the symbol has, and each group takes the lowest+/// number its prefix has free. `taken` holds every designator already numbered (on the sheets or by the single-part+/// plan). Pure, for the tests.+pub fn plan_multipart(sheets: &[(String, Vec<(String, String, i64, i64, i64, i64)>)], taken: &[String]) -> Vec<(String, String, String, i64, i64)> {+ let mut used: HashMap<String, HashSet<u64>> = HashMap::new();+ for d in taken {+ let pre: String = d.chars().take_while(|c| !c.is_ascii_digit()).collect();+ if let Ok(n) = d[pre.len()..].parse::<u64>() { used.entry(pre.to_ascii_uppercase()).or_default().insert(n); }+ }+ let mut out = Vec::new();+ for (sheet, comps) in sheets {+ // (designator, libReference, partId, partCount, x, y)+ let mut q: Vec<&(String, String, i64, i64, i64, i64)> = comps.iter().filter(|c| c.0.ends_with('?')).collect();+ q.sort_by_key(|c| (c.1.to_ascii_uppercase(), -c.5 / 100, c.4));+ let mut groups: Vec<(String, String, Vec<i64>, i64, String)> = Vec::new(); // lib, prefix, partIds, partCount, number+ for c in q {+ let pre = c.0.trim_end_matches('?').to_string();+ let gi = groups.iter().position(|g| g.0.eq_ignore_ascii_case(&c.1) && g.1.eq_ignore_ascii_case(&pre) && !g.2.contains(&c.2) && (g.2.len() as i64) < c.3.max(1));+ let gi = match gi {+ Some(i) => i,+ None => {+ let set = used.entry(pre.to_ascii_uppercase()).or_default();+ let mut n = 1;+ while set.contains(&n) { n += 1; }+ set.insert(n);+ groups.push((c.1.clone(), pre.clone(), vec![], c.3, format!("{pre}{n}")));+ groups.len() - 1+ }+ };+ groups[gi].2.push(c.2);+ out.push((sheet.clone(), c.0.clone(), groups[gi].4.clone(), c.4, c.5));+ }+ }+ out+}+ /// altium_annotate {projectPath, dryRun?}: number every `?` designator across the project's sheets. pub fn annotate(args: &Value) -> Value { if let Some(g) = support::need_licensed() { return g; }@@ -174,25 +211,33 @@ pub fn annotate(args: &Value) -> Value { let text = match std::fs::read(&prj) { Ok(b) => String::from_utf8_lossy(&b).to_string(), Err(e) => return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj}: {e}")}) }; let project = crate::bom::parse_project(&text, Path::new(&prj).parent().unwrap_or(Path::new("."))); let mut sheets = Vec::new();- let mut skipped = Vec::new();+ let mut multi_sheets = Vec::new();+ let mut taken_multi: Vec<String> = Vec::new(); for sheet in &project.sheets { if let Some(e) = support::must_open(sheet, 60) { return json!({"success": false, "errorCode": "sheet_open_failed", "sheet": sheet, "reply": e}); } let r = agent::call("schematic_data", &[("pins", "0".into()), ("params", "0".into()), ("limit", "5000".into())], None, timeout_of(args, 120)); let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e }; let all = d["components"].as_array().cloned().unwrap_or_default();- // a multi-part component's parts share one designator and cannot be told apart here: left to- // Altium's own Tools > Annotation, and said so- for c in all.iter().filter(|c| st(&c["designator"]).ends_with('?') && c["partId"].as_i64().unwrap_or(1) > 1) {- skipped.push(json!({"sheet": sheet, "designator": st(&c["designator"]), "libReference": st(&c["libReference"]), "partId": c["partId"]}));- }- let comps: Vec<(String, i64, i64)> = all.iter().filter(|c| c["partId"].as_i64().unwrap_or(1) <= 1)- .map(|c| (st(&c["designator"]), c["xMil"].as_i64().unwrap_or(0), c["yMil"].as_i64().unwrap_or(0))).collect();+ // a multi-part component's parts share one designator: grouped by plan_multipart. A part placed before 0.2.66+ // reads partCount 1 whatever its symbol has (the instance was clamped), so it is numbered as a single part.+ let multi = |c: &Value| c["partCount"].as_i64().unwrap_or(1) > 1;+ let xy = |c: &Value| (c["xMil"].as_i64().unwrap_or(0), c["yMil"].as_i64().unwrap_or(0));+ let comps: Vec<(String, i64, i64)> = all.iter().filter(|c| !(multi(c) && st(&c["designator"]).ends_with('?')) && c["partId"].as_i64().unwrap_or(1) <= 1)+ .map(|c| (st(&c["designator"]), xy(c).0, xy(c).1)).collect();+ let mp: Vec<(String, String, i64, i64, i64, i64)> = all.iter().filter(|c| multi(c) && st(&c["designator"]).ends_with('?'))+ .map(|c| (st(&c["designator"]), st(&c["libReference"]), c["partId"].as_i64().unwrap_or(1), c["partCount"].as_i64().unwrap_or(1), xy(c).0, xy(c).1)).collect();+ // numbered multi-part parts hold their numbers too+ for c in all.iter().filter(|c| multi(c) && !st(&c["designator"]).ends_with('?')) { taken_multi.push(st(&c["designator"])); } sheets.push((sheet.clone(), comps));+ multi_sheets.push((sheet.clone(), mp)); }- let plan = plan_annotation(&sheets);+ let mut plan = plan_annotation(&sheets);+ let mut taken: Vec<String> = sheets.iter().flat_map(|(_, c)| c.iter().map(|x| x.0.clone())).chain(plan.iter().map(|p| p.2.clone())).collect();+ taken.extend(taken_multi);+ plan.extend(plan_multipart(&multi_sheets, &taken)); let planned: Vec<Value> = plan.iter().map(|(sh, from, to, x, y)| json!({"sheet": sh, "from": from, "to": to, "atMil": [x, y]})).collect();- if plan.is_empty() { return json!({"success": true, "renamed": [], "skippedMultiPart": skipped, "_hint": "Every single-part designator already has a number."}); }- if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) { return json!({"success": true, "dryRun": true, "planned": planned, "skippedMultiPart": skipped}); }+ if plan.is_empty() { return json!({"success": true, "renamed": [], "_hint": "Every designator already has a number."}); }+ if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) { return json!({"success": true, "dryRun": true, "planned": planned}); } // each rename names its part by position: several parts on a sheet can all read 'R?' let mut by_sheet: BTreeMap<String, Vec<String>> = BTreeMap::new(); for (sh, from, to, x, y) in &plan { by_sheet.entry(sh.clone()).or_default().push(format!("{}|{}|{}|{}", clean(from), clean(to), x, y)); }@@ -207,8 +252,8 @@ pub fn annotate(args: &Value) -> Value { let _ = maybe_save(args, &sheet); } let failed: Vec<&Value> = done.iter().filter(|x| x["result"] != json!("ok")).collect();- json!({"success": failed.is_empty(), "renamed": done, "skippedMultiPart": skipped,- "_hint": "Numbers are the lowest free per prefix across the project, top-left first on each sheet. Multi-part components with '?' are left to Altium's Tools > Annotation (skippedMultiPart). The board gets the new designators with altium_update_pcb."})+ json!({"success": failed.is_empty(), "renamed": done,+ "_hint": "Numbers are the lowest free per prefix across the project, top-left first on each sheet. The parts of a multi-part symbol on one sheet share a number (U3A, U3B), grouped top-left first. The board gets the new designators with altium_update_pcb."}) } /// The designators on the project's sheets (graphical parts left out: they never reach a board) and on its board.@@ -601,6 +646,17 @@ mod tests { assert_eq!(names, vec![("R?".into(), "R2".into()), ("R?".into(), "R4".into()), ("C?".into(), "C1".into()), ("R?".into(), "R5".into())]); } + #[test]+ fn multi_part_question_marks_share_a_number_per_group() {+ // two dual op-amps' worth of parts (A, B, A, B) and a lone part B of a quad, beside a numbered U1+ let m = |p: i64, n: i64, x: i64| ("U?".to_string(), "OPA2".to_string(), p, n, x, 0);+ let sheets = vec![("A.SchDoc".to_string(), vec![m(1, 2, 0), m(2, 2, 100), m(1, 2, 200), m(2, 2, 300),+ ("U?".to_string(), "OPA4".to_string(), 2, 4, 400, 0)])];+ let plan = plan_multipart(&sheets, &["U1".to_string()]);+ let to: Vec<&str> = plan.iter().map(|p| p.2.as_str()).collect();+ assert_eq!(to, vec!["U2", "U2", "U3", "U3", "U4"]);+ }+ #[test] fn the_change_order_preview_names_additions_and_removals() { let (add, remove) = part_diff(&["R1".into(), "R9".into(), "U1".into()], &["U1".into(), "r1".into(), "TP2".into(), "TP10".into()]);
bridge-rs/src/verbs.rs+12−4@@ -22,7 +22,7 @@ pub const KNOWN: &[&str] = &[ "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", "find_space", "selection", "check_orientation", "replicate_layout", "outjob_info", "find_symbol", "part_parameters", // 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",+ "routing_state", "route_net", "move_parts", "place_vias", "board_features", "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 "show_project", "show_schematic", "show_2d_board", "show_3d_board", "show_library", "show_symbol", "show_footprint", "show_3d_chip", "list_library", "demo",@@ -644,8 +644,8 @@ fn describe() -> Value { &["sch_connect", "annotate", "update_pcb", "list_library"], &["Places part 1 of a multi-part component unless partId says otherwise.", "Sheet coordinates are mils from the sheet's lower-left corner; altium_schematic_data shows where the other parts are."], json!({"sheet": "C:/proj/Main.SchDoc", "library": "C:/proj/Lib.SchLib", "symbol": "TPS54202DDCR", "designator": "U2", "x": 3000, "y": 4000})),- verb_doc("sch_connect", "Connect pins of placed parts: a wire stub from each pin's outer end plus a net label (kind label), a power port (kind power: GND-like nets get a ground symbol) or the stub alone (kind wire). One undo step.",- json!({"sheet": "path to the .SchDoc (required)", "connections": "[{designator, pin (number or name), net, kind: label|power|wire (default label), stubMil (default 200)}] (required)", "save": "bool, default true"}),+ verb_doc("sch_connect", "Connect pins of placed parts: a wire stub from each pin's outer end plus a net label (kind label), a power port (kind power: GND-like nets get a ground symbol) or the stub alone (kind wire); or mark a pin left open on purpose with a No ERC marker (kind noerc, no net). One undo step.",+ json!({"sheet": "path to the .SchDoc (required)", "connections": "[{designator, pin (number or name), net, kind: label|power|wire|noerc (default label), stubMil (default 200)}] (required)", "save": "bool, default true"}), json!({"connected": "int", "results": "[{designator, pin, net, kind, result, endMil}]", "failed": "[..]", "saved": "bool"}), 90, "Net labels with the same text connect across the project; power ports connect by net name everywhere. Check the result with altium_compile (ERC) or altium_schematic_data.", &["sch_place_part", "compile", "schematic_data"],@@ -706,6 +706,13 @@ fn describe() -> Value { &["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("board_features", "Add the furniture every real board needs in one undo step: mounting holes (at points or the four corners), fiducials, and a stitching-via grid on a net that keeps clear of every other net's copper, every pad and the board edge.",+ json!({"mountingHoles": "[{x, y, drill?: 3.2, plated?: false, pad?: drill+2.8 when plated, net?}] or {corners: {inset: 4}, drill?, plated?, net?}", "fiducials": "[{x, y, size?: 1, opening?: 2 (mask opening diameter), side?: top|bottom}]", "stitching": "{net (required), pitch?: 5, size?: 0.6, drill?: 0.3, clearance?: 0.3, edge?: 1 (from the outline), region?: [[x,y],..], max?: 400, underParts?: false (vias stay out of each part's pad box)}", "dryRun": "bool - plan only", "filePath": "the .PcbDoc (opened first; expectPath is opened too)", "expectPath": "optional", "expectedRevision": "optional"}),+ json!({"planned": "{mountingHoles, fiducials, stitching:{count, points}}", "applied": "int", "results": "[ok|err:..] per item", "state": "routing_state after"}), 180,+ "dryRun first to see the stitching grid. After stitching, altium_repour so the net's pours join through the vias, then altium_run_drc.",+ &["place_vias", "repour", "run_drc", "routing_state"],+ &["Stitching keeps the pad clearance conservative (a pad's circumscribed circle) and does not look at pours: pours of other nets repour around the vias.", "Unplated holes are bare holes (pad = drill); plated holes take a ring and can join a net (e.g. GND)."],+ json!({"mountingHoles": {"corners": {"inset": 4}, "drill": 3.2}, "fiducials": [{"x": 5, "y": 5}, {"x": 45, "y": 25}], "stitching": {"net": "GND", "pitch": 4}})), 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", "precise": "bool, with check - also run Altium's batch DRC and list its Component Clearance violations (primitive to primitive)"}), json!({"spots": "[{x, y (the part origin, for move_parts), distanceMm, extent}]", "overlaps": "[[a, b]] (check)", "outsideOutline": "[designator] (check)"}), 180,@@ -838,7 +845,7 @@ fn describe() -> Value { &["errorCode bad_number = a pad field that must be a number was not; nothing is created.", "Units are MILS here; altium_build_footprint specs are millimetres.", "A fresh library carries the stray PCBComponent_1: altium_cleanup_libs."], json!({"name": "R0603", "libraryPath": "C:/lib/Test.PcbLib", "pads": "1,-30,0,35,37,rect,0;2,30,0,35,37,rect,0"})), verb_doc("create_symbol", "Create one rectangular symbol from a pin list in a SchLib (the library is created and saved if the path does not exist) and return a proof frame.",- json!({"name": "string (required)", "libraryPath": "path to a .SchLib (required)", "description": "string", "designator": "string, default 'U'", "bodyWidthMil": "int, default 800", "bodyHeightMil": "int, default 600", "pins": "'des,name,left|right|top|bottom,offset_mil[,part];...' - part 1..N makes a multi-part symbol (a dual op-amp: its A pins part 1, B pins part 2), 0 puts a pin on every part (power)", "symbols": "[{name, pins, ...}] - optional: make several in one call (each over the shared args)", "timeout": "seconds, default 90"}),+ json!({"name": "string (required)", "libraryPath": "path to a .SchLib (required)", "description": "string", "designator": "string, default 'U'", "bodyWidthMil": "int, default 800", "bodyHeightMil": "int, default 600", "pins": "'des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...' - part 1..N makes a multi-part symbol (a dual op-amp: its A pins part 1, B pins part 2), 0 puts a pin on every part (power); hiddenNet hides the pin and joins it to that net by name (shared power pins: '8,V+,left,300,0,VCC', or the compiler reports duplicate pins)", "symbols": "[{name, pins, ...}] - optional: make several in one call (each over the shared args)", "timeout": "seconds, default 90"}), json!({"data": "{symbol, library, libraryState: opened|created, pins: int}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 90, "Library is UNSAVED: altium_set_params {footprint, params}, then altium_save {filePath: libraryPath}. For multi-mode symbols use altium_build_symbol_modes.", &["build_symbol_modes", "set_params", "read_symbol", "save"],@@ -1545,6 +1552,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "check_orientation" => crate::design::check_orientation(args), "replicate_layout" => crate::design::replicate_layout(args), "outjob_info" => crate::inspect::outjob_info(args),+ "board_features" => crate::features::board_features(args), "find_symbol" => crate::inspect::find_symbol(args), "part_parameters" => crate::inspect::part_parameters(args), "find_space" => crate::design::find_space(args),
dev-skills/altium-bridge-test/run_verb_tests.py+16−3@@ -130,8 +130,15 @@ def matrix(w): M["set_visibility"] = dict(phase=2, **anyo("param visibility", libraryPath=SL, symbolName="TST_SOIC8", param="BatteryParam", visible=False)) M["set_models"] = dict(phase=2, **ok(libraryPath=SL, symbolName="TST_QFN24", footprint="TST_QFN24")) M["add_models"] = dict(phase=2, **anyo("adds a footprint model row", libraryPath=SL, symbolName="TST_SOIC8", models=[{"type": "PCBLIB", "name": "TST_SOIC8"}]))+ # 0.2.66: pins as objects (they used to arrive as one garbage pin and still pass), a dual op-amp with the shared+ # power pins hidden on their nets; every pin and both parts must arrive M["create_symbol"] = dict(phase=2, **ok(libraryPath=SL, name="BAT_SYM", designator="U?", description="battery",- pins=[{"number": "1", "name": "A", "side": "left"}, {"number": "2", "name": "B", "side": "right"}]))+ pins=[{"number": "1", "name": "OUTA", "side": "right", "part": 1}, {"number": "2", "name": "-INA", "side": "left", "part": 1},+ {"number": "3", "name": "+INA", "side": "left", "part": 1}, {"number": "7", "name": "OUTB", "side": "right", "part": 2},+ {"number": "6", "name": "-INB", "side": "left", "part": 2}, {"number": "5", "name": "+INB", "side": "left", "part": 2},+ {"number": "8", "name": "V+", "side": "left", "offsetMil": 300, "part": 0, "hiddenNet": "VCC"},+ {"number": "4", "name": "V-", "side": "left", "offsetMil": 400, "part": 0, "hiddenNet": "GND"}]),+ check=lambda d: (d.get("data") or {}).get("pins") == 8 and (d.get("data") or {}).get("parts") == 2) M["clone_symbol"] = dict(phase=2, **ok(libraryPath=SL, **{"from": "BAT_SYM", "to": "BAT_SYM_CLONE"})) M["rename_symbol"] = dict(phase=2, **ok(libraryPath=SL, **{"from": "BAT_SYM_CLONE", "to": "BAT_SYM_RENAMED"})) M["remove_symbol"] = dict(phase=2, **ok(libraryPath=SL, symbolName="BAT_SYM_RENAMED"))@@ -246,6 +253,11 @@ def matrix(w): M["export_manufacturing"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, pcbPath=LB)) M["outjob_info"] = dict(phase=3.5, **ok(outjobPath=D + "/lane.Manufacturing.OutJob"), check=lambda d: len((d.get("groups") or [{}])[0].get("containers") or []) >= 1) M["part_parameters"] = dict(phase=3.5, timeout=300, **ok(projectPath=LP), check=lambda d: len(d.get("names") or []) >= 1)+ # 0.2.66: board furniture after the placement checks (the vias would crowd find_space): 2 mm corner holes (under the+ # fab hole rule), one fiducial, at most 4 GND stitching vias; every item must land+ M["board_features"] = dict(phase=3.5, timeout=200, **ok(expectPath=LB, mountingHoles={"corners": {"inset": 2}, "drill": 2.0},+ fiducials=[{"x": 20, "y": 17}], stitching={"net": "GND", "pitch": 5, "max": 4}),+ check=lambda d: (d.get("applied") or 0) == 9 and d.get("failed") == 0) # ---- phase 3.5 (0.2.56-0.2.58): BOM and schematic editing on the lane project ---------------- # build_schematic made LP with LS (U1, U2) and LB. A third part goes on the sheet as U?, gets a@@ -257,8 +269,9 @@ def matrix(w): M["sch_place_part"] = dict(phase=3.5, **ok(sheet=LS, library=SL, symbol="TST_SOIC8", designator="U?", x=8000, y=2000)) M["annotate"] = dict(phase=3.5, timeout=300, **ok(projectPath=LP), check=lambda d: any(r.get("to") == "U3" for r in d.get("renamed") or [])) M["sch_connect"] = dict(phase=3.5, **ok(sheet=LS, connections=[{"designator": "U3", "pin": "1", "net": "N1"},- {"designator": "U3", "pin": "4", "net": "GND", "kind": "power"}]),- check=lambda d: d.get("connected") == 2)+ {"designator": "U3", "pin": "4", "net": "GND", "kind": "power"},+ {"designator": "U3", "pin": "6", "kind": "noerc"}]),+ check=lambda d: d.get("connected") == 3) 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
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.65",+ "version": "0.2.66", "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.65",+ "version": "0.2.66", "hero": { "type": "image", "path": "screenshots/hero.png"
scripts/release.py+2@@ -172,6 +172,8 @@ ENGINE_HALTING = [ r"\.MinimumRing\b", # IPCB_MinimumAnnularRing has no MinimumRing on AD 26.11.1 (measured 2026-10-15) r"\.Priority\s*:=", # writing a rule's Priority halts with an empty Error box (AD 26.11.1, 2026-10-15); # a newly added rule already lands at priority 1+ r"(?<!PC)\.SolderMaskExpansion\s*:=", r"(?<!PC)\.PasteMaskExpansion\s*:=", # on a pad: an access violation in+ # ScriptingSystem.DLL (AD Develop, 2026-10-23); set them on the pad cache (PC.) instead ]
skills/altium-boards/SKILL.md+22−3@@ -2,8 +2,8 @@ name: altium-boards description: >- 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.+ diff pairs, track widths, mounting holes, fiducials, stitching vias, free space, footprint swaps,+ readback, 3D. Use when asked for an altium stack-up, net class, mounting holes, where a part fits. --- Parent skill: **altium-bridge**@@ -145,7 +145,8 @@ All of these need a licensed Altium and read the current document unless a path {layerId, displayed}`; `schematic_data {designator?, pins?, params?}` on the current sheet. They read the CURRENT document: opening a .SchDoc makes board verbs answer "no current PCB board" until a .PcbDoc is opened again — order your calls. Payload is under `data`. Multi-part symbols list-once per part (`partId`).+once per part (`partId`, `partCount`), each with its own pins plus the shared part-0 pins (`part`,+`hidden`, `hiddenNet` on each pin). `schematic_data` returns an EMPTY `params[]` unless you ask: pass `params:true` (from 0.2.38 — the agent always honoured the flag, but the bridge never sent it, so the field came back empty no matter@@ -236,3 +237,21 @@ altium_net_connections '{"net":"FB"}' # airlineMm: each - A move without `rotation` now keeps the part's rotation (it used to turn it to 0). - `footprint_primitives` reports each pad's solder and paste mask expansion (um). +## Mounting holes, fiducials, stitching vias (0.2.66)++```bash+altium_board_features '{"mountingHoles":{"corners":{"inset":4},"drill":3.2},"fiducials":[{"x":5,"y":5},{"x":45,"y":25}],"stitching":{"net":"GND","pitch":4},"dryRun":true}'+altium_board_features '{"mountingHoles":[{"x":10,"y":10,"drill":3.2,"plated":true,"net":"GND"}]}' # plated, on a net+```++- One undo step, read back. `dryRun:true` returns the plan (every stitching point) without placing anything.+- Mounting holes are unplated bare holes by default (pad = drill); `plated:true` adds a ring (`pad`, default drill+ + 2.8 mm) and can join a net. Fab hole-size rules usually stop below 3.2 mm: the reply's `holeRule` says how to+ widen it (`altium_set_design_rules {hole:{min, max}}`).+- Fiducials are 1 mm copper dots with a 2 mm mask opening and no paste (`size`, `opening`, `side`). Measured: mask+ +0.5 mm, paste -1 mm. The mask is set through the pad cache; writing `Pad.SolderMaskExpansion` crashed Altium+ (an access violation in ScriptingSystem.DLL) and is now refused by the release lint.+- Stitching puts a grid on one net (`pitch`, default 5 mm) inside the outline or `region`, `edge` mm from the edge,+ `clearance` mm from every other net's copper and every pad, out from under parts (their pad box; `underParts:true`+ allows it), and clear of this call's holes and fiducials. Pour the net first: on a board with no pour the vias+ join nothing and DRC reports them as net antennae. Then `altium_repour` and `altium_run_drc`.
skills/altium-libraries/SKILL.md+7−2@@ -161,8 +161,13 @@ altium_create_footprint '{"libraryPath":"C:\\lib\\Parts.PcbLib","footprints":[{" ``` - A pin's 5th field is its part: 1..N makes a multi-part symbol, and 0 puts the pin on every part (power).- Measured: part 2 of a dual op-amp, placed with `altium_sch_place_part {partId:2}`, carried OUTB, -INB and +INB- plus the shared V+ and V-.+ A 6th field hides the pin on that net (`8,V+,left,300,0,VCC`). Do that for shared power pins: visible+ part-0 pins show on every part and the compiler reports "Duplicate pins in component" and duplicate net+ names (measured 2026-10-23, the same with Altium's own placement); hidden on VCC/GND it compiles clean.+- Pins can also be objects: `{number, name, side, offsetMil?, part?, hiddenNet?}`; without an offset they+ stack down their side 100 mil apart.+- Measured (0.2.66): part 2 of a dual op-amp, placed with `altium_sch_place_part {partId:2}`, reads back as+ `partId 2, partCount 2` with OUTB, -INB, +INB and the shared V+/V-. - `find_symbol` puts an exact name first, then the shortest names containing the query. .IntLib files are not searched; the Altium 365 workspace is searched with `altium_workspace_search`. - `symbols` / `footprints` lists make each item in turn and report each one.
skills/altium-schematic-edit/SKILL.md+5−2@@ -33,9 +33,12 @@ altium_update_pcb '{"projectPath":"C:\\proj\\board.PrjPcb","execute":true,"t (`kind:"label"`), a power port (`kind:"power"`; GND, AGND, VSS... get a ground symbol, other nets a bar) or nothing (`kind:"wire"`). Pins are matched by number first, then by name. Each connection answers `ok` or why not (`pin not found on R9`).+- `kind:"noerc"` marks a pin left open on purpose with a No ERC marker on its outer end (no wire, no net):+ `{"designator":"U3","pin":"6","kind":"noerc"}`. A marker off any pin end is reported by compile as+ "Floating No ERC". - `annotate` gives every `R?`, `C?`, `U?` the lowest free number for its prefix across all sheets,- top-left first. `dryRun:true` shows the plan. Multi-part components with `?` are left to Altium's own- Tools > Annotation (`skippedMultiPart`).+ top-left first. `dryRun:true` shows the plan. The parts of a multi-part symbol on one sheet share a+ number (U4A + U4B, then U5A + U5B), grouped top-left first, never two of the same part in one group. ## Parts from the Altium 365 workspace (the shared cloud library)
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.65+0.2.66
src/agent/AdomAgent.pas.tmpl+91−18@@ -1002,6 +1002,14 @@ Begin Pin.Orientation := eRotate180; Pin.Location := Point(MilsToCoord(0), MilsToCoord(H - Off)); End;+ { a 6th field hides the pin on that net: shared power pins of a multi-part symbol (part 0) are hidden and+ joined to their net by name, or every part shows them and the compiler reports duplicate pins (measured+ 2026-10-23: Altium's own placement of visible part-0 pins gave the same warnings) }+ If Trim(Field(Spec, 6)) <> '' Then+ Begin+ Pin.IsHidden := True;+ Pin.HiddenNetName := Trim(Field(Spec, 6));+ End; Comp.AddSchObject(Pin); End; I := Pins.Count;@@ -4873,11 +4881,17 @@ Begin Pin := PIt.FirstSchObject; While Pin <> Nil Do Begin- If PS <> '' Then PS := PS + ',';- PS := PS + '{' + JStr('number', Pin.Designator) + ',' + JStr('name', Pin.Name) + ',' +- JRaw('electrical', IntToStr(Pin.Electrical)) + ',' +- JRaw('xMil', IntToStr(Round(CoordToMils(Pin.Location.X)))) + ',' + JRaw('yMil', IntToStr(Round(CoordToMils(Pin.Location.Y)))) + ',' +- JRaw('orientation', IntToStr(Pin.Orientation)) + ',' + JRaw('lengthMil', IntToStr(Round(CoordToMils(Pin.PinLength)))) + '}';+ { a placed part carries every part's and display mode's pins: list the ones it shows (its part,+ the shared part 0, its display mode) }+ If ((Pin.OwnerPartId = Comp.CurrentPartID) Or (Pin.OwnerPartId = 0)) And (Pin.OwnerPartDisplayMode = Comp.DisplayMode) Then+ Begin+ If PS <> '' Then PS := PS + ',';+ PS := PS + '{' + JStr('number', Pin.Designator) + ',' + JStr('name', Pin.Name) + ',' ++ JRaw('electrical', IntToStr(Pin.Electrical)) + ',' ++ JRaw('xMil', IntToStr(Round(CoordToMils(Pin.Location.X)))) + ',' + JRaw('yMil', IntToStr(Round(CoordToMils(Pin.Location.Y)))) + ',' ++ JRaw('orientation', IntToStr(Pin.Orientation)) + ',' + JRaw('lengthMil', IntToStr(Round(CoordToMils(Pin.PinLength)))) + ',' ++ JRaw('part', IntToStr(Pin.OwnerPartId)) + ',' + JRaw('hidden', JBool(Pin.IsHidden)) + ',' + JStr('hiddenNet', Pin.HiddenNetName) + '}';+ End; Pin := PIt.NextSchObject; End; Comp.SchIterator_Destroy(PIt);@@ -4916,6 +4930,7 @@ Begin If S <> '' Then S := S + ','; S := S + '{' + JRaw('params', '[' + MS + ']') + ',' + JStr('designator', Comp.Designator.Text) + ',' + JRaw('partId', IntToStr(Comp.CurrentPartID)) + ',' ++ JRaw('partCount', IntToStr(Comp.PartCount)) + ',' + JStr('libReference', Comp.LibReference) + ',' + JStr('comment', Comp.Comment.Text) + ',' + JStr('description', Desc) + ',' +@@ -6023,6 +6038,8 @@ Var Op, Kind, Res, Results : String; Trk : IPCB_Track; Via : IPCB_Via;+ Pad : IPCB_Pad;+ PC : TPadCache; Comp : IPCB_Component; Prim : IPCB_Primitive; Net : IPCB_Net;@@ -6070,6 +6087,43 @@ Begin If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Via); End; End+ Else If Kind = 'P' Then+ Begin+ { a free pad: P|x|y|size|hole|plated|layer|net|name|maskExpansion. Layer 74 = multi-layer (a mounting+ hole); a hole of 0 on top or bottom is a fiducial. }+ Net := FindNet(Board, FieldP(Op, 8));+ If (Net = Nil) And (FieldP(Op, 8) <> '') Then Res := 'err:no net ' + FieldP(Op, 8)+ Else+ Begin+ Pad := PCBServer.PCBObjectFactory(ePadObject, eNoDimension, eCreate_Default);+ Pad.X := AdomInt(FieldP(Op, 2)); Pad.Y := AdomInt(FieldP(Op, 3));+ Pad.TopXSize := AdomInt(FieldP(Op, 4)); Pad.TopYSize := AdomInt(FieldP(Op, 4));+ Pad.TopShape := eRounded;+ Pad.HoleSize := AdomInt(FieldP(Op, 5));+ If AdomInt(FieldP(Op, 7)) = 74 Then+ Begin+ { a simple-mode pad: its top size is every layer's (MidXSize halts the engine) }+ Pad.Layer := eMultiLayer;+ Pad.Plated := FieldP(Op, 6) = '1';+ End+ Else If AdomInt(FieldP(Op, 7)) = 32 Then Pad.Layer := eBottomLayer+ Else Pad.Layer := eTopLayer;+ Pad.Name := FieldP(Op, 9);+ If FieldP(Op, 10) <> '' Then+ Begin+ { a manual mask opening (a fiducial's bare ring) and no paste. Only through the pad cache: assigning+ Pad.SolderMaskExpansion raised an access violation in ScriptingSystem.DLL (2026-10-23). }+ PC := Pad.GetState_Cache;+ PC.SolderMaskExpansion := AdomInt(FieldP(Op, 10));+ PC.SolderMaskExpansionValid := eCacheManual;+ PC.PasteMaskExpansion := -Pad.TopXSize;+ PC.PasteMaskExpansionValid := eCacheManual;+ Pad.SetState_Cache := PC;+ End;+ If Net <> Nil Then Pad.Net := Net;+ If gInputError <> '' Then Res := 'err:bad number ' + gInputError Else RegisterPrim(Board, Pad);+ End;+ End Else If Kind = 'M' Then Begin Comp := FindCompByDes(Board, FieldP(Op, 2));@@ -7088,6 +7142,7 @@ Var W : ISch_Wire; NL : ISch_NetLabel; PO : ISch_PowerObject;+ NE : ISch_NoERC; Op, Des, PinKey, Net, Kind, Res, S : String; I, J, Dir, HX, HY, EX, EY, Stub, NOk : Integer; Begin@@ -7127,7 +7182,7 @@ Begin If Hit = Nil Then Res := 'pin not found on ' + Des; End; CL.Free;- If (Res = '') And (Net = '') And (Kind <> 'wire') Then Res := 'no net given';+ If (Res = '') And (Net = '') And (Kind <> 'wire') And (Kind <> 'noerc') Then Res := 'no net given'; If Res = '' Then Begin Dir := Hit.Orientation;@@ -7137,19 +7192,31 @@ Begin Else If Dir = 2 Then HX := HX - Hit.PinLength Else HY := HY - Hit.PinLength; EX := HX; EY := HY;+ If Kind = 'noerc' Then Stub := 0; If Dir = 0 Then EX := HX + MilsToCoord(Stub) Else If Dir = 1 Then EY := HY + MilsToCoord(Stub) Else If Dir = 2 Then EX := HX - MilsToCoord(Stub) Else EY := HY - MilsToCoord(Stub);- W := SchServer.SchObjectFactory(eWire, eCreate_Default);- W.Location := Point(HX, HY);- W.InsertVertex := 1;- W.SetState_Vertex(1, Point(HX, HY));- W.InsertVertex := 2;- W.SetState_Vertex(2, Point(EX, EY));- W.LineWidth := eSmall;- Doc.RegisterSchObjectInContainer(W);- SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ If Kind = 'noerc' Then+ Begin+ { a No ERC marker on the pin's outer end: an intentionally unconnected pin, no wire, no net }+ NE := SchServer.SchObjectFactory(eNoERC, eCreate_Default);+ NE.Location := Point(HX, HY);+ Doc.RegisterSchObjectInContainer(NE);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, NE.I_ObjectAddress);+ End+ Else+ Begin+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(HX, HY);+ W.InsertVertex := 1;+ W.SetState_Vertex(1, Point(HX, HY));+ W.InsertVertex := 2;+ W.SetState_Vertex(2, Point(EX, EY));+ W.LineWidth := eSmall;+ Doc.RegisterSchObjectInContainer(W);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ End; If Kind = 'power' Then Begin PO := SchServer.SchObjectFactory(ePowerObject, eCreate_Default);@@ -7293,6 +7360,10 @@ Begin DY := (StrToIntDef(Param(Lines, 'y'), 1000) Div 100) * 100; SchServer.ProcessControl.PreProcess(Sheet, ''); Inst := SchServer.SchObjectFactory(eSchComponent, eCreate_Default);+ { a multi-part symbol needs its part count on the instance first, or the part is clamped to 1 and the sheet reads+ part 2 back as part 1 (2026-10-22, dual op-amp) }+ If LibCmp.PartCount > 1 Then Inst.PartCount := LibCmp.PartCount;+ If (Part < 1) Or (Part > Inst.PartCount) Then Part := 1; Inst.CurrentPartID := Part; Inst.DisplayMode := 0; Inst.LibReference := LibCmp.LibReference;@@ -7306,14 +7377,16 @@ Begin Begin If ((Prim.ObjectID = ePin) Or (Prim.ObjectID = eRectangle) Or (Prim.ObjectID = eLine) Or (Prim.ObjectID = eArc) Or (Prim.ObjectID = eEllipse) Or (Prim.ObjectID = ePolygon) Or (Prim.ObjectID = ePolyline) Or (Prim.ObjectID = eLabel)) And- (Prim.OwnerPartDisplayMode = 0) And ((Prim.OwnerPartId = Part) Or (Prim.OwnerPartId = 0)) Then+ (Prim.OwnerPartDisplayMode = 0) Then Begin+ { every part's objects keep their own part (0 = shared by all parts), as Altium's own placement does: the+ instance shows CurrentPartID. Copying only the chosen part and moving its shared power pins onto it gave+ each part its own copy of V+/V-, which the compiler reported as duplicate pins (2026-10-23). } Cpy := Prim.Replicate;- Cpy.OwnerPartId := Part; Cpy.OwnerPartDisplayMode := 0; Cpy.MoveByXY(MilsToCoord(DX), MilsToCoord(DY)); Inst.AddSchObject(Cpy);- If Prim.ObjectID = ePin Then NPins := NPins + 1;+ If (Prim.ObjectID = ePin) And ((Prim.OwnerPartId = Part) Or (Prim.OwnerPartId = 0)) Then NPins := NPins + 1; End; Prim := PIt.NextSchObject; End;
src/bridge.json+3−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.65",+ "version": "0.2.66", "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",@@ -86,6 +86,7 @@ "altium_export_step", "altium_extension_ping", "altium_extract_intlib",+ "altium_board_features", "altium_find_space", "altium_find_symbol", "altium_footprint_primitives",@@ -313,6 +314,7 @@ "workspace_search": 180, "workspace_place": 300, "find_space": 180,+ "board_features": 180, "sch_swap_part": 180, "selection": 60, "check_orientation": 120,