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.67 source (not yet released, needs signing): hierarchical schematics (altium_sch_sheet_symbol, sch_connect kind port), altium_netlist, altium_footprint_from_land_pattern, altium_silkscreen_prep (Altium's Silkscreen Preparation, run by export_manufacturing by default); 178 verbs; batteries 177/0 on AD 26.11.1 and Altium Designer Develop
20 files changed
+948−33
CHANGELOG.md+10README.md+15−9SKILL.md+4bridge-rs/src/footprint.rs+291bridge-rs/src/hier.rs+201bridge-rs/src/main.rs+3−1bridge-rs/src/manufacturing.rs+7bridge-rs/src/route.rs+68bridge-rs/src/routing.rs+3−3bridge-rs/src/sch.rs+7−7bridge-rs/src/verbs.rs+41−4dev-skills/altium-bridge-test/run_verb_tests.py+16package.json+20−2page.json+20−2skills/altium-libraries/SKILL.md+17skills/altium-outputs/SKILL.md+16skills/altium-schematic-edit/SKILL.md+24−3src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+175src/bridge.json+9−1CHANGELOG.md+10@@ -1,5 +1,15 @@ # Changelog +## 0.2.67 (2026-10-23)+**Hierarchical schematics and the netlist.**+- New verb `altium_sch_sheet_symbol`: a sheet symbol on a parent sheet for a child sheet, one entry per signal joined to a parent net (a stub and a net label), and a port per entry on the child. A child that does not exist yet is made from Altium's ANSI B template and added to the project (its open members saved and closed first, so Altium never asks to reload). Altium never positions an entry added through the API (it stays at 0,0 and draws nowhere, measured on Develop); the bridge puts each one on the symbol's edge.+- New verb `altium_netlist`: compile the project and read every net with its pins across all sheets, the hierarchy resolved, or one net, or compare with `expect` (missing, extra and unexpected nets). Until now only build_schematic read the netlist.+- `altium_sch_connect {kind:"port"}`: a port named after the net at a pin's stub end, with `io`.+- Measured on Develop: a fresh two-sheet project (Top with U1, U2; Power with U10 joined through VIN/VOUT/GND entries and ports) compiled with VBUS = U1.1 + U10.1, 3V3 = U2.1 + U10.8, GND = U1.4 + U2.4 + U10.4, no mismatches, no compile errors; a second child (Sensor) got its four ports from the default `ports:true`.+- New verb `altium_footprint_from_land_pattern`: a complete footprint from the land pattern a datasheet prints. Families chip, dual (SOIC, TSSOP, DFN), sot, quad (QFP, QFN) and bga, plus an exposed pad. Pads are numbered the IPC way (pin 1 top-left, counter-clockwise; BGA rows skip I, O, Q, S, X, Z). The bridge adds the courtyard (+0.25 mm), silkscreen on the body outline with every stretch within 0.2 mm of copper removed and a pin-1 dot, a fab outline with a pin-1 chamfer, and an extruded 3D body when the height is given. `dryRun` returns the pad table without Altium.+- New verb `altium_silkscreen_prep`: Altium's own Tools > Silkscreen Preparation (`PCB:ClipSilkScreen`), its WPF dialog answered in the background (design-rule clearance, Move Text, delete silk outside the board), with the silkscreen DRC hits counted before and after. Measured on Develop: 5 silk-to-solder-mask hits to 0 in 24 s, the texts moved clear and readable. `altium_export_manufacturing` runs it first by default (`silkPrep:false` skips it). Prompted by a viewer of the buck video who spotted silk over pads on the first-pass board: Altium's DRC had flagged it, the pipeline graded it a warning (fabs trim it), and the board shipped looking wrong.+- Verb battery: `sch_sheet_symbol` adds a child to the lane project (entry joined to N1, its port placed), then `netlist` checks N1 still carries U1.8 and U3.1 with no compile errors. 176 verbs.+ ## 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.
README.md+15−9@@ -3,8 +3,10 @@ Let an AI assistant (Claude, ChatGPT, Codex or any agent) run **Altium Designer** for you, inside your own normal session. Ask in plain words and it: -- creates schematic symbols (multi-part too) and PCB footprints with STEP 3D bodies and Mouser / DigiKey / LCSC numbers-- places parts on a sheet, including parts from your Altium 365 workspace, then wires, annotates and swaps them+- creates schematic symbols (multi-part too) and PCB footprints (straight from a datasheet's land pattern) with 3D+ bodies and Mouser / DigiKey / LCSC numbers+- places parts on a sheet, including parts from your Altium 365 workspace, then wires, annotates and swaps them,+ builds hierarchical designs (sheet symbols, entries, ports) and checks the compiled netlist - runs Update PCB with the whole change order previewed first, keeping board-only parts - sets up the board: stack-up (2 to 4 layers, planes), net classes, diff pairs, design rules, outline - places and routes: moves and rotates parts, finds free space, routes nets, pours copper, adds mounting holes,@@ -13,7 +15,7 @@ normal session. Ask in plain words and it: - makes the outputs: Gerbers, NC drill, pick and place, BOMs grouped by MPN (JLCPCB, Mouser, DigiKey files), schematic PDF and STEP, checking that the outline and every copper layer reached the Gerbers -174 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free+178 commands, tested on Altium Designer 26.11 and Altium Designer Develop. It runs in **Hydrogen**, Adom's free desktop app, through Adom Bridge. You bring your own AI subscription and your own Altium licence. **How it works:** a reverse bridge lets Adom Bridge (ab), and the AI driving it, control the user's own Altium@@ -25,15 +27,15 @@ and the verb prefix `altium_`. ## What it can do -Tested on Altium Designer 26.11.1 and Altium Designer Develop. 174 verbs; each row names the ones+Tested on Altium Designer 26.11.1 and Altium Designer Develop. 178 verbs; each row names the ones behind it (prefix `altium_`). | Area | What the AI can do | Verbs | |---|---|---|-| Libraries | Create symbols (with display modes) and footprints, attach STEP 3D bodies, set MPN, manufacturer and Mouser / DigiKey / LCSC parameters, copy, rename and clone across libraries, compile and extract integrated libraries, register libraries in Installed Libraries, find a symbol or footprint by name across every library | `find_symbol`, `create_symbol`, `build_symbol_modes`, `create_footprint`, `build_footprint`, `add_body`, `set_params`, `add_models`, `set_models`, `xcopy_footprint`, `compile_intlib`, `extract_intlib`, `install_library` |+| Libraries | Create symbols (with display modes) and footprints, attach STEP 3D bodies, set MPN, manufacturer and Mouser / DigiKey / LCSC parameters, copy, rename and clone across libraries, compile and extract integrated libraries, register libraries in Installed Libraries, find a symbol or footprint by name across every library; generate a complete footprint (pads, courtyard, silkscreen, pin 1, 3D body) from a datasheet land pattern | `find_symbol`, `create_symbol`, `build_symbol_modes`, `create_footprint`, `build_footprint`, `add_body`, `set_params`, `add_models`, `set_models`, `xcopy_footprint`, `compile_intlib`, `extract_intlib`, `install_library`, `footprint_from_land_pattern` | | Parts in one step | Import a whole part (symbol, footprint, STEP, parameters) in one call; put an Adom wiki part on a sheet with the `altium-wiki-part` command (symbol, footprint, a 3D chip laser-etched with its part number, Mouser / DigiKey / LCSC numbers); import KiCad files | `import_part`, `install_library_bundle`, `add_symbol_art`, `import_kicad` | | Altium 365 | Search the workspace's components and place one on a sheet with its workspace link, parameters and footprint; GraphQL queries and library uploads with a workspace token | `workspace_search`, `workspace_place`, `a365_*` |-| Schematics | Draw a schematic from a netlist, place library parts, wire pins (wire, net label or power port), move, rotate, rename and delete parts, swap a part for another without breaking its connections, annotate, fill the title block, cross-probe | `build_schematic`, `sch_place_part`, `sch_connect`, `sch_edit`, `sch_swap_part`, `annotate`, `doc_params`, `sch_zoom` |+| Schematics | Draw a schematic from a netlist, place library parts, wire pins (wire, net label or power port), move, rotate, rename and delete parts, swap a part for another without breaking its connections, annotate, fill the title block, cross-probe, hierarchical sheets (sheet symbols with entries, ports on the child), the compiled netlist checked net by net | `build_schematic`, `sch_place_part`, `sch_connect`, `sch_edit`, `sch_swap_part`, `annotate`, `doc_params`, `sch_zoom`, `sch_sheet_symbol`, `netlist` | | 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` |@@ -41,7 +43,7 @@ behind it (prefix `altium_`). | 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` |-| Manufacturing outputs | Gerbers, NC drill, pick and place and BOM in one call, checking that the outline and every copper layer reached the Gerbers; any OutJob, and read an OutJob's containers and settings; the schematic as a PDF; STEP | `export_manufacturing`, `run_outjob`, `outjob_info`, `export_pdf`, `export_step` |+| Manufacturing outputs | Gerbers, NC drill, pick and place and BOM in one call, checking that the outline and every copper layer reached the Gerbers; any OutJob, and read an OutJob's containers and settings; the schematic as a PDF; STEP; silkscreen cleaned off the pads first with Altium's Silkscreen Preparation | `export_manufacturing`, `run_outjob`, `outjob_info`, `export_pdf`, `export_step`, `silkscreen_prep` | | Reading the design | The whole board as data in mm, schematic parts with pins, rules, layers, poured copper, silkscreen, every part parameter in the project | `read_board`, `routing_state`, `schematic_data`, `part_parameters`, `read_rules`, `pour_state`, `silk_state`, `pcb_report` | | Seeing it | Open any surface with a screenshot, 2D and 3D board views, zoom to parts or nets, camera moves | `show_*`, `zoom_to`, `select_net`, `animate_view` | | Running Altium | Launch in the background, look after the licence seat, close nuisance dialogs, work with several Altiums at once, run any DelphiScript or Altium command, install Altium | `launch`, `close`, `ensure_licensed`, `instances`, `check_dialogs`, `eval`, `run_command`, `install` |@@ -62,7 +64,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 174 `altium_*` verbs.+DelphiScript agent** inside it (the REPL lane), and hands the AI 178 `altium_*` verbs. - **Page / docs:** https://wiki.adom.inc/adom/altium-bridge - **Bridge SDK:** https://wiki.adom.inc/adom/adom-bridge-sdk@@ -169,7 +171,7 @@ A halted engine (DelphiScript runtime errors are modal) recovers with `altium_ag <!-- verbs:start (tools/readme_verbs.py writes this list) --> -All 174 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example.+All 178 verbs, by area (prefix `altium_`). `altium_describe` returns the live catalog with every input, output, hint and example. ### Running Altium: launch, close, health, dialogs @@ -246,6 +248,9 @@ All 174 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `altium_move_parts`: Move and rotate placed components; each part is read back to 0.001 mm and the exact rotation. - `altium_place_vias`: Place vias (through, top to bottom). - `altium_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.+- `altium_sch_sheet_symbol`: Make a design hierarchical: a sheet symbol on the parent sheet for a child sheet, its entries joined to the parent's nets, and matching ports on the child.+- `altium_netlist`: The compiled netlist of a project: every net with its pins (Designator.Pin) across every sheet, the hierarchy resolved; or one net; or compare with the nets you expect.+- `altium_silkscreen_prep`: Clean the silkscreen off the copper with Altium's own Tools > Silkscreen Preparation: silk clipped clear of every mask opening (pad) by the board's silk rule, designators moved off pads where they fit, silk outside the board deleted; the silkscreen DRC hits counted before and after. - `altium_remove_items`: Delete specific free tracks and vias, given exactly as altium_routing_state returned them. - `altium_ripup_nets`: Delete every free track, arc and via on each named net. - `altium_apply_pours`: Add solid polygon pours from outlines and pour them, each with its own connect style (solid joints or thermal spokes), plus pour keepouts (polygon cutouts); answers each pour's real poured copper area.@@ -316,6 +321,7 @@ All 174 verbs, by area (prefix `altium_`). `altium_describe` returns the live ca - `altium_create_footprint`: Create one footprint from a pad list in a PcbLib (the library is created and saved if the path does not exist) and return a proof frame. - `altium_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. - `altium_build_footprint`: Build a complete footprint (pads, silkscreen, courtyard, arcs) in a PcbLib from a kicad2altium spec file on this machine, and return a proof frame.+- `altium_footprint_from_land_pattern`: Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard, silkscreen kept clear of the copper with a pin-1 dot, a fab outline with a pin-1 chamfer, and an extruded 3D body when the height is given. - `altium_build_symbol_modes`: Build ONE SchLib component with N display modes (Normal + Alternate 1..N-1) from a symbol_modes spec file on this machine, and return a proof frame. - `altium_add_body`: Put a 3D body on a footprint of a PcbLib: a STEP model, or an extruded box when no step is given. - `altium_add_models`: Attach one or more PCBLIB footprint models, parameters, comment and description to a SchLib component (Altium's several-footprints-per-symbol mechanism).
SKILL.md+4@@ -24,6 +24,8 @@ Say **altium** to reach this bridge. Every verb returns `success`, a stable `err | 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**) |+| Hierarchical sheets: a sheet symbol, its entries, the child's ports | `altium_sch_sheet_symbol`, `altium_sch_connect {kind:"port"}` (**altium-schematic-edit**) |+| The compiled netlist, or check it against the nets you meant | `altium_netlist {projectPath, net?, expect?}` (**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**) |@@ -33,9 +35,11 @@ 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**) |+| Silkscreen off the pads before fab or a render (Tools > Silkscreen Preparation) | `altium_silkscreen_prep {filePath}` (runs inside `altium_export_manufacturing` by default) (**altium-outputs**) | | 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**) |+| A footprint from a datasheet's land pattern (SOIC, QFN, SOT, BGA, chips) | `altium_footprint_from_land_pattern {family, pins, pitch, span, padLength, padWidth, exposedPad, body}` (**altium-libraries**) | | Find a symbol or footprint by name; multi-part symbols; batches | `altium_find_symbol`, `altium_create_symbol {pins with part}`, `{symbols:[..]}` (**altium-libraries**) | | A project's parameters | `altium_part_parameters {projectPath, name?}` | | 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_outjob_info` (read an OutJob's containers without running it) (**altium-outputs**) |
bridge-rs/src/footprint.rsadded+291@@ -0,0 +1,291 @@+//! Footprints from a datasheet's land pattern: the AI gives the numbers the datasheet prints (pitch, pad size, row+//! span, body), the bridge lays out a complete footprint: pads numbered the standard way, an exposed pad, the+//! courtyard, silkscreen kept clear of the copper with a pin-1 dot, and a fab outline with a pin-1 chamfer. The result+//! is the agent's build_footprint spec (mm, Y up, origin at the centre).+use serde_json::{json, Value};++#[derive(Debug, Clone, PartialEq)]+pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }++#[derive(Debug, Default)]+pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }++fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }+fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }++/// BGA row letters: A..Z without I, O, Q, S, X, Z, then AA, AB, ...+pub fn bga_row(i: usize) -> String {+ const L: &[u8] = b"ABCDEFGHJKLMNPRTUVWY";+ if i < L.len() { (L[i] as char).to_string() } else { format!("{}{}", L[i / L.len() - 1] as char, L[i % L.len()] as char) }+}++/// The pads of a land pattern. Errors name the missing number in the words the caller used.+pub fn pads(a: &Value) -> Result<Vec<Pad>, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let need = |k: &str| num(a, k).ok_or_else(|| format!("{family} needs {k} (mm)"));+ let shape = a.get("padShape").and_then(|s| s.as_str()).unwrap_or(if family == "bga" { "round" } else { "roundrect" }).to_string();+ let mut out = Vec::new();+ let mut push = |name: String, x: f64, y: f64, w: f64, h: f64| out.push(Pad { name, x: r3(x), y: r3(y), w: r3(w), h: r3(h), shape: shape.clone(), hole: 0.0 });+ match family.as_str() {+ "chip" | "two" | "2pad" => {+ let (span, pl, pw) = (need("span")?, need("padLength")?, need("padWidth")?);+ push("1".into(), -span / 2.0, 0.0, pl, pw);+ push("2".into(), span / 2.0, 0.0, pl, pw);+ }+ "dual" | "sot" => {+ let (span, pitch, pl, pw) = (need("span")?, need("pitch")?, need("padLength")?, need("padWidth")?);+ let (left, right) = if family == "sot" {+ (num(a, "leftPins").ok_or("sot needs leftPins and rightPins")? as usize, num(a, "rightPins").ok_or("sot needs leftPins and rightPins")? as usize)+ } else {+ let n = need("pins")? as usize;+ if n % 2 != 0 { return Err(format!("dual needs an even pin count, got {n}")); }+ (n / 2, n / 2)+ };+ // left column top to bottom, then right column bottom to top (counter-clockwise from pin 1 top-left)+ for i in 0..left { push((i + 1).to_string(), -span / 2.0, ((left as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); }+ for j in 0..right { push((left + j + 1).to_string(), span / 2.0, (j as f64 - (right as f64 - 1.0) / 2.0) * pitch, pl, pw); }+ }+ "quad" => {+ let (pitch, pl, pw) = (need("pitch")?, need("padLength")?, need("padWidth")?);+ let span_x = need("span")?;+ let span_y = num(a, "spanY").unwrap_or(span_x);+ let (ny, nx) = match (num(a, "pinsPerSideY"), num(a, "pinsPerSideX")) {+ (Some(y), Some(x)) => (y as usize, x as usize),+ _ => {+ let n = need("pins")? as usize;+ if n % 4 != 0 { return Err(format!("quad needs pins divisible by 4, or pinsPerSideX and pinsPerSideY; got {n}")); }+ (n / 4, n / 4)+ }+ };+ let mut k = 1;+ // left top->bottom, bottom left->right, right bottom->top, top right->left+ for i in 0..ny { push(k.to_string(), -span_x / 2.0, ((ny as f64 - 1.0) / 2.0 - i as f64) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), (i as f64 - (nx as f64 - 1.0) / 2.0) * pitch, -span_y / 2.0, pw, pl); k += 1; }+ for i in 0..ny { push(k.to_string(), span_x / 2.0, (i as f64 - (ny as f64 - 1.0) / 2.0) * pitch, pl, pw); k += 1; }+ for i in 0..nx { push(k.to_string(), ((nx as f64 - 1.0) / 2.0 - i as f64) * pitch, span_y / 2.0, pw, pl); k += 1; }+ }+ "bga" => {+ let (pitch, d) = (need("pitch")?, need("ballPad")?);+ let (rows, cols) = (need("rows")? as usize, need("cols")? as usize);+ let missing: Vec<String> = a.get("missing").and_then(|m| m.as_array()).cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(|s| s.to_uppercase())).collect();+ for r in 0..rows {+ for c in 0..cols {+ let name = format!("{}{}", bga_row(r), c + 1);+ if missing.contains(&name) { continue; }+ push(name, (c as f64 - (cols as f64 - 1.0) / 2.0) * pitch, ((rows as f64 - 1.0) / 2.0 - r as f64) * pitch, d, d);+ }+ }+ }+ f => return Err(format!("family '{f}' is not one of chip, dual, sot, quad, bga")),+ }+ // the exposed (thermal) pad+ if let Some(ep) = a.get("exposedPad").filter(|e| e.is_object()) {+ let (w, h) = (num(ep, "w").ok_or("exposedPad needs w and h (mm)")?, num(ep, "h").ok_or("exposedPad needs w and h (mm)")?);+ let name = ep.get("number").map(|v| match v { Value::String(s) => s.clone(), o => o.to_string() }).unwrap_or_else(|| (out.len() + 1).to_string());+ out.push(Pad { name, x: r3(num(ep, "x").unwrap_or(0.0)), y: r3(num(ep, "y").unwrap_or(0.0)), w: r3(w), h: r3(h), shape: "rect".into(), hole: 0.0 });+ }+ Ok(out)+}++/// The parts of [a0, a1] that stay outside every blocked interval, kept when at least `min` long.+fn subtract(a0: f64, a1: f64, blocked: &mut [(f64, f64)], min: f64) -> Vec<(f64, f64)> {+ blocked.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+ let mut out = Vec::new();+ let mut cur = a0;+ for &(b0, b1) in blocked.iter() {+ if b1 <= cur || b0 >= a1 { continue; }+ if b0 > cur && b0 - cur >= min { out.push((cur, b0.min(a1))); }+ cur = cur.max(b1);+ if cur >= a1 { break; }+ }+ if a1 - cur >= min { out.push((cur, a1)); }+ out+}++/// The whole footprint: pads, then silkscreen (the body outline minus `clear` mm around every pad, and a pin-1 dot),+/// the fab outline with a pin-1 chamfer, and the courtyard (pads and body grown by `margin`).+pub fn layout(a: &Value) -> Result<Fp, String> {+ let family = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual").to_lowercase();+ let pads = pads(a)?;+ if pads.is_empty() { return Err("no pads".into()); }+ let body = a.get("body").cloned().unwrap_or(json!({}));+ let (bw, bh) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h")));+ let silk_w = num(a, "silkWidth").unwrap_or(0.15);+ let clear = num(a, "silkClearance").unwrap_or(0.2);+ let margin = num(a, "courtyard").unwrap_or(0.25);+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ let (mut x0, mut y0, mut x1, mut y1) = pads.iter().map(ext).fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, e| (a.0.min(e.0), a.1.min(e.1), a.2.max(e.2), a.3.max(e.3)));+ let mut fp = Fp { pads: pads.clone(), ..Default::default() };+ if let (Some(w), Some(h)) = (bw, bh) {+ let (bx0, by0, bx1, by1) = (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0);+ x0 = x0.min(bx0); y0 = y0.min(by0); x1 = x1.max(bx1); y1 = y1.max(by1);+ // silkscreen: each body edge minus the stretches that come within `clear` of copper+ let keep = clear + silk_w / 2.0;+ let mut edge = |horizontal: bool, c: f64, a0: f64, a1: f64| {+ let mut blocked: Vec<(f64, f64)> = pads.iter().filter_map(|p| {+ let (px0, py0, px1, py1) = ext(p);+ if horizontal { (c > py0 - keep && c < py1 + keep).then_some((px0 - keep, px1 + keep)) } else { (c > px0 - keep && c < px1 + keep).then_some((py0 - keep, py1 + keep)) }+ }).collect();+ for (s0, s1) in subtract(a0, a1, &mut blocked, 0.2) {+ if horizontal { fp.lines.push(("overlay", r3(s0), r3(c), r3(s1), r3(c), silk_w)); } else { fp.lines.push(("overlay", r3(c), r3(s0), r3(c), r3(s1), silk_w)); }+ }+ };+ // the outline is drawn just outside the body so it is not hidden under it+ let o = silk_w / 2.0;+ edge(true, by1 + o, bx0 - o, bx1 + o);+ edge(true, by0 - o, bx0 - o, bx1 + o);+ edge(false, bx0 - o, by0 - o, by1 + o);+ edge(false, bx1 + o, by0 - o, by1 + o);+ // fab: the body with its pin-1 corner (top-left) cut+ let ch = (w.min(h) * 0.25).min(1.0);+ let fab = [(bx0 + ch, by1), (bx1, by1), (bx1, by0), (bx0, by0), (bx0, by1 - ch), (bx0 + ch, by1)];+ for k in 0..fab.len() - 1 { fp.lines.push(("fab", r3(fab[k].0), r3(fab[k].1), r3(fab[k + 1].0), r3(fab[k + 1].1), 0.1)); }+ }+ // pin-1 dot: beside pin 1 on the outside, clear of its pad; a two-pad part only when polarized+ let polar = a.get("polarized").and_then(|b| b.as_bool()).unwrap_or(false);+ if family != "chip" || polar {+ let p1 = &pads[0];+ let r = 0.15;+ let (dx, dy) = if family == "bga" { (x0 - clear - r, y1 + clear + r) } else { (p1.x - p1.w / 2.0 - clear - r - silk_w, p1.y) };+ fp.discs.push(("overlay", r3(dx), r3(dy), r));+ x0 = x0.min(dx - r);+ y1 = y1.max(dy + r);+ }+ // courtyard: everything grown by the margin, on a 0.01 mm grid+ let g = |v: f64, up: bool| if up { (v * 100.0).ceil() / 100.0 } else { (v * 100.0).floor() / 100.0 };+ let c = (g(x0 - margin, false), g(y0 - margin, false), g(x1 + margin, true), g(y1 + margin, true));+ fp.courtyard = c;+ for (a0, b0, a1, b1) in [(c.0, c.1, c.2, c.1), (c.2, c.1, c.2, c.3), (c.2, c.3, c.0, c.3), (c.0, c.3, c.0, c.1)] {+ fp.lines.push(("courtyard", a0, b0, a1, b1, 0.05));+ }+ Ok(fp)+}++/// The agent's spec text.+pub fn spec(name: &str, desc: &str, fp: &Fp) -> String {+ let clean = |s: &str| s.replace(['|', '\n', '\r'], " ");+ let mut s = format!("META|{}|{}\n", clean(name), clean(desc));+ for p in &fp.pads {+ let crp = if p.shape == "roundrect" { 25 } else { 0 };+ s.push_str(&format!("PAD|{}|{:.4}|{:.4}|{:.4}|{:.4}|{}|{:.3}|{}|{}\n", clean(&p.name), p.x, p.y, p.w, p.h, p.shape, p.hole, if p.hole > 0.0 { "multi" } else { "top" }, crp));+ }+ for (l, a, b, c, d, w) in &fp.lines { s.push_str(&format!("LINE|{l}|{a:.4}|{b:.4}|{c:.4}|{d:.4}|{w:.4}\n")); }+ for (l, x, y, r) in &fp.discs { s.push_str(&format!("DISC|{l}|{x:.4}|{y:.4}|{r:.4}\n")); }+ s+}++/// A plain description: "SOIC-8, 1.27 mm pitch, 8 pads".+fn describe(a: &Value, fp: &Fp) -> String {+ let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");+ let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();+ format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())+}++/// altium_footprint_from_land_pattern {libraryPath, name, family, ..., dryRun?}+pub fn from_land_pattern(args: &Value) -> Value {+ let Some(name) = crate::support::s(args, "name").filter(|n| !n.is_empty()) else { return json!({"success": false, "errorCode": "bad_args", "error": "name is required"}) };+ let fp = match layout(args) { Ok(f) => f, Err(e) => return json!({"success": false, "errorCode": "bad_args", "error": e,+ "_hint": "Give the datasheet's RECOMMENDED LAND PATTERN numbers (mm): chip {span, padLength, padWidth}; dual/sot {pins | leftPins+rightPins, pitch, span (pad centre to pad centre across), padLength, padWidth}; quad {pins | pinsPerSideX+pinsPerSideY, pitch, span, spanY?, padLength, padWidth}; bga {rows, cols, pitch, ballPad, missing?}; plus exposedPad {w, h} and body {w, l, height?}."}) };+ let desc = crate::support::s(args, "description").unwrap_or_else(|| describe(args, &fp));+ let text = spec(&name, &desc, &fp);+ let summary = json!({"pads": fp.pads.len(), "courtyardMm": [fp.courtyard.0, fp.courtyard.1, fp.courtyard.2, fp.courtyard.3],+ "silkLines": fp.lines.iter().filter(|l| l.0 == "overlay").count(),+ "padTable": fp.pads.iter().take(12).map(|p| json!([p.name, p.x, p.y, p.w, p.h])).collect::<Vec<_>>()});+ if args.get("dryRun").and_then(|b| b.as_bool()) == Some(true) {+ return json!({"success": true, "dryRun": true, "name": name, "description": desc, "summary": summary, "spec": text,+ "_hint": "Nothing built. Check padTable (name, x, y, w, h in mm, Y up, pin 1 top-left) against the datasheet, then call again without dryRun."});+ }+ let Some(lib) = crate::support::s(args, "libraryPath").filter(|p| p.to_lowercase().ends_with(".pcblib")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "libraryPath (.PcbLib) is required"});+ };+ let dir = crate::agent::cache_dir().join("footprints");+ let _ = std::fs::create_dir_all(&dir);+ let safe: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();+ let path = dir.join(format!("{safe}.spec"));+ if let Err(e) = std::fs::write(&path, &text) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{}: {e}", path.display())}); }+ let mut r = crate::library::build_footprint(&json!({"libraryPath": lib, "spec": path.to_string_lossy(), "timeout": 240}));+ // a 3D body when the height is known: an extruded box the size of the body+ let body = args.get("body").cloned().unwrap_or(json!({}));+ if r.get("success").and_then(|b| b.as_bool()) != Some(false) {+ if let (Some(w), Some(l), Some(h)) = (num(&body, "w"), num(&body, "l"), num(&body, "height")) {+ let mil = |mm: f64| (mm / 0.0254).round() as i64;+ let b = crate::library::add_body(&json!({"libraryPath": lib, "footprintName": name, "bwMil": mil(w), "bhMil": mil(l), "hMil": mil(h), "soMil": mil(num(&body, "standoff").unwrap_or(0.0))}));+ r["body3d"] = json!({"success": b.get("success"), "error": b.get("error")});+ }+ r["_hint"] = json!("Built in the PcbLib (unsaved): check shot.image, then altium_save {filePath: libraryPath}. Link it to a symbol with altium_set_models.");+ }+ r["summary"] = summary;+ r["spec"] = json!(path.to_string_lossy());+ r+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn soic8_numbers_down_the_left_and_up_the_right() {+ let p = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})).unwrap();+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("1", -2.7, 1.905));+ assert_eq!((p[3].name.as_str(), p[3].y), ("4", -1.905));+ assert_eq!((p[4].name.as_str(), p[4].x, p[4].y), ("5", 2.7, -1.905));+ assert_eq!((p[7].name.as_str(), p[7].y), ("8", 1.905));+ assert_eq!((p[0].w, p[0].h), (1.55, 0.6));+ }++ #[test]+ fn quad_goes_counter_clockwise_and_turns_the_top_and_bottom_pads() {+ let p = pads(&json!({"family": "quad", "pins": 16, "pitch": 0.5, "span": 3.0, "padLength": 0.8, "padWidth": 0.25, "exposedPad": {"w": 1.7, "h": 1.7}})).unwrap();+ assert_eq!(p.len(), 17);+ assert_eq!((p[0].x, p[0].y), (-1.5, 0.75)); // 1: left, top+ assert_eq!((p[4].x, p[4].y, p[4].w), (-0.75, -1.5, 0.25)); // 5: bottom, left end, turned+ assert_eq!((p[8].x, p[8].y), (1.5, -0.75)); // 9: right, bottom+ assert_eq!((p[12].x, p[12].y), (0.75, 1.5)); // 13: top, right end+ assert_eq!((p[16].name.as_str(), p[16].shape.as_str()), ("17", "rect"));+ }++ #[test]+ fn sot23_has_two_pins_left_and_one_right() {+ let p = pads(&json!({"family": "sot", "leftPins": 2, "rightPins": 1, "pitch": 1.9, "span": 2.2, "padLength": 1.0, "padWidth": 0.6})).unwrap();+ assert_eq!(p.iter().map(|q| (q.name.clone(), q.x, q.y)).collect::<Vec<_>>(), vec![("1".into(), -1.1, 0.95), ("2".into(), -1.1, -0.95), ("3".into(), 1.1, 0.0)]);+ }++ #[test]+ fn bga_rows_skip_confusable_letters() {+ assert_eq!((bga_row(0), bga_row(7), bga_row(8), bga_row(19), bga_row(20)), ("A".into(), "H".into(), "J".into(), "Y".into(), "AA".into()));+ let p = pads(&json!({"family": "bga", "rows": 3, "cols": 3, "pitch": 0.8, "ballPad": 0.4, "missing": ["b2"]})).unwrap();+ assert_eq!(p.len(), 8);+ assert_eq!((p[0].name.as_str(), p[0].x, p[0].y), ("A1", -0.8, 0.8));+ }++ #[test]+ fn silkscreen_keeps_clear_of_copper_and_the_courtyard_holds_everything() {+ let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9}});+ let fp = layout(&a).unwrap();+ let ext = |p: &Pad| (p.x - p.w / 2.0, p.y - p.h / 2.0, p.x + p.w / 2.0, p.y + p.h / 2.0);+ for &(l, x0, y0, x1, y1, w) in fp.lines.iter().filter(|l| l.0 == "overlay") {+ let _ = l;+ for p in &fp.pads {+ let (px0, py0, px1, py1) = ext(p);+ // distance from the segment (axis-aligned) to the pad box, minus half the stroke+ let dx = (px0 - x0.max(x1)).max(x0.min(x1) - px1).max(0.0);+ let dy = (py0 - y0.max(y1)).max(y0.min(y1) - py1).max(0.0);+ assert!((dx * dx + dy * dy).sqrt() - w / 2.0 >= 0.199, "silk {x0},{y0}-{x1},{y1} too close to pad {}", p.name);+ }+ }+ // a SOIC's body sides sit under its pads: only the top and bottom edges survive+ assert_eq!(fp.lines.iter().filter(|l| l.0 == "overlay").count(), 2);+ let c = fp.courtyard;+ assert!(fp.pads.iter().all(|p| { let (a0, b0, a1, b1) = ext(p); a0 >= c.0 && b0 >= c.1 && a1 <= c.2 && b1 <= c.3 }));+ assert_eq!(fp.discs.len(), 1);+ assert!(fp.discs[0].1 < -2.7 - 0.775);+ let s = spec("SOIC-8", "test", &fp);+ assert!(s.starts_with("META|SOIC-8|test\nPAD|1|-2.7000|1.9050|1.5500|0.6000|roundrect|0.000|top|25\n"));+ }++ #[test]+ fn a_missing_number_is_named() {+ let e = pads(&json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padWidth": 0.6})).unwrap_err();+ assert!(e.contains("padLength"));+ }+}
bridge-rs/src/hier.rsadded+201@@ -0,0 +1,201 @@+//! Hierarchical schematics: a sheet symbol on a parent sheet for a child sheet, its entries joined to the parent's+//! nets, and matching ports on the child. The child is made from Altium's own sheet template and listed in the+//! project when it does not exist yet.+use crate::sch::{agent_data, clean, maybe_save, st};+use crate::{agent, support};+use serde_json::{json, Value};+use std::path::Path;+use std::time::Duration;++/// The project file's text with one more [DocumentN] for `rel` (after the last document section, numbered on).+/// None when it already lists it. Pure, for the tests.+pub fn add_document(prj_text: &str, rel: &str) -> Option<String> {+ let norm = |s: &str| s.replace('/', "\\").to_lowercase();+ if prj_text.lines().any(|l| l.trim().strip_prefix("DocumentPath=").map(|d| norm(d) == norm(rel)).unwrap_or(false)) { return None; }+ let nums: Vec<u32> = prj_text.lines().filter_map(|l| l.trim().strip_prefix("[Document").and_then(|r| r.strip_suffix(']')).and_then(|n| n.parse().ok())).collect();+ let n = nums.iter().max().copied().unwrap_or(0) + 1;+ let section = format!("[Document{n}]\r\nDocumentPath={rel}\r\nAnnotationEnabled=1\r\n\r\n");+ // insert before the first section that follows the last [DocumentN] block, or at the end+ let lines: Vec<&str> = prj_text.split_inclusive('\n').collect();+ let last_doc = lines.iter().rposition(|l| l.trim().starts_with("[Document") && l.trim().ends_with(']'));+ let insert_at = match last_doc {+ Some(i) => lines.iter().enumerate().skip(i + 1).find(|(_, l)| l.trim().starts_with('[')).map(|(k, _)| k).unwrap_or(lines.len()),+ None => lines.len(),+ };+ let mut out: String = lines[..insert_at].concat();+ if !out.ends_with("\r\n\r\n") && !out.ends_with("\n\n") { if !out.ends_with('\n') { out.push_str("\r\n"); } out.push_str("\r\n"); }+ out.push_str(§ion);+ out.push_str(&lines[insert_at..].concat());+ Some(out)+}++/// The side an entry sits on: as asked, else inputs left and everything else right.+fn side_of(e: &Value) -> &'static str {+ match e.get("side").and_then(|s| s.as_str()) {+ Some(s) if s.eq_ignore_ascii_case("left") => "left",+ Some(s) if s.eq_ignore_ascii_case("right") => "right",+ _ => if e.get("io").and_then(|s| s.as_str()).map(|s| s.eq_ignore_ascii_case("input")).unwrap_or(false) { "left" } else { "right" },+ }+}++/// altium_sch_sheet_symbol {sheet, child, projectPath?, name?, x?, y?, widthMil?, entries:[{name, io?, side?, net?}], ports?, save?}+pub fn sheet_symbol(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let Some(parent) = support::s(args, "sheet").filter(|p| p.to_lowercase().ends_with(".schdoc")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "sheet (the parent .SchDoc) is required"});+ };+ let Some(child_arg) = support::s(args, "child").filter(|p| p.to_lowercase().ends_with(".schdoc")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "child (the .SchDoc the symbol stands for) is required"});+ };+ let pdir = Path::new(&parent).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();+ let child = if Path::new(&child_arg).is_absolute() || child_arg.contains(':') { child_arg.clone() } else { format!("{pdir}\\{child_arg}") };+ let entries = args.get("entries").and_then(|e| e.as_array()).cloned().unwrap_or_default();+ if entries.iter().any(|e| st(&e["name"]).is_empty()) {+ return json!({"success": false, "errorCode": "bad_args", "error": "every entry needs a name"});+ }+ let mut steps = Vec::new();+ // the child: Altium's own sheet template, when it does not exist yet+ let created = !Path::new(&child).exists();+ if created {+ let tpl = match support::altium_public_file(r"Templates\ANSI B Landscape.SchDot") {+ Ok(p) => p,+ Err(e) => return json!({"success": false, "errorCode": "template_missing", "error": e}),+ };+ if let Err(e) = std::fs::copy(&tpl, &child) { return json!({"success": false, "errorCode": "write_failed", "error": format!("could not create {child}: {e}")}); }+ steps.push(json!({"childCreated": child}));+ }+ // the project lists the child (a sheet outside the project is not part of the design)+ if let Some(prj) = support::s(args, "projectPath") {+ let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+ if text.is_empty() { return json!({"success": false, "errorCode": "project_not_found", "error": format!("{prj} could not be read")}); }+ let prj_dir = Path::new(&prj).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();+ if let Some(new_text) = add_document(&text, &crate::routing::rel_to(&prj_dir, &child)) {+ // rewriting an open project's file makes Altium ask to reload it: save and close its members first+ let (open, unlisted) = crate::routing::project_members(Path::new(&prj));+ if !unlisted.is_empty() {+ return json!({"success": false, "errorCode": "project_has_unlisted_documents", "error": format!("{prj} is open with documents its file does not list: {}", unlisted.join(", ")),+ "_hint": "Save or close those documents first (altium_save / altium_close_document), then call again."});+ }+ crate::routing::close_project_saved(&prj, &open);+ if let Err(e) = std::fs::write(&prj, new_text) { return json!({"success": false, "errorCode": "write_failed", "error": format!("{prj}: {e}")}); }+ agent::call("open", &[("path", prj.clone())], None, Duration::from_secs(120));+ steps.push(json!({"addedToProject": prj}));+ }+ }+ // the symbol on the parent+ let left = entries.iter().filter(|e| side_of(e) == "left").count();+ let right = entries.len() - left;+ let h = ((left.max(right) as i64 + 1) * 100).max(400);+ let w = args.get("widthMil").and_then(|v| v.as_i64()).unwrap_or(1400);+ let x = args.get("x").and_then(|v| v.as_i64()).unwrap_or(2000) / 100 * 100;+ let y = args.get("y").and_then(|v| v.as_i64()).unwrap_or(8000) / 100 * 100;+ let stem = Path::new(&child).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+ let name = support::s(args, "name").unwrap_or(stem);+ let file = crate::routing::rel_to(&pdir, &child);+ let payload: Vec<String> = entries.iter().map(|e| format!("{}|{}|{}|{}|{}", clean(&st(&e["name"])), side_of(e), clean(&st(&e["io"])).to_lowercase(),+ clean(&st(&e["net"])), e.get("stubMil").and_then(|v| v.as_i64()).unwrap_or(300))).collect();+ if let Some(e) = support::must_open(&parent, 60) { return e; }+ let r = agent::call("sheet_symbol", &[("x", x.to_string()), ("y", y.to_string()), ("w", w.to_string()), ("h", h.to_string()), ("name", clean(&name)), ("file", clean(&file))],+ Some(&payload.join("\n")), Duration::from_secs(90));+ let sym = match agent_data(&r) { Ok(d) => d, Err(e) => return e };+ let saved_parent = maybe_save(args, &parent);+ // the ports on the child, for the entries it lacks+ let mut ports = Value::Null;+ if args.get("ports").and_then(|b| b.as_bool()) != Some(false) && !entries.is_empty() {+ if let Some(e) = support::must_open(&child, 60) { return e; }+ let pp: Vec<String> = entries.iter().map(|e| format!("{}|{}", clean(&st(&e["name"])), clean(&st(&e["io"])).to_lowercase())).collect();+ let at = args.get("portsAt").cloned().unwrap_or(json!({}));+ let n = |k: &str, d: i64| at.get(k).and_then(|v| v.as_i64()).unwrap_or(d).to_string();+ let pr = agent::call("sheet_ports", &[("left", n("left", 1000)), ("right", n("right", 15000)), ("top", n("top", 9500))], Some(&pp.join("\n")), Duration::from_secs(90));+ ports = match agent_data(&pr) { Ok(d) => json!({"placed": d["placed"], "alreadyThere": d["alreadyThere"]}), Err(e) => return e };+ let _ = maybe_save(args, &child);+ }+ json!({"success": true, "parent": parent, "child": child, "childCreated": created, "symbol": {"name": sym["name"], "file": sym["file"], "atMil": [x, y], "sizeMil": [w, h], "entries": sym["entries"]},+ "ports": ports, "savedParent": saved_parent, "steps": steps,+ "_hint": "Wire the child's circuit to its ports: altium_sch_connect {sheet: child, connections:[{designator, pin, net: <port name>, kind:'port'}]} or a wire from a pin to the port's stubEndMil. Then altium_compile {projectPath}: the entries and ports join by name, and the parent's nets reach the child."})+}++/// Compare a compiled netlist with the nets the caller expects: each expected net's missing and extra pins, and nets+/// joined that were not expected (two or more pins). Pure, for the tests.+pub fn compare(got: &serde_json::Map<String, Value>, want: &serde_json::Map<String, Value>) -> Vec<Value> {+ use std::collections::BTreeSet;+ let set = |v: &Value| -> BTreeSet<String> { v.as_array().cloned().unwrap_or_default().iter().filter_map(|x| x.as_str().map(|s| s.to_uppercase())).collect() };+ let find = |n: &str| got.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| set(v)).unwrap_or_default();+ let mut out = Vec::new();+ for (net, pins) in want {+ let (w, g) = (set(pins), find(net));+ if w != g { out.push(json!({"net": net, "missing": w.difference(&g).collect::<Vec<_>>(), "extra": g.difference(&w).collect::<Vec<_>>()})); }+ }+ for (net, pins) in got {+ if !want.keys().any(|k| k.eq_ignore_ascii_case(net)) && set(pins).len() > 1 { out.push(json!({"net": net, "unexpected": pins})); }+ }+ out+}++/// altium_netlist {projectPath, net?, expect?}: compile the project and read its nets (every sheet, hierarchy+/// resolved) as {net: ["U1.3", ...]}; with expect, compare pin by pin.+pub fn netlist(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let Some(prj) = support::s(args, "projectPath").filter(|p| p.to_lowercase().ends_with(".prjpcb")) else {+ return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) is required"});+ };+ // the project focused: its own file opened, then its first sheet (the netlist reads the FOCUSED project)+ agent::call("open", &[("path", prj.clone())], None, Duration::from_secs(120));+ let text = std::fs::read(&prj).map(|b| String::from_utf8_lossy(&b).to_string()).unwrap_or_default();+ let dir = Path::new(&prj).parent().unwrap_or(Path::new("."));+ if let Some(first) = crate::bom::parse_project(&text, dir).sheets.first() {+ if let Some(e) = support::must_open(first, 60) { return e; }+ }+ let c = agent::call("compile", &[("objectKind", "Project".into())], None, Duration::from_secs(300));+ let n = agent::call("project_nets", &[], None, Duration::from_secs(120));+ let d = match agent_data(&n) { Ok(d) => d, Err(e) => return e };+ if !d["project"].as_str().map(|p| p.eq_ignore_ascii_case(&prj.replace('/', "\\"))).unwrap_or(false)+ && !d["project"].as_str().map(|p| Path::new(p).file_name() == Path::new(&prj).file_name()).unwrap_or(false) {+ return json!({"success": false, "errorCode": "wrong_project", "error": format!("Altium compiled {} instead", d["project"]), "_hint": "Open a sheet of the project you mean (altium_open_document), then call again."});+ }+ let mut nets = d["nets"].as_object().cloned().unwrap_or_default();+ if let Some(want) = support::s(args, "net") { nets.retain(|k, _| k.eq_ignore_ascii_case(&want)); }+ let errors: Vec<Value> = c.get("data").and_then(|x| x.get("messages")).and_then(|m| m.as_array()).cloned().unwrap_or_default()+ .into_iter().filter(|m| m["class"].as_str().map(|k| k.to_lowercase().contains("error")).unwrap_or(false)).collect();+ let mut out = json!({"success": true, "project": prj, "netCount": d["netCount"], "nets": nets, "compileErrors": errors,+ "_hint": "Pins are Designator.PinNumber across every sheet, the hierarchy resolved (a net named by a port or entry carries its parent's name). Pass expect:{net:[pins]} to compare."});+ if let Some(want) = args.get("expect").and_then(|e| e.as_object()) {+ let mm = compare(&d["nets"].as_object().cloned().unwrap_or_default(), want);+ out["success"] = json!(mm.is_empty());+ out["mismatches"] = json!(mm);+ }+ out+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn a_child_sheet_joins_the_project_after_its_last_document() {+ let prj = "[Design]\r\nVersion=1.0\r\n\r\n[Document1]\r\nDocumentPath=lane.SchDoc\r\n\r\n[Document2]\r\nDocumentPath=lane.PcbDoc\r\n\r\n[ProjectVariant1]\r\nDescription=Lite\r\n";+ let out = add_document(prj, "Power.SchDoc").unwrap();+ assert!(out.contains("[Document3]\r\nDocumentPath=Power.SchDoc\r\n"));+ assert!(out.find("[Document3]").unwrap() < out.find("[ProjectVariant1]").unwrap());+ assert!(add_document(&out, "power.schdoc").is_none());+ assert!(add_document("[Design]\r\nVersion=1.0\r\n", "A.SchDoc").unwrap().contains("[Document1]\r\nDocumentPath=A.SchDoc"));+ }++ #[test]+ fn netlist_comparison_names_missing_extra_and_unexpected() {+ let got = json!({"VBUS": ["U1.1", "U10.1"], "NetU2_3": ["U2.3", "U10.3"], "GND": ["U1.4"]});+ let want = json!({"vbus": ["u1.1", "U10.1"], "GND": ["U1.4", "U2.4"]});+ let mm = compare(got.as_object().unwrap(), want.as_object().unwrap());+ assert_eq!(mm.len(), 2);+ assert_eq!(mm[0]["missing"], json!(["U2.4"]));+ assert_eq!(mm[1]["net"], json!("NetU2_3"));+ }++ #[test]+ fn inputs_go_left_and_the_rest_right_unless_asked() {+ assert_eq!(side_of(&json!({"io": "input"})), "left");+ assert_eq!(side_of(&json!({"io": "output"})), "right");+ assert_eq!(side_of(&json!({"io": "output", "side": "left"})), "left");+ assert_eq!(side_of(&json!({})), "right");+ }+}
bridge-rs/src/main.rs+3−1@@ -12,6 +12,8 @@ mod dialogs; mod health; mod install; mod features;+mod footprint;+mod hier; mod inspect; mod instance; mod library;@@ -39,7 +41,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.66";+pub const BRIDGE_VERSION: &str = "0.2.67"; fn json_response(status: u16, body: &serde_json::Value) -> Response<std::io::Cursor<Vec<u8>>> { let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
bridge-rs/src/manufacturing.rs+7@@ -157,6 +157,12 @@ pub fn export_manufacturing(args: &Value) -> Value { return json!({"success": false, "errorCode": "bad_args", "error": "projectPath (.PrjPcb) and pcbPath (.PcbDoc) are required", "_hint": "altium_build_schematic writes the project; the board must be one of its documents."}); }+ // the silkscreen comes off the copper first (Altium's Silkscreen Preparation): fabs trim ink off pads anyway, but+ // the delivered board and its renders showed silk over pads (a viewer of the buck video spotted it, 2026-10-23)+ let silk_prep = if args.get("silkPrep").and_then(|v| v.as_bool()) == Some(false) { json!({"skipped": "silkPrep:false"}) } else {+ let r = crate::route::silkscreen_prep(&json!({"filePath": pcb, "check": args.get("silkPrepCheck").and_then(|v| v.as_bool()).unwrap_or(false)}));+ json!({"success": r.get("success"), "steps": r.get("steps"), "before": r.get("before"), "after": r.get("after"), "dialogsLeft": r.get("dialogsLeft")})+ }; // the board's inner copper (planes, mid layers) goes into the Gerber plot set let inner = if crate::support::must_open(&pcb, 60).is_none() { crate::agent::call("layer_stack", &[], None, std::time::Duration::from_secs(60)).get("data").map(inner_layer_ids).unwrap_or_default()@@ -184,6 +190,7 @@ pub fn export_manufacturing(args: &Value) -> Value { let mut a = json!({"outjobPath": outjob, "projectPath": prj, "medium": "Manufacturing"}); if let Some(v) = args.get("timeout") { a["timeout"] = v.clone(); } let mut r = outputs::run_outjob(&a);+ r["silkPrep"] = silk_prep.clone(); // a fab house reads the board's outline from the Gerbers: a scripted board has its shape but nothing on any // outline layer, and its GM1/GKO came out empty with no warning (2026-10-22). Draw it on Mechanical 1 and // generate again, unless told not to.
bridge-rs/src/route.rs+68@@ -27,6 +27,74 @@ fn checkbox_state(tree: &Value, name: &str) -> Option<String> { walk(&root, name) } +/// The silkscreen DRC hits by rule (silk to solder mask, silk to silk, silk outside the board), from a full DRC.+fn silk_hits(path: &str) -> Value {+ let r = crate::outputs::run_drc(&json!({"filePath": path, "limit": 5000, "shots": false}));+ let v = r.get("violations").and_then(|v| v.as_array()).cloned().unwrap_or_default();+ let n = |rule: &str| v.iter().filter(|x| x.get("rule").and_then(|r| r.as_str()) == Some(rule)).count();+ json!({"silkToSolderMask": n("SilkToSolderMaskClearance"), "silkToSilk": n("SilkToSilkClearance"),+ "silkToBoardRegion": n("SilkToBoardRegionClearance"), "maskSliver": n("MinimumSolderMaskSliver"), "drcOk": r.get("success")})+}++/// altium_silkscreen_prep {filePath?, moveText?, deleteOutsideBoard?, clipLocked?, minRemainingMil?, selectedOnly?, check?, save?}:+/// Altium's own Tools > Silkscreen Preparation (PCB:ClipSilkScreen), its dialog answered in the background, with the+/// silkscreen DRC hits counted before and after.+pub fn silkscreen_prep(args: &Value) -> Value {+ if let Some(g) = support::need_licensed() { return g; }+ let t0 = support::now();+ let path = s(args, "filePath");+ if let Some(p) = &path { if let Some(e) = support::must_open(p, 60) { return e; } }+ let check = args.get("check").and_then(|b| b.as_bool()) != Some(false);+ let board = path.clone().unwrap_or_default();+ let before = if check && !board.is_empty() { silk_hits(&board) } else { Value::Null };+ if let Some(p) = &path { if let Some(e) = support::must_open(p, 60) { return e; } }+ agent::call("run_process", &[("process", "PCB:ClipSilkScreen".into()), ("params", "".into())], None, Duration::from_secs(6));+ let mut dlg = None;+ for _ in 0..40 { if let Some(w) = find_window("silkscreen preparation") { dlg = Some(w); break; } std::thread::sleep(Duration::from_millis(500)); }+ let Some(dlg) = dlg else {+ return json!({"success": false, "errorCode": "silk_prep_dialog_not_found", "_hint": "PCB:ClipSilkScreen did not open its dialog: is a PCB the current document? (filePath opens one)"});+ };+ let h = dlg.get("hwnd").cloned().unwrap_or(Value::Null);+ let mut steps = Vec::new();+ if args.get("selectedOnly").and_then(|b| b.as_bool()) == Some(true) {+ let r = ab::call("desktop_ui_select", json!({"hwnd": h, "name": "Selected", "role": "listitem"}), &[], 20);+ steps.push(json!({"scope": "Selected", "success": r.get("success")}));+ }+ if let Some(m) = args.get("minRemainingMil").and_then(|v| v.as_f64()) {+ let r = ab::call("desktop_ui_set", json!({"hwnd": h, "automationId": "RemainingLength", "value": format!("{m}mil")}), &[], 15);+ steps.push(json!({"minRemainingMil": m, "success": r.get("success")}));+ }+ // Move Text keeps a designator readable (moved off the pad, within Max Distance) instead of clipping it to nothing+ let tree = ab::call("desktop_ui_tree", json!({"hwnd": h, "maxDepth": 10, "maxNodes": 200, "roleFilter": ["checkbox"]}), &[], 20);+ for (name, want) in [("Use Design Rules", true),+ ("Move Text", args.get("moveText").and_then(|b| b.as_bool()).unwrap_or(true)),+ ("Delete Silksreen Outside Board Shape", args.get("deleteOutsideBoard").and_then(|b| b.as_bool()).unwrap_or(true)),+ ("Clip Locked Components and Primitives", args.get("clipLocked").and_then(|b| b.as_bool()).unwrap_or(false))] {+ let on = checkbox_state(&tree, name).map(|x| x.eq_ignore_ascii_case("On")).unwrap_or(false);+ if on != want {+ let r = ab::call("desktop_ui_toggle", json!({"hwnd": h, "name": name}), &[], 15);+ steps.push(json!({"toggle": name, "to": want, "success": r.get("success")}));+ }+ }+ let go = ab::call("desktop_ui_click", json!({"hwnd": h, "name": "OK", "role": "button"}), &[], 30);+ steps.push(json!({"uiClick": "OK", "success": go.get("success")}));+ let wait = timeout_of(args, 300);+ let t1 = std::time::Instant::now();+ std::thread::sleep(Duration::from_secs(2));+ while t1.elapsed() < wait && !agent::is_responsive() { std::thread::sleep(Duration::from_millis(600)); }+ // anything Altium asks afterwards is reported, never answered blind+ let left = crate::dialogs::scan_scoped(true);+ let saved = if args.get("save").and_then(|b| b.as_bool()) == Some(false) || board.is_empty() { json!(false) } else {+ json!(crate::surfaces::save(&json!({"filePath": board})).get("success").and_then(|b| b.as_bool()) != Some(false))+ };+ let after = if check && !board.is_empty() { silk_hits(&board) } else { Value::Null };+ let clean = after.get("silkToSolderMask").and_then(|v| v.as_u64()) == Some(0);+ json!({"success": left.is_empty(), "board": board, "before": before, "after": after, "silkOffCopper": if after.is_null() { Value::Null } else { json!(clean) },+ "steps": steps, "dialogsLeft": left, "saved": saved, "seconds": support::elapsed_round(t0),+ "_hint": if clean || after.is_null() { "Silkscreen clipped clear of every mask opening (pad), designators moved off pads where they fit. Fabs trim silk off pads anyway; this makes the board itself look right in renders and outputs." }+ else { "Some silk still touches a mask opening (after.silkToSolderMask): text that could not move within Max Distance. altium_silk_state lists it; altium_apply_silkscreen moves it." }})+}+ /// altium_autoroute {filePath?, strategy?, lockPreroutes?, ripupViolations?, save?, timeout?} pub fn autoroute(args: &Value) -> Value { if let Some(g) = support::need_licensed() { return g; }
bridge-rs/src/routing.rs+3−3@@ -833,7 +833,7 @@ fn same_file_name(a: &std::path::Path, b: &std::path::Path) -> bool { /// Altium's own view of a project: the documents it holds OPEN under that project, and those of them /// the project FILE does not list (they joined in memory only). Closing the project over an unlisted, /// modified member raises "Save changes?" on the user's screen.-fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) {+pub(crate) fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) { let dir = prj.parent().unwrap_or(std::path::Path::new(".")); let listed = std::fs::read(prj).map(|b| listed_documents(&String::from_utf8_lossy(&b), dir)).unwrap_or_default(); let name = prj.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();@@ -846,7 +846,7 @@ fn project_members(prj: &std::path::Path) -> (Vec<String>, Vec<String>) { } /// Save every open member of a project, then close them and the project: no prompt, nothing lost.-fn close_project_saved(prj: &str, open: &[String]) {+pub(crate) fn close_project_saved(prj: &str, open: &[String]) { for d in open { let _ = crate::surfaces::save(&json!({"filePath": d})); }@@ -1152,7 +1152,7 @@ fn agent_ok(r: &Value) -> Option<Value> { } /// a document's path as the project file lists it: relative when it sits under the project's folder-fn rel_to(dir: &str, path: &str) -> String {+pub(crate) fn rel_to(dir: &str, path: &str) -> String { let d = dir.trim_end_matches(['\\', '/']).replace('/', "\\"); let p = path.replace('/', "\\"); if p.to_lowercase().starts_with(&(d.to_lowercase() + "\\")) { p[d.len() + 1..].to_string() } else { p }
bridge-rs/src/sch.rs+7−7@@ -8,9 +8,9 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::time::Duration; -fn st(v: &Value) -> String { v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() }) }+pub(crate) fn st(v: &Value) -> String { v.as_str().map(str::to_string).unwrap_or_else(|| if v.is_null() { String::new() } else { v.to_string() }) } -fn clean(v: &str) -> String { v.replace(['|', '\n', '\r'], " ") }+pub(crate) fn clean(v: &str) -> String { v.replace(['|', '\n', '\r'], " ") } fn need_sheet(args: &Value) -> Result<String, Value> { let Some(sheet) = s(args, "sheet").or_else(|| s(args, "filePath")) else {@@ -23,13 +23,13 @@ fn need_sheet(args: &Value) -> Result<String, Value> { } /// Save the sheet unless save:false; the reply says which.-fn maybe_save(args: &Value, sheet: &str) -> Value {+pub(crate) fn maybe_save(args: &Value, sheet: &str) -> Value { if args.get("save").and_then(|b| b.as_bool()) == Some(false) { return json!(false); } let r = crate::surfaces::save(&json!({"filePath": sheet})); json!(r.get("success").and_then(|b| b.as_bool()) != Some(false)) } -fn agent_data(r: &Value) -> Result<Value, Value> {+pub(crate) fn agent_data(r: &Value) -> Result<Value, Value> { match r.get("data") { Some(d) if d["ok"].as_bool() == Some(true) => Ok(d.clone()), Some(d) => Err(json!({"success": false, "errorCode": d.get("errorCode").cloned().unwrap_or(json!("agent_refused")), "error": d.get("error"), "reply": r})),@@ -42,10 +42,10 @@ 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), noerc (a No ERC marker on a pin left open on purpose; no net)"});+ 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), port (a port named after the net, io: input|output|bidirectional, the sheet's link to its sheet symbol)"}); };- 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();+ 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), clean(&st(&c["io"])))).collect(); if let Some(e) = support::must_open(&sheet, 60) { return e; } let r = agent::call("sch_connect", &[], Some(&lines.join("\n")), timeout_of(args, 90)); let d = match agent_data(&r) { Ok(d) => d, Err(e) => return e };
bridge-rs/src/verbs.rs+41−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", "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",+ "routing_state", "route_net", "move_parts", "place_vias", "board_features", "sch_sheet_symbol", "netlist", "silkscreen_prep", "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",@@ -32,7 +32,7 @@ pub const KNOWN: &[&str] = &[ "save", "test_board", "board_shape", "pcb_report", "import_kicad", "install_extension", "extension_ping", // library authoring- "create_footprint", "create_symbol", "build_footprint", "build_symbol_modes", "add_body",+ "create_footprint", "create_symbol", "build_footprint", "footprint_from_land_pattern", "build_symbol_modes", "add_body", "add_models", "normalize_bodies", "plate_vias", "set_params", "set_visibility", "read_symbol", "clone_symbol", "rename_symbol", "rename_footprint", "remove_symbol", "remove_footprint", "copy_footprint", "xcopy_footprint", "swap_footprint", "symbol_mode", "cleanup_libs",@@ -474,7 +474,7 @@ fn describe() -> Value { &["schematic_data", "compile", "build_board"], &["The SchLib must hold every symbol (altium_import_part).", "Parts are placed unrotated."], json!({"schPath": "C:\\work\\Buck.SchDoc", "libraryPath": "C:\\work\\library\\Buck.SchLib", "parts": [{"ref": "U1", "symbol": "TPS54202DDCR", "block": "core", "comment": "TPS54202"}], "nets": {"VIN": ["U1.3"]}})), verb_doc("export_manufacturing", "A project's manufacturing set in one call: Gerbers, NC drill, pick and place (CSV, mm) and the BOM, from an OutJob the bridge writes beside the project (composed from this Altium's own OutJob templates, every output on one medium that opens nothing).",- json!({"projectPath": "the .PrjPcb", "pcbPath": "its .PcbDoc", "bomColumns": "(optional) [column or parameter name] - default Comment, Description, Designator, Footprint, Quantity, MPN, Manufacturer, Mouser/DigiKey/LCSC Part #", "clean": "bool (default true) - empty the output folder first", "timeout": "seconds, default 300"}),+ json!({"projectPath": "the .PrjPcb", "pcbPath": "its .PcbDoc", "bomColumns": "(optional) [column or parameter name] - default Comment, Description, Designator, Footprint, Quantity, MPN, Manufacturer, Mouser/DigiKey/LCSC Part #", "clean": "bool (default true) - empty the output folder first", "silkPrep": "bool (default true) - Altium's Silkscreen Preparation first (silk off the pads, text moved where it fits)", "silkPrepCheck": "bool (default false) - DRC silk counts before/after in silkPrep", "timeout": "seconds, default 300"}), json!({"outjob": "path", "outputDir": "path", "files": "[relative paths]", "dialogs": "[..]", "elapsedSec": "float"}), 600, "Pull files[] with pull_file and check them against the board (layers, drill hits, every placed part in the pick and place and the BOM) before handing them over. AI Flow's `altium export` does both.", &["run_outjob", "build_schematic", "routing_state"], &["The board must be a document of the project.", "The BOM is the compiled project's (schematic parts); board-only pads are not in it."],@@ -645,7 +645,7 @@ fn describe() -> Value { &["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); 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!({"sheet": "path to the .SchDoc (required)", "connections": "[{designator, pin (number or name), net, kind: label|power|wire|noerc|port (default label), io?: input|output|bidirectional (port), 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,27 @@ 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("silkscreen_prep", "Clean the silkscreen off the copper with Altium's own Tools > Silkscreen Preparation: silk clipped clear of every mask opening (pad) by the board's silk rule, designators moved off pads where they fit, silk outside the board deleted; the silkscreen DRC hits counted before and after.",+ json!({"filePath": "the .PcbDoc (opened first)", "moveText": "bool, default true: move text off pads (within Altium's Max Distance) instead of clipping it", "deleteOutsideBoard": "bool, default true", "clipLocked": "bool, default false", "minRemainingMil": "shortest silk segment kept (Altium's default 5.9 mil)", "selectedOnly": "bool, default false", "check": "bool, default true: DRC before and after", "save": "bool, default true"}),+ json!({"before": "{silkToSolderMask, silkToSilk, silkToBoardRegion, maskSliver}", "after": "{same}", "silkOffCopper": "bool", "steps": "[toggles, OK]", "dialogsLeft": "[anything Altium asked]"}), 600,+ "Run it before fab outputs and before 3D or screenshot renders: fabs trim silk off pads anyway, but a board that shows ink on copper looks wrong.",+ &["silk_state", "apply_silkscreen", "run_drc", "export_manufacturing"],+ &["It uses the board's SilkToSolderMaskClearance rule (Use Design Rules stays on).", "Text that cannot move within Max Distance stays and is counted in after.silkToSolderMask."],+ json!({"filePath": "C:\\\\work\\\\Board.PcbDoc"})),+ verb_doc("netlist", "The compiled netlist of a project: every net with its pins (Designator.Pin) across every sheet, the hierarchy resolved; or one net; or compare with the nets you expect.",+ json!({"projectPath": ".PrjPcb (required)", "net": "optional: one net", "expect": "optional {net: [\"U1.3\", ...]}: compare pin by pin"}),+ json!({"netCount": "int", "nets": "{net: [pin]}", "compileErrors": "[messages]", "mismatches": "[{net, missing, extra} | {net, unexpected}] (expect)"}), 300,+ "Use it to check a schematic the AI drew or edited before altium_update_pcb: expect the nets you meant and read mismatches.",+ &["compile", "schematic_data", "sch_connect", "sch_sheet_symbol", "update_pcb"],+ &["Unnamed nets read as Altium names them (NetU2_3).", "Compiles the project first: the reply is the design as Altium sees it now."],+ json!({"projectPath": "C:\\\\work\\\\Board.PrjPcb", "expect": {"VBUS": ["U1.1", "U10.1"], "GND": ["U1.4", "U10.4"]}})),+ verb_doc("sch_sheet_symbol", "Make a design hierarchical: a sheet symbol on the parent sheet for a child sheet, its entries joined to the parent's nets, and matching ports on the child. A child that does not exist yet is made from Altium's sheet template and added to the project.",+ json!({"sheet": "the parent .SchDoc (required)", "child": "the child .SchDoc (required; a name alone sits beside the parent)", "projectPath": ".PrjPcb: lists the child when it is not listed yet", "name": "the symbol's designator (default the child's name)", "x": "mil, the symbol's top-left corner (default 2000)", "y": "mil (default 8000)", "widthMil": "default 1400", "entries": "[{name, io: input|output|bidirectional|passive, side?: left|right (default inputs left), net?: a parent net to join (a stub and a net label), stubMil?: 300}]", "ports": "bool, default true: put a port on the child for each entry it lacks", "portsAt": "{left, right, top} mil on the child (defaults 1000, 15000, 9500)", "save": "bool, default true"}),+ json!({"childCreated": "bool", "symbol": "{name, file, atMil, sizeMil, entries:[{name, side, io, net, atMil}]}", "ports": "{placed:[{name, io, atMil, stubEndMil}], alreadyThere:[name]}", "steps": "[childCreated | addedToProject]"}), 240,+ "Then connect the child's circuit to its ports (altium_sch_connect kind:'port', or a wire to stubEndMil) and altium_compile {projectPath}: entries and ports join by name.",+ &["sch_connect", "compile", "schematic_data", "build_schematic"],+ &["Altium never places a sheet entry by itself (its position stays 0,0 and it draws nowhere): the bridge puts each one on the symbol's edge, 100 mil apart.", "Ports already on the child (by name) are reported in alreadyThere, not doubled."],+ json!({"sheet": "C:\\work\\Top.SchDoc", "child": "Power.SchDoc", "projectPath": "C:\\work\\Board.PrjPcb", "entries": [{"name": "VIN", "io": "input", "net": "VBUS"}, {"name": "3V3", "io": "output", "net": "3V3"}, {"name": "GND", "io": "passive", "net": "GND"}]})), 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,@@ -851,6 +872,18 @@ fn describe() -> Value { &["build_symbol_modes", "set_params", "read_symbol", "save"], &["A fresh library carries the stray Component_1: altium_cleanup_libs.", "Pin offsets are mils along the chosen side; check shot.image for overlapping pins."], json!({"name": "CH340G", "libraryPath": "C:/lib/Test.SchLib", "pins": "1,GND,left,100;2,TXD,left,200;16,VCC,right,100"})),+ verb_doc("footprint_from_land_pattern", "Build a complete footprint from the land pattern a datasheet prints: pads numbered the standard way (chip, dual SOIC/TSSOP/DFN, SOT, quad QFP/QFN, BGA), an exposed pad, the courtyard, silkscreen kept clear of the copper with a pin-1 dot, a fab outline with a pin-1 chamfer, and an extruded 3D body when the height is given.",+ json!({"libraryPath": ".PcbLib (required unless dryRun; created if missing)", "name": "footprint name (required)", "family": "chip | dual | sot | quad | bga",+ "pins": "dual/quad total", "leftPins": "sot", "rightPins": "sot", "pinsPerSideX": "quad, top/bottom", "pinsPerSideY": "quad, left/right",+ "pitch": "mm", "span": "mm, pad centre to pad centre across the rows (quad: left-right)", "spanY": "quad, top-bottom (default span)",+ "padLength": "mm, along the lead (across the row)", "padWidth": "mm, along the row", "ballPad": "bga pad diameter mm", "rows": "bga", "cols": "bga", "missing": "bga ['B2', ...]",+ "exposedPad": "{w, h, x?, y?, number?}", "body": "{w, l, height?, standoff?} mm - silkscreen, fab, courtyard, 3D",+ "padShape": "roundrect (default) | rect | round | oval", "polarized": "chip: add the pin-1 dot", "courtyard": "margin mm, default 0.25", "silkWidth": "default 0.15", "silkClearance": "default 0.2", "description": "optional", "dryRun": "bool - return the pad table and spec only"}),+ json!({"summary": "{pads, courtyardMm, silkLines, padTable:[[name, x, y, w, h]]}", "spec": "the staged spec path", "body3d": "{success}", "shot": "{image}"}), 300,+ "dryRun first and compare padTable with the datasheet drawing; then build, check shot.image, altium_save the library and link it with altium_set_models.",+ &["build_footprint", "create_footprint", "add_body", "set_models", "save", "show_footprint"],+ &["The numbers are the datasheet's RECOMMENDED LAND PATTERN, not the package outline: pad size and the pad-centre span.", "Pin 1 is top-left (Y up), counter-clockwise, the IPC convention."],+ json!({"libraryPath": "C:\\\\lib\\\\Parts.PcbLib", "name": "SOIC-8_3.9x4.9mm_P1.27mm", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}})), verb_doc("build_footprint", "Build a complete footprint (pads, silkscreen, courtyard, arcs) in a PcbLib from a kicad2altium spec file on this machine, and return a proof frame.", json!({"libraryPath": "path to a .PcbLib (required; created if missing)", "spec": "Windows path to the spec (tools/kicad2altium.py output; mm, Y up) (required)", "timeout": "seconds, default 240"}), json!({"data": "{footprint, description, library, libraryState, pads, throughHolePads, drawingPrimitives}", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"}), 240,@@ -1553,6 +1586,10 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "replicate_layout" => crate::design::replicate_layout(args), "outjob_info" => crate::inspect::outjob_info(args), "board_features" => crate::features::board_features(args),+ "sch_sheet_symbol" => crate::hier::sheet_symbol(args),+ "netlist" => crate::hier::netlist(args),+ "silkscreen_prep" => crate::route::silkscreen_prep(args),+ "footprint_from_land_pattern" => crate::footprint::from_land_pattern(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@@ -152,6 +152,10 @@ def matrix(w): M["normalize_bodies"] = dict(phase=2, **anyo("no bodies on the test footprints", libraryPath=PL)) M["create_footprint"] = dict(phase=2, **ok(libraryPath=PL, name="BAT_FP", description="battery", pads="1,-40,0,24,24,rect,0;2,40,0,24,24,rect,0"))+ # 0.2.67: a QFN-16 from its land pattern, exposed pad and 3D body included: 17 pads, and the body lands+ M["footprint_from_land_pattern"] = dict(phase=2, timeout=320, **ok(libraryPath=PL, name="BAT_QFN16", family="quad", pins=16, pitch=0.5, span=3.0,+ padLength=0.8, padWidth=0.25, exposedPad={"w": 1.7, "h": 1.7}, body={"w": 3, "l": 3, "height": 0.9}),+ check=lambda d: (d.get("summary") or {}).get("pads") == 17 and (d.get("body3d") or {}).get("success") is not False) M["copy_footprint"] = dict(phase=2, **ok(libraryPath=PL, **{"from": "BAT_FP", "to": "BAT_FP_COPY"})) M["rename_footprint"] = dict(phase=2, **ok(libraryPath=PL, **{"from": "BAT_FP_COPY", "to": "BAT_FP_RENAMED"})) M["remove_footprint"] = dict(phase=2, **ok(libraryPath=PL, footprintName="BAT_FP_RENAMED"))@@ -230,6 +234,10 @@ def matrix(w): M["set_outline"] = dict(phase=3.5, **ok(outline={"rect": [0, 0, 40, 20], "cornerRadius": 1})) M["silk_state"] = dict(phase=3.5, **ok()) M["apply_silkscreen"] = dict(phase=3.5, **ok(texts=[{"text": "BATTERY", "x": 20, "y": 18, "size": 1}]))+ # 0.2.67: Altium's Silkscreen Preparation on the lane board, its dialog answered in the background; no silk may be+ # left on a mask opening+ M["silkscreen_prep"] = dict(phase=3.5, timeout=600, **ok(filePath=LB, check=True),+ check=lambda d: (d.get("after") or {}).get("silkToSolderMask") == 0 and not d.get("dialogsLeft")) M["routing_drc"] = dict(phase=3.5, timeout=300, **ok()) # 0.2.60: board design data on the lane board. The class and the pair are board-only, so the change order # update_pcb runs further down must leave them alone (its removal rows are unticked by default).@@ -288,6 +296,14 @@ def matrix(w): # 0.2.60: a variant written into the project file and read back from Altium M["variants"] = dict(phase=3.5, timeout=400, **ok(projectPath=LP, name="BatteryLite", notFitted=["U2"], timeout=380), check=lambda d: any(v.get("name") == "BatteryLite" for v in (d.get("altium") or {}).get("variants") or []))+ # 0.2.67: hierarchy last on the lane project (a child sheet joins it): a sheet symbol whose entry joins N1, a new+ # child sheet with its port; then the compiled netlist across both sheets+ M["sch_sheet_symbol"] = dict(phase=3.5, timeout=300, **ok(sheet=LS, child="LaneSub.SchDoc", projectPath=LP, x=10000, y=6000,+ entries=[{"name": "N1", "io": "input", "net": "N1"}]),+ check=lambda d: d.get("childCreated") is True and len((d.get("symbol") or {}).get("entries") or []) == 1+ and len((d.get("ports") or {}).get("placed") or []) == 1)+ M["netlist"] = dict(phase=3.5, timeout=320, **ok(projectPath=LP),+ check=lambda d: {"U1.8", "U3.1"} <= set((d.get("nets") or {}).get("N1") or []) and not d.get("compileErrors")) # ---- phase 4: outputs -------------------------------------------------------------------- M["export_step"] = dict(phase=4, timeout=300, **anyo("STEP of the battery board", filePath=PD, output=D + "/battery.step"))
package.json+20−2@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.2.66",+ "version": "0.2.67", "type": "app", "description": "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.", "tags": [@@ -129,7 +129,25 @@ "altium jlcpcb bom", "altium step export", "altium multi-part symbol",- "altium no erc"+ "altium no erc",+ "altium hierarchical schematic",+ "altium sheet symbol",+ "altium sheet entry",+ "add a sheet in altium",+ "altium netlist",+ "check my altium netlist",+ "altium port",+ "make an altium footprint from a datasheet",+ "altium footprint from land pattern",+ "altium qfn footprint",+ "altium soic footprint",+ "altium bga footprint",+ "altium footprint wizard",+ "altium ipc footprint",+ "altium silkscreen over pads",+ "altium silkscreen preparation",+ "clip silkscreen altium",+ "altium silk to solder mask" ], "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.", "sample_prompts": [
page.json+20−2@@ -4,7 +4,7 @@ "type": "app", "title": "Altium Bridge: AI for Altium Designer", "brief": "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.",- "version": "0.2.66",+ "version": "0.2.67", "hero": { "type": "image", "path": "screenshots/hero.png"@@ -121,7 +121,25 @@ "altium jlcpcb bom", "altium step export", "altium multi-part symbol",- "altium no erc"+ "altium no erc",+ "altium hierarchical schematic",+ "altium sheet symbol",+ "altium sheet entry",+ "add a sheet in altium",+ "altium netlist",+ "check my altium netlist",+ "altium port",+ "make an altium footprint from a datasheet",+ "altium footprint from land pattern",+ "altium qfn footprint",+ "altium soic footprint",+ "altium bga footprint",+ "altium footprint wizard",+ "altium ipc footprint",+ "altium silkscreen over pads",+ "altium silkscreen preparation",+ "clip silkscreen altium",+ "altium silk to solder mask" ], "discovery_pitch": "An AI that runs Altium Designer in the user's own session: libraries, schematics, Update PCB, stack-up, routing, DRC, Gerbers and BOMs.", "metadata": {},
skills/altium-libraries/SKILL.md+17@@ -172,3 +172,20 @@ altium_create_footprint '{"libraryPath":"C:\\lib\\Parts.PcbLib","footprints":[{" searched; the Altium 365 workspace is searched with `altium_workspace_search`. - `symbols` / `footprints` lists make each item in turn and report each one. +## A footprint from the datasheet's land pattern (0.2.67)++```bash+altium_footprint_from_land_pattern '{"name":"SOIC-8_3.9x4.9mm_P1.27mm","family":"dual","pins":8,"pitch":1.27,"span":5.4,"padLength":1.55,"padWidth":0.6,"body":{"w":3.9,"l":4.9,"height":1.75},"dryRun":true}'+altium_footprint_from_land_pattern '{"libraryPath":"C:\\lib\\Parts.PcbLib","name":"QFN-16_3x3mm_P0.5mm","family":"quad","pins":16,"pitch":0.5,"span":3.0,"padLength":0.8,"padWidth":0.25,"exposedPad":{"w":1.7,"h":1.7},"body":{"w":3,"l":3,"height":0.9}}'+```++- Give the numbers the datasheet's RECOMMENDED LAND PATTERN prints (pad size, pitch, pad-centre span across the+ rows), not the package outline. Families: `chip` (2 pads), `dual` (SOIC, TSSOP, DFN), `sot` (`leftPins` /+ `rightPins`, SOT-23 = 2 + 1), `quad` (QFP, QFN; `pinsPerSideX/Y` for rectangular ones), `bga` (rows, cols,+ `ballPad`, `missing`), plus `exposedPad` and `body`.+- Pin 1 is top-left (Y up) and numbering runs counter-clockwise, the IPC convention; BGA rows skip I, O, Q, S, X, Z.+- The bridge draws the courtyard (pads and body + 0.25 mm), silkscreen on the body outline with every stretch within+ 0.2 mm of copper removed and a pin-1 dot, a fab outline (Mechanical 13) with a pin-1 chamfer, and with+ `body.height` an extruded 3D body.+- `dryRun:true` returns the pad table (name, x, y, w, h) and the spec without Altium: compare it with the datasheet+ drawing first.
skills/altium-outputs/SKILL.md+16@@ -67,3 +67,19 @@ Altium need not be running. Run one container with `altium_run_outjob {outjobPat with Admin → Developer (Pro/Enterprise) — the Develop trial has no such page. - OutJob→PDF was blocked in the predecessor bridge; on this one run it through `altium_run_outjob` and verify the files[] like any medium.++## Silkscreen off the copper: Silkscreen Preparation (0.2.67)++```bash+altium_silkscreen_prep '{"filePath":"C:\\work\\Board.PcbDoc"}'+```++- Runs Altium's own Tools > Silkscreen Preparation (`PCB:ClipSilkScreen`) and answers its dialog in the background:+ Use Design Rules on (the board's silk-to-solder-mask clearance), Move Text on (designators move off pads where they+ fit instead of being clipped away), silk outside the board deleted, locked parts left alone.+- It counts the silkscreen DRC hits before and after (`before` / `after`: silkToSolderMask, silkToSilk,+ silkToBoardRegion, maskSliver). Measured on the battery board: 5 silk-to-mask hits (a text across a pad column, a+ designator on a pad) went to 0 in 24 s, both texts moved clear and readable.+- `altium_export_manufacturing` runs it first by default (`silkPrep:false` skips it). Fabs trim silk off pads+ anyway, so silk hits are not a manufacturing failure, but a delivered board and its renders should not show ink on+ copper.
skills/altium-schematic-edit/SKILL.md+24−3@@ -1,9 +1,9 @@ --- name: altium-schematic-edit description: >-- 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.+ Edit an Altium schematic and push it to the board: place library or Altium 365 parts, swap, wire,+ annotate, hierarchical sheets, the compiled netlist, a previewed ECO, PDF. Use when asked to add a part+ in altium, add a sheet, check the netlist, or update pcb. --- Parent skill: **altium-bridge**@@ -90,6 +90,27 @@ altium_sheet_params '{"projectPath":"C:\\proj\\board.PrjPcb","designators":["R5" Wires do not follow a moved or rotated part: reconnect with `sch_connect`. Every part of a multi-part component with that designator changes together. +## Hierarchical sheets and the netlist (0.2.67)++```bash+altium_sch_sheet_symbol '{"sheet":"C:\\work\\Top.SchDoc","child":"Power.SchDoc","projectPath":"C:\\work\\Board.PrjPcb","x":6000,"y":7000,+ "entries":[{"name":"VIN","io":"input","net":"VBUS"},{"name":"VOUT","io":"output","net":"3V3"},{"name":"GND","io":"passive","net":"GND"}]}'+altium_sch_connect '{"sheet":"C:\\work\\Power.SchDoc","connections":[{"designator":"U10","pin":"1","net":"VIN","kind":"port","io":"input"}]}'+altium_netlist '{"projectPath":"C:\\work\\Board.PrjPcb","expect":{"VBUS":["U1.1","U10.1"]}}'+```++- `sch_sheet_symbol` draws a sheet symbol on the parent for a child sheet: one entry per signal (inputs on the left,+ the rest on the right, 100 mil apart), each joined to a parent net by a stub and a net label when `net` is given.+ A child that does not exist is made from Altium's ANSI B template and added to the project; by default+ (`ports:true`) the child gets a port per entry with a stub, inputs down the left margin and outputs down the right.+ Pass `ports:false` when you will connect the child's pins with `sch_connect kind:"port"` instead.+- `sch_connect {kind:"port"}` puts a port named after the net at a pin's stub end (`io` input/output/bidirectional).+- `netlist` compiles the project and returns every net with its pins across all sheets, the hierarchy resolved;+ `expect` compares pin by pin (`mismatches`). Measured (Develop): VBUS = U1.1 on Top + U10.1 on Power through the+ entry and port, GND across three parts on two sheets, no mismatches, no compile errors.+- Altium never places a sheet entry by itself: an entry added through the API stays at 0,0 and draws nowhere. The+ bridge sets each one's position on the edge.+ ## Push to the board: altium_update_pcb Altium's Design > Update PCB Document (from the board: Import Changes), the engineering change order.
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.66+0.2.67
src/agent/AdomAgent.pas.tmpl+175@@ -7131,6 +7131,160 @@ End; at the outer end of pins of placed parts on the current sheet. payload lines: designator|pin (number or name)|net|kind|stubMil One undo step. Answers per line: ok, or why not. }+{ sheet_symbol: a sheet symbol on the CURRENT sheet for a child sheet. Params x, y (mil, its top-left corner), w, h+ (mil), name, file. Payload: one entry per line, name|side(left/right)|io(input/output/bidirectional/passive)|net|+ stubMil. Altium never places an entry by itself (its Location stays 0,0 and it draws nowhere, measured on Develop+ 2026-10-23): each one is put on its edge here, 100 mil apart from 100 mil down. A net gives the entry a wire stub out+ of the symbol and a net label on it. }+Function CmdSheetSymbol(Lines : TStringList; Payload : String) : String;+Var+ Doc : ISch_Document;+ SS : ISch_SheetSymbol;+ E : ISch_SheetEntry;+ W : ISch_Wire;+ NL : ISch_NetLabel;+ Ops : TStringList;+ Op, Nm, Side, IOS, Net, S : String;+ I, X, Y, SW, SH, NL0, NR, Dist, EX, EY, TX, Stub : Integer;+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') + '}'; Exit; End;+ X := MilsToCoord(AdomInt(Param(Lines, 'x'))); Y := MilsToCoord(AdomInt(Param(Lines, 'y')));+ SW := MilsToCoord(AdomInt(Param(Lines, 'w'))); SH := MilsToCoord(AdomInt(Param(Lines, 'h')));+ Ops := TStringList.Create; Ops.Text := Payload;+ SchServer.ProcessControl.PreProcess(Doc, '');+ SS := SchServer.SchObjectFactory(eSheetSymbol, eCreate_Default);+ SS.Location := Point(X, Y);+ SS.XSize := SW; SS.YSize := SH;+ SS.SheetName.Text := Param(Lines, 'name');+ SS.SheetName.Location := Point(X, Y + MilsToCoord(100));+ SS.SheetFileName.Text := Param(Lines, 'file');+ SS.SheetFileName.Location := Point(X, Y - SH - MilsToCoord(150));+ S := ''; NL0 := 0; NR := 0;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Ops[I];+ If Trim(Op) = '' Then Continue;+ Nm := FieldP(Op, 1); Side := LowerCase(FieldP(Op, 2)); IOS := LowerCase(FieldP(Op, 3)); Net := FieldP(Op, 4);+ Stub := AdomInt(FieldP(Op, 5)); If Stub <= 0 Then Stub := 300;+ E := SchServer.SchObjectFactory(eSheetEntry, eCreate_Default);+ E.Name := Nm;+ If IOS = 'input' Then E.IOType := ePortInput+ Else If IOS = 'output' Then E.IOType := ePortOutput+ Else If (IOS = 'bidirectional') Or (IOS = 'bidir') Then E.IOType := ePortBidirectional+ Else E.IOType := ePortUnspecified;+ If Side = 'right' Then Begin NR := NR + 1; Dist := MilsToCoord(100 * NR); E.Side := eRightSide; EX := X + SW; End+ Else Begin NL0 := NL0 + 1; Dist := MilsToCoord(100 * NL0); E.Side := eLeftSide; EX := X; End;+ EY := Y - Dist;+ E.DistanceFromTop := Dist;+ E.Location := Point(EX, EY);+ SS.AddSchObject(E);+ If Net <> '' Then+ Begin+ If Side = 'right' Then TX := EX + MilsToCoord(Stub) Else TX := EX - MilsToCoord(Stub);+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(EX, EY);+ W.InsertVertex := 1; W.SetState_Vertex(1, Point(EX, EY));+ W.InsertVertex := 2; W.SetState_Vertex(2, Point(TX, EY));+ W.LineWidth := eSmall;+ Doc.RegisterSchObjectInContainer(W);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ NL := SchServer.SchObjectFactory(eNetLabel, eCreate_Default);+ NL.Text := Net;+ NL.Location := Point(TX, EY);+ If Side <> 'right' Then NL.Justification := eJustify_BottomRight;+ Doc.RegisterSchObjectInContainer(NL);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, NL.I_ObjectAddress);+ End;+ If S <> '' Then S := S + ',';+ S := S + '{' + JStr('name', Nm) + ',' + JStr('side', Side) + ',' + JStr('io', IOS) + ',' + JStr('net', Net) + ',' ++ JRaw('atMil', '[' + IntToStr(Round(CoordToMils(EX))) + ',' + IntToStr(Round(CoordToMils(EY))) + ']') + '}';+ End;+ Doc.RegisterSchObjectInContainer(SS);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, SS.I_ObjectAddress);+ SchServer.ProcessControl.PostProcess(Doc, '');+ Doc.GraphicallyInvalidate;+ Ops.Free;+ Result := '{' + JRaw('ok','true') + ',' + JStr('name', SS.SheetName.Text) + ',' + JStr('file', SS.SheetFileName.Text) + ',' ++ JRaw('entries', '[' + S + ']') + '}';+End;++{ sheet_ports: ports on the CURRENT sheet for the names it lacks. Payload name|io per line. Inputs (and unspecified)+ stack down the left margin, outputs down the right, 200 mil apart, each with a 300 mil wire stub ready to join;+ names that already have a port are reported, not doubled. Params: left, right, top (mil). }+Function CmdSheetPorts(Lines : TStringList; Payload : String) : String;+Var+ Doc : ISch_Document;+ It : ISch_Iterator;+ Prt, Old : ISch_Port;+ W : ISch_Wire;+ Ops, Have : TStringList;+ Op, Nm, IOS, S, Skip : String;+ I, LX, RX, TY, NL0, NR, PX, PY, WX : Integer;+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') + '}'; Exit; End;+ Have := TStringList.Create;+ It := Doc.SchIterator_Create; It.AddFilter_ObjectSet(MkSet(ePort));+ Old := It.FirstSchObject;+ While Old <> Nil Do Begin Have.Add(UpperCase(Old.Name)); Old := It.NextSchObject; End;+ Doc.SchIterator_Destroy(It);+ LX := MilsToCoord(AdomInt(Param(Lines, 'left'))); RX := MilsToCoord(AdomInt(Param(Lines, 'right'))); TY := MilsToCoord(AdomInt(Param(Lines, 'top')));+ Ops := TStringList.Create; Ops.Text := Payload;+ SchServer.ProcessControl.PreProcess(Doc, '');+ S := ''; Skip := ''; NL0 := 0; NR := 0;+ For I := 0 To Ops.Count - 1 Do+ Begin+ Op := Ops[I];+ If Trim(Op) = '' Then Continue;+ Nm := FieldP(Op, 1); IOS := LowerCase(FieldP(Op, 2));+ If Have.IndexOf(UpperCase(Nm)) >= 0 Then+ Begin+ If Skip <> '' Then Skip := Skip + ',';+ Skip := Skip + '"' + JsonEsc(Nm) + '"';+ Continue;+ End;+ Prt := SchServer.SchObjectFactory(ePort, eCreate_Default);+ Prt.Name := Nm;+ Prt.Width := MilsToCoord(600);+ If IOS = 'input' Then Prt.IOType := ePortInput+ Else If IOS = 'output' Then Prt.IOType := ePortOutput+ Else If (IOS = 'bidirectional') Or (IOS = 'bidir') Then Prt.IOType := ePortBidirectional+ Else Prt.IOType := ePortUnspecified;+ If IOS = 'output' Then+ Begin+ PY := TY - MilsToCoord(200 * NR); NR := NR + 1;+ PX := RX - Prt.Width; Prt.Style := ePortRight; WX := PX - MilsToCoord(300);+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(WX, PY);+ W.InsertVertex := 1; W.SetState_Vertex(1, Point(WX, PY));+ W.InsertVertex := 2; W.SetState_Vertex(2, Point(PX, PY));+ End+ Else+ Begin+ PY := TY - MilsToCoord(200 * NL0); NL0 := NL0 + 1;+ PX := LX; Prt.Style := ePortRight; WX := PX + Prt.Width + MilsToCoord(300);+ W := SchServer.SchObjectFactory(eWire, eCreate_Default);+ W.Location := Point(PX + Prt.Width, PY);+ W.InsertVertex := 1; W.SetState_Vertex(1, Point(PX + Prt.Width, PY));+ W.InsertVertex := 2; W.SetState_Vertex(2, Point(WX, PY));+ End;+ Prt.Location := Point(PX, PY);+ W.LineWidth := eSmall;+ Doc.RegisterSchObjectInContainer(Prt);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Prt.I_ObjectAddress);+ Doc.RegisterSchObjectInContainer(W);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, W.I_ObjectAddress);+ If S <> '' Then S := S + ',';+ S := S + '{' + JStr('name', Nm) + ',' + JStr('io', IOS) + ',' + JRaw('atMil', '[' + IntToStr(Round(CoordToMils(PX))) + ',' + IntToStr(Round(CoordToMils(PY))) + ']') ++ ',' + JRaw('stubEndMil', '[' + IntToStr(Round(CoordToMils(WX))) + ',' + IntToStr(Round(CoordToMils(PY))) + ']') + '}';+ End;+ SchServer.ProcessControl.PostProcess(Doc, '');+ Doc.GraphicallyInvalidate;+ Ops.Free; Have.Free;+ Result := '{' + JRaw('ok','true') + ',' + JRaw('placed', '[' + S + ']') + ',' + JRaw('alreadyThere', '[' + Skip + ']') + '}';+End;+ Function CmdSchConnect(Lines : TStringList; Payload : String) : String; Var Doc : ISch_Document;@@ -7143,6 +7297,8 @@ Var NL : ISch_NetLabel; PO : ISch_PowerObject; NE : ISch_NoERC;+ Prt : ISch_Port;+ IOS : String; Op, Des, PinKey, Net, Kind, Res, S : String; I, J, Dir, HX, HY, EX, EY, Stub, NOk : Integer; Begin@@ -7231,6 +7387,23 @@ Begin Doc.RegisterSchObjectInContainer(PO); SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, PO.I_ObjectAddress); End+ Else If Kind = 'port' Then+ Begin+ { a port named after the net at the stub's end: the sheet's link to its sheet symbol's entry. Field 6+ is the direction (input/output/bidirectional; default unspecified). It points away from the part. }+ Prt := SchServer.SchObjectFactory(ePort, eCreate_Default);+ Prt.Name := Net;+ Prt.Width := MilsToCoord(600);+ IOS := LowerCase(FieldP(Op, 6));+ If IOS = 'input' Then Prt.IOType := ePortInput+ Else If IOS = 'output' Then Prt.IOType := ePortOutput+ Else If (IOS = 'bidirectional') Or (IOS = 'bidir') Then Prt.IOType := ePortBidirectional+ Else Prt.IOType := ePortUnspecified;+ If Dir = 2 Then Begin Prt.Style := ePortLeft; Prt.Location := Point(EX - Prt.Width, EY); End+ Else Begin Prt.Style := ePortRight; Prt.Location := Point(EX, EY); End;+ Doc.RegisterSchObjectInContainer(Prt);+ SchServer.RobotManager.SendMessage(Doc.I_ObjectAddress, c_BroadCast, SCHM_PrimitiveRegistration, Prt.I_ObjectAddress);+ End Else If Kind = 'label' Then Begin NL := SchServer.SchObjectFactory(eNetLabel, eCreate_Default);@@ -8334,6 +8507,8 @@ Begin Else If Cmd = 'schematic_data' Then Result := CmdSchematicData(Lines) Else If Cmd = 'sheet_params' Then Result := CmdSheetParams(Lines) Else If Cmd = 'sch_connect' Then Result := CmdSchConnect(Lines, Payload)+ Else If Cmd = 'sheet_symbol' Then Result := CmdSheetSymbol(Lines, Payload)+ Else If Cmd = 'sheet_ports' Then Result := CmdSheetPorts(Lines, Payload) Else If Cmd = 'sch_edit' Then Result := CmdSchEdit(Lines, Payload) Else If Cmd = 'sch_place_part' Then Result := CmdSchPlacePart(Lines) Else If Cmd = 'doc_params' Then Result := CmdDocParams(Lines)
src/bridge.json+9−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.2.66",+ "version": "0.2.67", "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",@@ -87,6 +87,10 @@ "altium_extension_ping", "altium_extract_intlib", "altium_board_features",+ "altium_sch_sheet_symbol",+ "altium_netlist",+ "altium_footprint_from_land_pattern",+ "altium_silkscreen_prep", "altium_find_space", "altium_find_symbol", "altium_footprint_primitives",@@ -315,6 +319,10 @@ "workspace_place": 300, "find_space": 180, "board_features": 180,+ "sch_sheet_symbol": 240,+ "netlist": 300,+ "footprint_from_land_pattern": 300,+ "silkscreen_prep": 600, "sch_swap_part": 180, "selection": 60, "check_orientation": 120,