app
Altium - the Altium Bridge
Public Made by Adomby adom
Adom Bridge for Altium Designer: a resident DelphiScript agent gives the AI eval, the 1,100-command process launcher, all 8 EDA surfaces with screenshots, compile/ERC, DRC, OutJob outputs, native SchLib/PcbLib authoring with STEP 3D bodies, and dialog handling by PID.
← Commit history
v0.1.16: rename dump_board -> read_board; read_*/write_* naming convention recorded
14 files changed
+1125−42
CHANGELOG.md+4adom-bridge-altium-manifest.json+4−4adom-bridge-altium-v0.1.16.zipbridge-rs/README.md+20−12bridge-rs/fixtures/README.md+2−2bridge-rs/fixtures/read_board.json+817bridge-rs/src/board.rs+252bridge-rs/src/main.rs+1−1bridge-rs/src/verbs.rs+9−9bridge.json+1−1package.json+1−1skills/altium-bridge-dev/SKILL.md+9−7src/BRIDGE_VERSION+1−1src/agent/AdomAgent.pas.tmpl+4−4CHANGELOG.md+4@@ -1,5 +1,9 @@ # Changelog +## 0.1.16 — 2026-09-01+- **Renamed `dump_board` → `read_board`** (agent command and Rust verb), before anything depended on it. John, on the 0.1.15 naming: *"that's not a great verb name ... we should probably pick a better verb thats more like a read* or write*"*. He is right — "dump" names the implementation rather than the caller's intent, and it has no counterpart, so it breaks the symmetry the moment writes arrive. The convention is now recorded in the dev skill and the Rust README: **`read_*` to get data out, `write_*` / `apply_*` to put it back.** Rust module `dump.rs` → `board.rs`, `BoardDump` → `BoardData`, fixture `read_board.json`.+- For the record, since it came up: the name was mine, not inherited — no verb names were copied from any third-party Altium MCP server.+ ## 0.1.15 — 2026-09-01 - Agent gains `dump_board`: components + nets + layers + copper + rules in ONE round trip, composed from the existing readers so it adds no new Altium API surface (and therefore cannot introduce an engine-halting identifier). This is the primitive the Rust bridge shapes its views from; the Python verbs are unchanged. - Rust bridge (`bridge-rs/`): **cold bootstrap through the `-R` lane proven live — 1.59 s, zero dialogs**, which was the last unverified path. Dump-derived views land with it (`board_summary` 232 ms, `rules_by_kind` 152 ms), plus `fixtures/dump_board.json` — a recorded live dump that lets `cargo test` exercise all the shaping logic with no Altium, no Windows and no agent. 8 tests; they already caught a dispatch bug and one wrong assumption about the Fanout rule group.
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.15",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.15/adom-bridge-altium-v0.1.15.zip",- "sha256": "2b3ef85f0c1cdbfed1bbd9b7b01e794520da1c61e6dd0c9e81447d22de8ff69a",- "size": 105130,+ "version": "0.1.16",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.16/adom-bridge-altium-v0.1.16.zip",+ "sha256": "894e07d34e0182a0c730fd79f1d9b74b1836d1514f433a722d442e3fd3447deb",+ "size": 105131, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.16.zipadded⋯ 1 unchanged line ⋯
bridge-rs/README.md+20−12@@ -7,12 +7,12 @@ on record (John, 2026-08-31): screenshots, UIA, shell, taskbar — instead of re-implementing Win32. No `windows-rs`, no ctypes. 2. **The DelphiScript agent shrinks to generic primitives; the bridge becomes the brain.** Only ~213 of the agent's 3,675 lines touch Altium's API; the rest (JSON assembly, parsing, tallies,- filters) moves here where it is typed, testable against recorded dumps, and never needs an+ filters) moves here where it is typed, testable against recorded board reads, and never needs an Altium restart to change. 3. **No standalone MCP server.** External agents reach Altium through `adom-bridge-cli` on PATH (the Adom Anywhere lane), same as this container does. -## Status — 2026-09-01: cold bootstrap proven, dump primitives landed+## Status — 2026-09-01: cold bootstrap proven, board-read primitive landed **26 of the Python bridge's 95 verbs (27%).** The Python bridge (`../src`, 0.1.14) stays the shipped runtime until Rust covers the paths users actually hit.@@ -24,14 +24,14 @@ Proven live on the box this session: `shell_execute` (412 — CLI-only, with pull_file/send_files/hd_send_files), so the bridge owns its child processes directly; and `Command::arg()` re-quotes an argument containing a space, which Altium cannot parse — `raw_arg` is required (the Python bridge hit the identical trap in August).-* **Dump primitives.** `dump_board` is ONE agent round trip for components + nets + layers + copper+* **Board reads.** `read_board` is ONE agent round trip for components + nets + layers + copper + rules, composed in DelphiScript from the EXISTING readers (no new Altium API surface, so it- cannot introduce an engine-halting identifier). `src/dump.rs` derives every view from it:- `board_summary` 232 ms, `rules_by_kind` 152 ms, raw dump 157 ms.-* **The shaping is now tested with no Altium anywhere.** `fixtures/dump_board.json` is a recorded- live dump; `cargo test` runs 8 tests against it on any machine. They have already earned their+ cannot introduce an engine-halting identifier). `src/board.rs` derives every view from it:+ `board_summary` 232 ms, `rules_by_kind` 152 ms, raw read 157 ms.+* **The shaping is now tested with no Altium anywhere.** `fixtures/read_board.json` is a recorded+ live board read; `cargo test` runs 8 tests against it on any machine. They have already earned their keep: one caught a dispatch bug (an inner `match` on the un-stripped verb name, so- `altium_board_summary` silently returned the raw dump), and one caught a wrong assumption of mine+ `altium_board_summary` silently returned the raw read), and one caught a wrong assumption of mine about the Fanout rule group. * **A real view the agent could not give cheaply:** Altium emits `TRuleKind` as a bare ordinal. `rule_kind_name()` maps 31 ordinals to names, read off a live default rule set (each default rule@@ -104,6 +104,14 @@ curl -s -X POST http://127.0.0.1:8991/command -H 'content-type: application/json Both bridges can read the same resident agent (the inbox is serialized by the agent, one request at a time) — but only ONE session should drive it at a time; see the dev skill's one-driver rule. +## Naming++Verbs say what the caller wants, in the caller's words: **`read_*` to get data out, `write_*` /+`apply_*` to put it back**. The board-wide read is `read_board`, not `dump_board` — "dump" describes+the implementation (a blob out of memory) rather than the intent, and it has no counterpart, so it+breaks the symmetry the moment writes arrive. (John, 2026-09-01, on the first draft: *"that's not a+great verb name ... we should probably pick a better verb thats more like a read* or write*"*.)+ ## Layout | File | Role |@@ -117,9 +125,9 @@ at a time) — but only ONE session should drive it at a time; see the dev skill ## Next 1. Port lifecycle (launch/close, bootstrap, recover with the busy-notice reaper via ab).-2. Introduce the dump primitives (`dump_components`, `dump_pads`, `dump_primitives`, `dump_nets`,- `dump_rules`, `dump_layers`, `dump_sch_components`, `dump_sch_pins`, `apply`) and re-express the- verbs as views; record fixtures from Bluetooth Sentinel for unit tests.+2. Extend the read primitives: give `read_board` the stack-in-use flag it still lacks, plus a+ `read_schematic` companion, and re-express the remaining introspection verbs as views over them.+ Writes get the mirror-image naming (`write_*` / `apply_*`). 3. Surfaces + frames (all via ab screenshots), checks/outputs, library authoring, boards. 4. Swap: publish the zip (`altium-bridge.exe` + `bridge.json` + `BRIDGE_VERSION` + README) as a release asset, repoint the manifest, and retire `../src`.@@ -136,4 +144,4 @@ Measured on the box 2026-08-31, and the reason the agent shrinks to dumb primiti a modal. A fresh `response_<id>.json` has no readers and writes in **0 ms**. With both applied, in-engine file writes measure 0 ms and the round-trip floor is the poll interval,-not I/O — which is exactly the budget this bridge's dump-primitive design assumes.+not I/O — which is exactly the budget this bridge's read-primitive design assumes.
bridge-rs/fixtures/README.md+2−2@@ -1,12 +1,12 @@ # Fixtures -Recorded `dump_board` payloads. They exist so the bridge's shaping logic (`src/dump.rs`) can be+Recorded `read_board` payloads. They exist so the bridge's shaping logic (`src/board.rs`) can be unit-tested with **no Altium, no Windows, no agent** — `cargo test` on any machine. Re-record from a live box: ```bash-adom-bridge --ai-thread "<t>" altium_dump_board '{}' > bridge-rs/fixtures/dump_board.json+adom-bridge --ai-thread "<t>" altium_read_board '{}' > bridge-rs/fixtures/read_board.json ``` Keep one small board (fast tests) and add a larger one when a view starts depending on scale.
bridge-rs/fixtures/read_board.jsonadded+817@@ -0,0 +1,817 @@+{+ "board": "C:\\Users\\john\\Documents\\adom-altium-lan7800\\LAN7800_test.PcbDoc",+ "components": {+ "components": [+ {+ "bottom": false,+ "comment": "Comment",+ "designator": "Designator1",+ "hMil": 337,+ "layerId": 1,+ "nets": "49=;48=;47=;46=;45=;44=;43=;42=;41=;40=;39=;38=;37=;36=;35=;34=;33=;32=;31=;30=;29=;28=;27=;26=;25=;24=;23=;22=;21=;20=;19=;18=;17=;16=;15=;14=;13=;12=;11=;10=;9=;8=;7=;6=;5=;4=;3=;2=;1=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=;49=",+ "pads": 65,+ "pattern": "",+ "rotation": 0,+ "wMil": 337,+ "xMil": 295,+ "yMil": 295+ }+ ],+ "count": 1,+ "ok": true+ },+ "copper": {+ "layers": [+ {+ "displayed": true,+ "id": 1,+ "kind": "signal",+ "name": "Top Layer"+ },+ {+ "displayed": true,+ "id": 2,+ "kind": "signal",+ "name": "Mid-Layer 1"+ },+ {+ "displayed": true,+ "id": 3,+ "kind": "signal",+ "name": "Mid-Layer 2"+ },+ {+ "displayed": true,+ "id": 4,+ "kind": "signal",+ "name": "Mid-Layer 3"+ },+ {+ "displayed": true,+ "id": 5,+ "kind": "signal",+ "name": "Mid-Layer 4"+ },+ {+ "displayed": true,+ "id": 6,+ "kind": "signal",+ "name": "Mid-Layer 5"+ },+ {+ "displayed": true,+ "id": 7,+ "kind": "signal",+ "name": "Mid-Layer 6"+ },+ {+ "displayed": true,+ "id": 8,+ "kind": "signal",+ "name": "Mid-Layer 7"+ },+ {+ "displayed": true,+ "id": 9,+ "kind": "signal",+ "name": "Mid-Layer 8"+ },+ {+ "displayed": true,+ "id": 10,+ "kind": "signal",+ "name": "Mid-Layer 9"+ },+ {+ "displayed": true,+ "id": 11,+ "kind": "signal",+ "name": "Mid-Layer 10"+ },+ {+ "displayed": true,+ "id": 12,+ "kind": "signal",+ "name": "Mid-Layer 11"+ },+ {+ "displayed": true,+ "id": 13,+ "kind": "signal",+ "name": "Mid-Layer 12"+ },+ {+ "displayed": true,+ "id": 14,+ "kind": "signal",+ "name": "Mid-Layer 13"+ },+ {+ "displayed": true,+ "id": 15,+ "kind": "signal",+ "name": "Mid-Layer 14"+ },+ {+ "displayed": true,+ "id": 16,+ "kind": "signal",+ "name": "Mid-Layer 15"+ },+ {+ "displayed": true,+ "id": 17,+ "kind": "signal",+ "name": "Mid-Layer 16"+ },+ {+ "displayed": true,+ "id": 18,+ "kind": "signal",+ "name": "Mid-Layer 17"+ },+ {+ "displayed": true,+ "id": 19,+ "kind": "signal",+ "name": "Mid-Layer 18"+ },+ {+ "displayed": true,+ "id": 20,+ "kind": "signal",+ "name": "Mid-Layer 19"+ },+ {+ "displayed": true,+ "id": 21,+ "kind": "signal",+ "name": "Mid-Layer 20"+ },+ {+ "displayed": true,+ "id": 22,+ "kind": "signal",+ "name": "Mid-Layer 21"+ },+ {+ "displayed": true,+ "id": 23,+ "kind": "signal",+ "name": "Mid-Layer 22"+ },+ {+ "displayed": true,+ "id": 24,+ "kind": "signal",+ "name": "Mid-Layer 23"+ },+ {+ "displayed": true,+ "id": 25,+ "kind": "signal",+ "name": "Mid-Layer 24"+ },+ {+ "displayed": true,+ "id": 26,+ "kind": "signal",+ "name": "Mid-Layer 25"+ },+ {+ "displayed": true,+ "id": 27,+ "kind": "signal",+ "name": "Mid-Layer 26"+ },+ {+ "displayed": true,+ "id": 28,+ "kind": "signal",+ "name": "Mid-Layer 27"+ },+ {+ "displayed": true,+ "id": 29,+ "kind": "signal",+ "name": "Mid-Layer 28"+ },+ {+ "displayed": true,+ "id": 30,+ "kind": "signal",+ "name": "Mid-Layer 29"+ },+ {+ "displayed": true,+ "id": 31,+ "kind": "signal",+ "name": "Mid-Layer 30"+ },+ {+ "displayed": true,+ "id": 39,+ "kind": "plane",+ "name": "Internal Plane 1"+ },+ {+ "displayed": true,+ "id": 40,+ "kind": "plane",+ "name": "Internal Plane 2"+ },+ {+ "displayed": true,+ "id": 41,+ "kind": "plane",+ "name": "Internal Plane 3"+ },+ {+ "displayed": true,+ "id": 42,+ "kind": "plane",+ "name": "Internal Plane 4"+ },+ {+ "displayed": true,+ "id": 43,+ "kind": "plane",+ "name": "Internal Plane 5"+ },+ {+ "displayed": true,+ "id": 44,+ "kind": "plane",+ "name": "Internal Plane 6"+ },+ {+ "displayed": true,+ "id": 45,+ "kind": "plane",+ "name": "Internal Plane 7"+ },+ {+ "displayed": true,+ "id": 46,+ "kind": "plane",+ "name": "Internal Plane 8"+ },+ {+ "displayed": true,+ "id": 47,+ "kind": "plane",+ "name": "Internal Plane 9"+ },+ {+ "displayed": true,+ "id": 48,+ "kind": "plane",+ "name": "Internal Plane 10"+ },+ {+ "displayed": true,+ "id": 49,+ "kind": "plane",+ "name": "Internal Plane 11"+ },+ {+ "displayed": true,+ "id": 50,+ "kind": "plane",+ "name": "Internal Plane 12"+ },+ {+ "displayed": true,+ "id": 51,+ "kind": "plane",+ "name": "Internal Plane 13"+ },+ {+ "displayed": true,+ "id": 52,+ "kind": "plane",+ "name": "Internal Plane 14"+ },+ {+ "displayed": true,+ "id": 53,+ "kind": "plane",+ "name": "Internal Plane 15"+ },+ {+ "displayed": true,+ "id": 54,+ "kind": "plane",+ "name": "Internal Plane 16"+ },+ {+ "displayed": true,+ "id": 32,+ "kind": "signal",+ "name": "Bottom Layer"+ }+ ],+ "ok": true+ },+ "layers": {+ "layers": [+ {+ "displayed": true,+ "id": 1,+ "kind": "signal",+ "name": "Top Layer"+ },+ {+ "displayed": true,+ "id": 32,+ "kind": "signal",+ "name": "Bottom Layer"+ },+ {+ "displayed": true,+ "id": 33,+ "kind": "overlay",+ "name": "Top Overlay"+ },+ {+ "displayed": true,+ "id": 34,+ "kind": "overlay",+ "name": "Bottom Overlay"+ },+ {+ "displayed": true,+ "id": 35,+ "kind": "paste",+ "name": "Top Paste"+ },+ {+ "displayed": true,+ "id": 36,+ "kind": "paste",+ "name": "Bottom Paste"+ },+ {+ "displayed": true,+ "id": 37,+ "kind": "solder",+ "name": "Top Solder"+ },+ {+ "displayed": true,+ "id": 38,+ "kind": "solder",+ "name": "Bottom Solder"+ },+ {+ "displayed": true,+ "id": 56,+ "kind": "keepout",+ "name": "Keep-Out Layer"+ },+ {+ "displayed": true,+ "id": 74,+ "kind": "multi",+ "name": "Multi-Layer"+ },+ {+ "displayed": true,+ "id": 55,+ "kind": "drill",+ "name": "Drill Guide"+ },+ {+ "displayed": true,+ "id": 73,+ "kind": "drill",+ "name": "Drill Drawing"+ },+ {+ "displayed": true,+ "id": 57,+ "kind": "mechanical",+ "name": "Mechanical 1"+ },+ {+ "displayed": true,+ "id": 58,+ "kind": "mechanical",+ "name": "Mechanical 2"+ },+ {+ "displayed": true,+ "id": 59,+ "kind": "mechanical",+ "name": "Mechanical 3"+ },+ {+ "displayed": true,+ "id": 60,+ "kind": "mechanical",+ "name": "Mechanical 4"+ },+ {+ "displayed": true,+ "id": 61,+ "kind": "mechanical",+ "name": "Mechanical 5"+ },+ {+ "displayed": true,+ "id": 62,+ "kind": "mechanical",+ "name": "Mechanical 6"+ },+ {+ "displayed": true,+ "id": 63,+ "kind": "mechanical",+ "name": "Mechanical 7"+ },+ {+ "displayed": true,+ "id": 64,+ "kind": "mechanical",+ "name": "Mechanical 8"+ },+ {+ "displayed": false,+ "id": 65,+ "kind": "mechanical",+ "name": "Adom.SolderBalls"+ },+ {+ "displayed": true,+ "id": 66,+ "kind": "mechanical",+ "name": "Mechanical 10"+ },+ {+ "displayed": true,+ "id": 67,+ "kind": "mechanical",+ "name": "Mechanical 11"+ },+ {+ "displayed": true,+ "id": 68,+ "kind": "mechanical",+ "name": "Mechanical 12"+ },+ {+ "displayed": true,+ "id": 69,+ "kind": "mechanical",+ "name": "Fab"+ },+ {+ "displayed": true,+ "id": 70,+ "kind": "mechanical",+ "name": "Mechanical 14"+ },+ {+ "displayed": true,+ "id": 71,+ "kind": "mechanical",+ "name": "Courtyard"+ },+ {+ "displayed": true,+ "id": 72,+ "kind": "mechanical",+ "name": "Mechanical 16"+ }+ ],+ "ok": true+ },+ "nets": {+ "count": 0,+ "nets": [],+ "ok": true+ },+ "ok": true,+ "rules": {+ "count": 35,+ "ok": true,+ "rules": [+ {+ "comment": "",+ "enabled": true,+ "kind": 62,+ "name": "UnpouredPolygon",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 56,+ "name": "NetAntennae",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 59,+ "name": "SilkToBoardRegionClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 55,+ "name": "SilkToSilkClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 54,+ "name": "SilkToSolderMaskClearance",+ "priority": 1,+ "scope1": "IsPad",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 53,+ "name": "MinimumSolderMaskSliver",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 52,+ "name": "HoleToHoleClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 51,+ "name": "DiffPairsRouting",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "Fanout_BGA (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_BGA",+ "priority": 1,+ "scope1": "IsBGA",+ "scope2": "All"+ },+ {+ "comment": "Fanout_LCC (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_LCC",+ "priority": 2,+ "scope1": "IsLCC",+ "scope2": "All"+ },+ {+ "comment": "Fanout_SOIC (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_SOIC",+ "priority": 3,+ "scope1": "IsSOIC",+ "scope2": "All"+ },+ {+ "comment": "Fanout_Small (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_Small",+ "priority": 4,+ "scope1": "(CompPinCount < 5)",+ "scope2": "All"+ },+ {+ "comment": "Fanout_Default (Default Rule)",+ "enabled": true,+ "kind": 49,+ "name": "Fanout_Default",+ "priority": 5,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 42,+ "name": "HoleSize",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 48,+ "name": "LayerPairs",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 50,+ "name": "Height",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 24,+ "name": "ComponentClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 57,+ "name": "AssemblyTestpoint",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 58,+ "name": "AssemblyTestPointUsage",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 43,+ "name": "FabricationTestpoint",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 44,+ "name": "FabricationTestPointUsage",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 7,+ "name": "RoutingTopology",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 13,+ "name": "SolderMaskExpansion",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 14,+ "name": "PasteMaskExpansion",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 11,+ "name": "RoutingVias",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 10,+ "name": "RoutingCorners",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 9,+ "name": "RoutingLayers",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 8,+ "name": "RoutingPriority",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 2,+ "name": "Width",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 20,+ "name": "PolygonConnect",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 12,+ "name": "PlaneClearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 6,+ "name": "PlaneConnect",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 0,+ "name": "Clearance",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 16,+ "name": "UnRoutedNet",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ },+ {+ "comment": "",+ "enabled": true,+ "kind": 15,+ "name": "ShortCircuit",+ "priority": 1,+ "scope1": "All",+ "scope2": "All"+ }+ ]+ },+ "sections": "components,nets,layers,copper,rules"+}
bridge-rs/src/board.rsadded+252@@ -0,0 +1,252 @@+//! Reading the board: one primitive, many typed views.+//!+//! The point of the Rust bridge: the DelphiScript agent stops shaping JSON per verb and emits a few+//! one generic read; the bridge derives every view from it. That moves the fragile part (parsing,+//! tallies, filters, sorting) out of a language where a typo halts the engine behind a modal on the+//! user's screen, into one that is typed, unit-tested, and changeable without restarting Altium.+//!+//! `read_board` is ONE agent round trip for components + nets + layers + copper + rules. Five verbs+//! that used to cost five round trips (~50 ms each, plus Altium's own work) now cost one, and the+//! views below are pure functions over the result — which is why they can be tested against a+//! recorded fixture with no Altium anywhere (see `fixtures/`).+use serde_json::{json, Value};++/// A recorded or live `read_board` payload (the agent's `data` object).+#[derive(Clone, Debug)]+pub struct BoardData(pub Value);++impl BoardData {+ pub fn new(data: Value) -> Self {+ BoardData(data)+ }++ /// The agent composes sub-envelopes (`{ok, ...}`) per section; unwrap one by name.+ fn section(&self, name: &str) -> Option<&Value> {+ self.0.get(name).filter(|s| s.get("ok").and_then(|b| b.as_bool()).unwrap_or(false))+ }++ pub fn board_path(&self) -> Option<&str> {+ self.0.get("board").and_then(|v| v.as_str())+ }++ fn array(&self, section: &str, key: &str) -> Vec<Value> {+ self.section(section)+ .and_then(|s| s.get(key))+ .and_then(|a| a.as_array())+ .cloned()+ .unwrap_or_default()+ }++ // ---- views ---------------------------------------------------------------++ pub fn components(&self) -> Vec<Value> {+ self.array("components", "components")+ }++ /// Designators, sorted naturally (U2 before U10 — plain string order gets this wrong, and a+ /// BOM or a review list read in the wrong order is the kind of thing nobody notices).+ pub fn designators(&self) -> Vec<String> {+ let mut d: Vec<String> = self+ .components()+ .iter()+ .filter_map(|c| c.get("designator").or_else(|| c.get("name")).and_then(|v| v.as_str()).map(str::to_string))+ .collect();+ d.sort_by(|a, b| natural_cmp(a, b));+ d+ }++ pub fn nets(&self) -> Vec<Value> {+ self.array("nets", "nets")+ }++ pub fn layers(&self) -> Vec<Value> {+ self.array("layers", "layers")+ }++ /// Copper layers only, in stack order, ignoring the ones Altium reports as not present.+ pub fn copper_layers(&self) -> Vec<Value> {+ self.array("copper", "layers")+ .into_iter()+ .filter(|l| l.get("used").and_then(|b| b.as_bool()).unwrap_or(true))+ .collect()+ }++ pub fn rules(&self) -> Vec<Value> {+ self.array("rules", "rules")+ }++ /// Enabled rules grouped by kind NAME, each group sorted by priority — the shape a design+ /// review wants. The agent emits Altium's numeric `TRuleKind` ordinal; the mapping below was+ /// read off a live default rule set (each default rule is named after its own kind), so it is+ /// derived from data rather than guessed. A user-RENAMED rule keeps its ordinal, so the group+ /// label stays correct even when the rule name no longer matches.+ pub fn rules_by_kind(&self) -> Value {+ let mut kinds: std::collections::BTreeMap<String, Vec<Value>> = Default::default();+ for r in self.rules() {+ if r.get("enabled").and_then(|b| b.as_bool()) == Some(false) {+ continue;+ }+ let label = r+ .get("kind")+ .and_then(|k| k.as_i64())+ .map(rule_kind_name)+ .unwrap_or_else(|| "Unknown".to_string());+ kinds.entry(label).or_default().push(r);+ }+ for v in kinds.values_mut() {+ v.sort_by_key(|r| r.get("priority").and_then(|p| p.as_i64()).unwrap_or(i64::MAX));+ }+ json!(kinds)+ }++ /// One-screen summary: what is on this board.+ pub fn summary(&self) -> Value {+ let comps = self.components();+ let (mut top, mut bottom) = (0usize, 0usize);+ for c in &comps {+ if c.get("bottom").and_then(|b| b.as_bool()).unwrap_or(false) { bottom += 1 } else { top += 1 }+ }+ let rules = self.rules();+ json!({+ "board": self.board_path(),+ "components": comps.len(),+ "componentsTop": top,+ "componentsBottom": bottom,+ "nets": self.nets().len(),+ // NOTE: the agent enumerates every copper layer Altium CAN address (top + 30 mid ++ // 16 planes + bottom), not the stack actually in use — there is no "in stack" flag in+ // the read yet, so this is a capability count, not a layer count. Do not report it to+ // a user as "this board has N layers".+ "copperLayersAddressable": self.copper_layers().len(),+ "rules": rules.len(),+ "rulesEnabled": rules.iter().filter(|r| r.get("enabled").and_then(|b| b.as_bool()) != Some(false)).count(),+ "ruleKinds": self.rules_by_kind().as_object().map(|o| o.len()).unwrap_or(0),+ })+ }+}++/// Altium `TRuleKind` ordinals, read off a live AD26 default rule set (2026-09-01): every default+/// rule is named after its own kind, which makes the mapping observable rather than guessed.+/// Unknown ordinals are rendered as `kind <n>` — never invented.+pub fn rule_kind_name(k: i64) -> String {+ let name = match k {+ 0 => "Clearance", 2 => "Width", 6 => "PlaneConnect", 7 => "RoutingTopology",+ 8 => "RoutingPriority", 9 => "RoutingLayers", 10 => "RoutingCorners", 11 => "RoutingVias",+ 12 => "PlaneClearance", 13 => "SolderMaskExpansion", 14 => "PasteMaskExpansion",+ 15 => "ShortCircuit", 16 => "UnRoutedNet", 20 => "PolygonConnect",+ 24 => "ComponentClearance", 42 => "HoleSize", 43 => "FabricationTestpoint",+ 44 => "FabricationTestPointUsage", 48 => "LayerPairs", 49 => "Fanout", 50 => "Height",+ 51 => "DiffPairsRouting", 52 => "HoleToHoleClearance", 53 => "MinimumSolderMaskSliver",+ 54 => "SilkToSolderMaskClearance", 55 => "SilkToSilkClearance", 56 => "NetAntennae",+ 57 => "AssemblyTestpoint", 58 => "AssemblyTestPointUsage",+ 59 => "SilkToBoardRegionClearance", 62 => "UnpouredPolygon",+ _ => "",+ };+ if name.is_empty() { format!("kind {k}") } else { name.to_string() }+}++/// U2 before U10, R1 before R2 before R10 — compare digit runs numerically.+pub fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering {+ let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());+ loop {+ match (x.peek().copied(), y.peek().copied()) {+ (None, None) => return std::cmp::Ordering::Equal,+ (None, _) => return std::cmp::Ordering::Less,+ (_, None) => return std::cmp::Ordering::Greater,+ (Some(ca), Some(cb)) => {+ if ca.is_ascii_digit() && cb.is_ascii_digit() {+ let na: String = std::iter::from_fn(|| x.next_if(|c| c.is_ascii_digit())).collect();+ let nb: String = std::iter::from_fn(|| y.next_if(|c| c.is_ascii_digit())).collect();+ let (va, vb) = (na.parse::<u64>().unwrap_or(0), nb.parse::<u64>().unwrap_or(0));+ if va != vb {+ return va.cmp(&vb);+ }+ } else {+ if ca != cb {+ return ca.cmp(&cb);+ }+ x.next();+ y.next();+ }+ }+ }+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ fn fixture() -> BoardData {+ // Recorded from a live board; see fixtures/README.md for how to re-record.+ let raw = include_str!("../fixtures/read_board.json");+ let v: Value = serde_json::from_str(raw).expect("fixture parses");+ BoardData::new(v.get("data").cloned().unwrap_or(v))+ }++ #[test]+ fn natural_order_beats_string_order() {+ let mut v = vec!["U10", "U2", "R1", "C10", "C9"];+ v.sort_by(|a, b| natural_cmp(a, b));+ assert_eq!(v, vec!["C9", "C10", "R1", "U2", "U10"]);+ }++ #[test]+ fn views_read_the_recorded_board() {+ let d = fixture();+ assert!(d.board_path().unwrap().ends_with(".PcbDoc"));+ let s = d.summary();+ assert_eq!(s.get("rules").and_then(|v| v.as_u64()), Some(d.rules().len() as u64));+ assert_eq!(+ s.get("components").and_then(|v| v.as_u64()).unwrap(),+ s.get("componentsTop").and_then(|v| v.as_u64()).unwrap()+ + s.get("componentsBottom").and_then(|v| v.as_u64()).unwrap(),+ "every component lands on exactly one side"+ );+ }++ #[test]+ fn every_rule_in_the_fixture_maps_to_a_named_kind() {+ // 35 rules / 31 ordinals on a live AD26 default rule set — if Altium adds a kind we do not+ // know, this fails loudly instead of silently labelling it "Unknown".+ let d = fixture();+ let by = d.rules_by_kind();+ let unnamed: Vec<&String> = by.as_object().unwrap().keys().filter(|k| k.starts_with("kind ")).collect();+ assert!(unnamed.is_empty(), "unmapped TRuleKind ordinals: {unnamed:?}");+ // the five Fanout defaults (BGA/LCC/SOIC/Small/Default) all carry ordinal 49 — the one+ // many-to-one group in the default set, and the reason grouping by ordinal beats by name+ assert_eq!(by.get("Fanout").map(|f| f.as_array().unwrap().len()), Some(5));+ assert!(by.get("Clearance").is_some());+ }++ #[test]+ fn an_unknown_ordinal_is_labelled_not_invented() {+ assert_eq!(rule_kind_name(0), "Clearance");+ assert_eq!(rule_kind_name(999), "kind 999");+ }++ #[test]+ fn rules_by_kind_drops_disabled_and_sorts_by_priority() {+ let d = BoardData::new(json!({+ "rules": {"ok": true, "rules": [+ {"name": "b", "kind": 0, "priority": 2, "enabled": true},+ {"name": "a", "kind": 0, "priority": 1, "enabled": true},+ {"name": "off", "kind": 0, "priority": 0, "enabled": false},+ {"name": "w", "kind": 2, "priority": 1, "enabled": true}+ ]}+ }));+ let by = d.rules_by_kind();+ let cl = by.get("Clearance").unwrap().as_array().unwrap();+ assert_eq!(cl.len(), 2, "disabled rule dropped");+ assert_eq!(cl[0].get("name").unwrap(), "a", "sorted by priority");+ assert!(by.get("Width").is_some());+ }++ #[test]+ fn a_failed_section_yields_an_empty_view_not_a_panic() {+ // the agent reports {ok:false, error} per section when e.g. no board is current+ let d = BoardData::new(json!({"components": {"ok": false, "error": "no current PCB board"}}));+ assert!(d.designators().is_empty());+ assert_eq!(d.summary().get("components").and_then(|v| v.as_u64()), Some(0));+ }+}
bridge-rs/src/main.rs+1−1@@ -4,7 +4,7 @@ mod ab; mod agent; mod dialogs;-mod dump;+mod board; mod health; mod lifecycle; mod verbs;
bridge-rs/src/verbs.rs+9−9@@ -1,7 +1,7 @@ //! Verb dispatch. v0 = the Phase 1 introspection surface as thin views over the existing agent //! commands (zero template changes), plus status/readiness. Every verb returns the envelope the //! Python bridge returns, so the CLI and callers see no difference.-use crate::{agent, dialogs, dump, health, lifecycle};+use crate::{agent, board, dialogs, health, lifecycle}; use serde_json::{json, Value}; use std::time::Duration; @@ -10,7 +10,7 @@ const KNOWN: &[&str] = &[ "list_designators", "component_data", "component_pins", "list_nets", "net_connections", "layers", "copper_layers", "read_rules", "set_layer_visibility", "footprint_primitives", "schematic_data", "agent_start", "agent_stop", "agent_recover", "launch", "close", "check_dialogs", "dismiss_dialogs",- "dump_board", "board_summary", "rules_by_kind",+ "read_board", "board_summary", "rules_by_kind", "describe", "uninstall", ]; @@ -46,7 +46,7 @@ fn describe() -> Value { verb_doc("component_data", "Designator, comment, footprint, layer and XY for components.", json!({"designator": "string - optional filter", "limit": "int (default 500)"}), board.clone(), 90, "Omit 'designator' for the whole board.", &["list_designators"], &[], json!({"designator": "U1"})), verb_doc("component_pins", "Pads of one placed component with net names.", json!({"designator": "string - required"}), board.clone(), 90, "Pad-level view; for tracks/arcs/texts use footprint_primitives.", &["footprint_primitives"], &[], json!({"designator": "U1"})), verb_doc("list_nets", "Every net with its pad count.", json!({}), board.clone(), 90, "Tallied in the agent; sorted by name.", &["net_connections"], &[], json!({})),- verb_doc("net_connections", "Per-net pad list and bounding span.", json!({"net": "string - optional filter", "limit": "int (default 4000)"}), board.clone(), 120, "Span is minx|miny|maxx|maxy in board units.", &["list_nets"], &["A whole-board dump on a dense board can exceed the default timeout - filter by net."], json!({"net": "GND"})),+ verb_doc("net_connections", "Per-net pad list and bounding span.", json!({"net": "string - optional filter", "limit": "int (default 4000)"}), board.clone(), 120, "Span is minx|miny|maxx|maxy in board units.", &["list_nets"], &["A whole-board read on a dense board can exceed the default timeout - filter by net."], json!({"net": "GND"})), verb_doc("layers", "All layers with visibility state.", json!({}), board.clone(), 90, "Includes non-copper layers; copper_layers is the copper-only view.", &["copper_layers", "set_layer_visibility"], &[], json!({})), verb_doc("copper_layers", "Copper layers only (top, mid 1-30, planes 1-16, bottom).", json!({}), board.clone(), 90, "Use the returned layerId with set_layer_visibility.", &["layers"], &[], json!({})), verb_doc("read_rules", "Design rules: kind, priority, scopes, enabled state.", json!({}), board.clone(), 90, "Read-only; rule authoring is not in this surface yet.", &["layers"], &[], json!({})),@@ -215,11 +215,11 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()); dialogs::dismiss(&button, titles.as_deref()) }- // ---- dump primitives: ONE agent round trip, views shaped here ----- "dump_board" | "board_summary" | "rules_by_kind" => {+ // ---- board-read primitive: ONE agent round trip, views shaped here ----+ "read_board" | "board_summary" | "rules_by_kind" => { if let Some(g) = need_licensed() { return g; } let sections = s(args, "sections").unwrap_or_else(|| "components,nets,layers,copper,rules".into());- let r = agent::call("dump_board", &[("sections", sections), ("limit", s(args, "limit").unwrap_or("2000".into()))],+ let r = agent::call("read_board", &[("sections", sections), ("limit", s(args, "limit").unwrap_or("2000".into()))], None, Duration::from_secs(timeout.max(120))); let data = r.get("data").cloned().unwrap_or(Value::Null); if data.get("ok").and_then(|b| b.as_bool()) != Some(true) {@@ -229,9 +229,9 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> // the unshaped agent payload — this is what gets recorded as a test fixture return json!({"success": true, "raw": data, "roundtripMs": r.get("roundtripMs")}); }- let d = dump::BoardDump::new(data);+ let d = board::BoardData::new(data); // NB: match `cmd` (prefix-stripped), not `command` — "altium_board_summary" would- // otherwise fall through to the raw-dump arm.+ // otherwise fall through to the raw-read arm. match cmd { "board_summary" => json!({"success": true, "summary": d.summary(), "roundtripMs": r.get("roundtripMs"), "_hint": "One agent round trip; every view below is derived in the bridge."}),@@ -241,7 +241,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) -> "designators": d.designators(), "nets": d.nets(), "layers": d.layers(), "copperLayers": d.copper_layers(), "rules": d.rules(), "roundtripMs": r.get("roundtripMs"),- "_hint": "The raw dump behind every introspection view — record it as a test fixture with `> fixtures/dump_board.json`."})+ "_hint": "The raw read behind every introspection view — record it as a test fixture with `> fixtures/read_board.json`."}) } } _ => json!({"success": false, "error": format!("Unknown command: {command}"),
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.15",+ "version": "0.1.16", "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",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.15",+ "version": "0.1.16", "type": "app", "description": "Skills for your container so your AI knows how to drive the Altium Designer bridge (altium_* verbs). The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
skills/altium-bridge-dev/SKILL.md+9−7@@ -111,7 +111,7 @@ locked Windows session (DirectX) — check `desktop_get_foreground` first. Decision (John): the bridge PROCESS moves to a Rust exe that (1) calls ab's verbs for everything desktop-shaped (no Win32 in the bridge), (2) absorbs the DelphiScript agent's shaping logic so the-agent shrinks to generic dump/apply primitives, (3) ships no MCP server (external agents use+agent shrinks to generic read/apply primitives, (3) ships no MCP server (external agents use `adom-bridge-cli`). Vertical slice VERIFIED live on AdomLapper: `/health` (Altium detected through `desktop_find_window`, title parsed incl. the ADDevelop shape), `/verbs`, `/command` for status, eval, run_command, open_document and the 11 Phase-1 introspection verbs — speaking the existing@@ -181,17 +181,19 @@ fine for Windows-only Altium. Set `killImageName` to the exe basename. Fork target: `adom/hello-rust-bridge` v1.3.1 — the full contract in ~290 lines, including the `describe` verb (ab's Verbs tab renders it verbatim) and the `uninstall` cascade verb. -## Dump primitives: the shape the Rust bridge is built on (2026-09-01)+## Board reads: the shape the Rust bridge is built on (2026-09-01) -`dump_board` (agent) returns components + nets + layers + copper + rules in ONE round trip. It is+`read_board` (agent) returns components + nets + layers + copper + rules in ONE round trip. It is composed in DelphiScript from the EXISTING per-verb readers — deliberately: no new Altium API surface means it cannot introduce an undeclared identifier, which is the one mistake that halts the engine behind a modal on the user's screen. -The bridge then derives every view (`src/dump.rs`). Two rules that came out of doing it:+The bridge then derives every view (`src/board.rs`). Two rules that came out of doing it: -1. **Record the RAW agent payload as a fixture, not the bridge's own response.** `altium_dump_board- {raw: true}` returns the unshaped payload; that is what `fixtures/dump_board.json` holds, so the+0. **Name verbs for the caller's intent: `read_*` out, `write_*` / `apply_*` back.** The first draft+ of this shipped as `dump_board`; John rejected it — "dump" names the implementation (a blob out of+ memory), not the intent, and it has no counterpart, so it breaks the moment writes arrive.+1. **Record the RAW agent payload as a fixture, not the bridge's own response.** `altium_read_board {raw: true}` returns the unshaped payload; that is what `fixtures/read_board.json` holds, so the tests exercise the shaping rather than re-reading its output. 2. **Never invent a label for an ordinal.** Altium emits `TRuleKind` as a number. The mapping in `rule_kind_name()` was READ OFF a live default rule set (every default rule is named after its@@ -200,5 +202,5 @@ The bridge then derives every view (`src/dump.rs`). Two rules that came out of d fails loudly rather than silently mislabelling. Known gap: the agent enumerates every copper layer Altium CAN address (48: top + 30 mid + 16 planes-+ bottom), not the stack in use — there is no "in stack" flag in the dump yet, so the summary calls++ bottom), not the stack in use — there is no "in stack" flag in the read yet, so the summary calls it `copperLayersAddressable`. Do NOT report that number to a user as the board's layer count.
src/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.15+0.1.16
src/agent/AdomAgent.pas.tmpl+4−4@@ -3605,10 +3605,10 @@ Begin + '}'; End; -{ ONE round trip for the whole board. Composed from the EXISTING readers on purpose: no new Altium+{ ONE round trip to READ the whole board. Composed from the EXISTING readers on purpose: no new Altium API surface, so it cannot introduce an undeclared identifier (which halts the engine behind a modal). The bridge shapes these into typed views; recorded dumps are the unit-test fixtures. }-Function CmdDumpBoard(Lines : TStringList) : String;+Function CmdReadBoard(Lines : TStringList) : String; Var Board : IPCB_Board; Sects, S : String;@@ -3638,7 +3638,7 @@ Begin Else If Cmd = 'open' Then Result := CmdOpen(Lines) Else If Cmd = 'eval' Then Result := CmdEval(Lines, Payload) Else If Cmd = 'io_bench' Then Result := CmdIoBench(Lines)- Else If Cmd = 'dump_board' Then Result := CmdDumpBoard(Lines)+ Else If Cmd = 'read_board' Then Result := CmdReadBoard(Lines) Else If Cmd = 'lib_list' Then Result := CmdLibList(Lines) Else If Cmd = 'lib_select' Then Result := CmdLibSelect(Lines) Else If Cmd = 'compile' Then Result := CmdCompile(Lines)@@ -3689,7 +3689,7 @@ Begin gRunning := False; Result := '{' + JRaw('ok', 'true') + ',' + JStr('note', 'agent loop stopping') + '}'; End- Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,dump_board') + '}';+ Else Result := '{' + JRaw('ok', 'false') + ',' + JStr('error', 'unknown command: ' + Cmd) + ',' + JStr('known', 'ping,state,run_process,open,eval,lib_list,lib_select,compile,violations,outjob,create_footprint,create_symbol,install_library,compile_intlib,extract_intlib,set_params,add_body,stop,list_designators,component_data,component_pins,list_nets,layers,net_connections,schematic_data,copper_layers,read_rules,set_layer_visibility,footprint_primitives,io_bench,read_board') + '}'; End; { ------------------------------------------------------------------ loop --- }