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.2: LAN7800 lane — footprint/symbol-mode builders, footprint models, test board, swap; dialog pre-emption
8 files changed
+335−8
CHANGELOG.md+5@@ -1,5 +1,10 @@ # Changelog +## 0.1.2 — 2026-08-30+- LAN7800 creative-import lane: build_footprint (spec from tools/kicad2altium.py: via pads by duplicate designator, roundrect %, named mech layers incl. Adom.SolderBalls), build_symbol_modes (10 display modes on one component) + symbol_mode (editor-driven), add_models (N footprint models + params), normalize_bodies, test_board, board_shape (PCB:PlaceBoardOutline), swap_footprint (nets kept by designator), pcb_report with pads/bodies/nets/tracks.+- Dialog pre-emption: ReadAll probes with FileOpen (the debugger breaks on any exception), TxClear on agent start / before save / after recover (dangling PreProcess = "a command is currently active").+- Library canvas offset understood: bodies added through the canvas realign onto the pads automatically.+ ## 0.1.0 — 2026-08-30 - Schematic placement fixed: place_on_sch copies each lib primitive onto a fresh sheet component (Replicate alone drops pins/body); proven on the CH340G (17 primitives render). - 3D pipeline fixed end-to-end: bodies persist (added via the PcbLib editor CANVAS with the footprint current), STEP + extruded both render on a real board; place_on_pcb builds a fresh eComponentObject, copies children BEFORE the board add, normalizes the pad cluster and each body to the target separately. Ray's CH340G STEP renders in 3D on a board.
SKILL.md+5−1@@ -62,7 +62,11 @@ Altium is a licensed host app. The bridge never installs it silently; it detects | `altium_set_params` | parameters (LCSC Part #, MPN, …), comment, footprint link on a symbol | `libraryPath`, `symbolName`, `params`, `footprint` | | `altium_add_body` | 3D body on a footprint — STEP model or extruded box | `libraryPath`, `footprintName`, `step` \| `bwMil,bhMil,hMil` | | `altium_install_library` / `altium_compile_intlib` / `altium_extract_intlib` | Installed Libraries, .LibPkg → .IntLib, .IntLib → sources | `libraryPath` / `libpkgPath` / `intlibPath` |-| `altium_save` | save the focused document | `filePath` |+| `altium_build_footprint` / `altium_build_symbol_modes` | author a footprint from a `tools/kicad2altium.py` spec (via pads, roundrect, mech layers) / ONE symbol with N display modes | `libraryPath`, `spec` |+| `altium_add_models` / `altium_symbol_mode` | N footprint models + params on a symbol / switch display mode + frame | `symbolName`, `models[]`, `params` / `mode` |+| `altium_test_board` / `altium_board_shape` / `altium_swap_footprint` | wired test board, rectangular board shape, footprint swap keeping nets | `footprintName`, `wMm`,`hMm`, `assigns` |+| `altium_pcb_report` / `altium_normalize_bodies` | ground truth (pads/bodies/nets/tracks with bboxes) / centre bodies on pads | `want:"board"`, `footprintName` |+| `altium_save` | save the focused document (pre-clears a dangling transaction) | `filePath` | | `altium_signin_status` / `altium_license` / `altium_connect_workspace` | account + licence + workspace state; drive the License Management page (`action: show\|refresh\|use_license\|start_trial`) | — | | `altium_a365_configure` / `_status` / `_query` | Altium 365 GraphQL (needs a token from a workspace that has Admin → Developer; the Develop trial does not) | `workspace`, `token`, `query` | | `altium_demo` | six-beat tour: symbol → footprint → 3D part → schematic → 2D → 3D board | `stage` |
adom-bridge-altium-manifest.json+4−4@@ -1,9 +1,9 @@ { "name": "altium",- "version": "0.1.0",- "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.0/adom-bridge-altium-v0.1.0.zip",- "sha256": "abe87f8507aaf5f7cb850507a4b99dadefbe1caa1f6d3edb60684f54fe42c052",- "size": 81133,+ "version": "0.1.2",+ "url": "https://wiki.adom.inc/download/adom/altium-bridge/0.1.2/adom-bridge-altium-v0.1.2.zip",+ "sha256": "5ce93c63c370684119225d35461d5764cfec30232a8603859896428c6836a49d",+ "size": 89364, "verbPrefixes": [ "altium_" ],
adom-bridge-altium-v0.1.2.zipadded⋯ 1 unchanged line ⋯
bridge.json+46−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "altium", "displayName": "Altium Designer",- "version": "0.1.0",+ "version": "0.1.2", "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",@@ -80,7 +80,51 @@ "altium_import_kicad", "altium_a365_configure", "altium_signin_status",- "altium_connect_workspace"+ "altium_connect_workspace",+ {+ "name": "altium_build_footprint",+ "description": "Build a footprint from a kicad2altium spec (pads incl. via pads, roundrect, mech-layer drawing)"+ },+ {+ "name": "altium_build_symbol_modes",+ "description": "Build ONE symbol with N display modes from a symbol_modes spec"+ },+ {+ "name": "altium_symbol_mode",+ "description": "Switch the library editor to display mode N and return a frame"+ },+ {+ "name": "altium_add_models",+ "description": "Attach N footprint models + parameters to a symbol"+ },+ {+ "name": "altium_normalize_bodies",+ "description": "Centre every 3D body on its pad cluster (library frame fix)"+ },+ {+ "name": "altium_test_board",+ "description": "New PcbDoc with the footprint placed and nets/stub tracks"+ },+ {+ "name": "altium_board_shape",+ "description": "Redefine the board shape as a rectangle (editor process)"+ },+ {+ "name": "altium_swap_footprint",+ "description": "Replace the board component with another footprint, nets kept by designator"+ },+ {+ "name": "altium_pcb_report",+ "description": "Ground truth: pads/bodies/nets/tracks with bboxes"+ },+ {+ "name": "altium_place_on_sch",+ "description": "Place a library symbol on a fresh sheet"+ },+ {+ "name": "altium_place_on_pcb",+ "description": "Place a library footprint on a fresh board"+ } ], "statusVerb": "altium_status", "uninstall": "altium_uninstall",
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "altium-bridge",- "version": "0.1.1",+ "version": "0.1.2", "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": [
tools/kicad2altium.pyadded+140@@ -0,0 +1,140 @@+#!/usr/bin/env python3+"""kicad2altium.py - turn a .kicad_mod into a line-oriented Altium footprint spec+the resident agent's build_footprint command consumes (no JSON parsing in+DelphiScript). Units: mm in, mm out; Y is flipped (KiCad Y-down -> Altium Y-up).++Spec lines (pipe-separated):+ META|name|description+ PAD|designator|x|y|w|h|shape|hole|layer|crpct shape: round|rect|oval|roundrect ; layer: top|multi+ LINE|layer|x1|y1|x2|y2|width+ ARC|layer|cx|cy|r|width|start|stop full circles: 0..360, width = stroke+ DISC|layer|cx|cy|r filled circle (drawn as arc with width=r)+ POLY|layer|width|x1,y1;x2,y2;...+Layers: overlay (F.SilkS) | fab (F.Fab -> Mechanical 13) | courtyard (F.CrtYd -> Mechanical 15)+ | balls (User.9 -> Mechanical 9 "Adom.SolderBalls") | comment (Cmts.User -> Mechanical 1)+"""+import re, sys, json++LAYER = {"F.SilkS": "overlay", "F.Fab": "fab", "F.CrtYd": "courtyard", "User.9": "balls",+ "Cmts.User": "comment", "F.Cu": "top", "*.Cu": "multi"}++def num(x): return float(x)++def parse_sexpr(s):+ """Minimal s-expression parser -> nested lists (strings kept)."""+ tokens = re.findall(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+', s)+ stack = [[]]+ for t in tokens:+ if t == '(':+ stack.append([])+ elif t == ')':+ top = stack.pop(); stack[-1].append(top)+ else:+ stack[-1].append(t.strip('"') if t.startswith('"') else t)+ return stack[0]++def find(node, key):+ return [n for n in node if isinstance(n, list) and n and n[0] == key]++def first(node, key):+ f = find(node, key)+ return f[0] if f else None++def layer_of(node):+ l = first(node, 'layer')+ if l: return LAYER.get(l[1])+ ls = first(node, 'layers')+ if ls:+ for v in ls[1:]:+ if v in LAYER: return LAYER[v]+ if v.endswith('.Cu'): return 'multi' if v.startswith('*') else 'top'+ return None++def convert(path, name=None, description=""):+ tree = parse_sexpr(open(path).read())+ fp = tree[0]+ assert fp[0] in ('footprint', 'module'), fp[0]+ name = name or fp[1]+ out = [f"META|{name}|{description}"]+ stats = {"pads": 0, "th": 0, "lines": 0, "arcs": 0, "discs": 0, "polys": 0, "layers": set()}+ for n in fp[2:]:+ if not isinstance(n, list): continue+ k = n[0]+ if k == 'pad':+ des, kind, shape = n[1], n[2], n[3]+ at = first(n, 'at'); size = first(n, 'size')+ x, y = num(at[1]), -num(at[2])+ w, h = num(size[1]), num(size[2])+ rot = num(at[3]) if len(at) > 3 else 0.0+ if rot in (90.0, 270.0): w, h = h, w+ hole = 0.0+ d = first(n, 'drill')+ if d: hole = num(d[1])+ layer = 'multi' if kind == 'thru_hole' else 'top'+ crp = 0+ if shape == 'roundrect':+ rr = first(n, 'roundrect_rratio')+ crp = int(round(num(rr[1]) * 100)) if rr else 25+ shape_a = 'roundrect'+ elif shape in ('circle',):+ shape_a = 'round'+ elif shape == 'oval':+ shape_a = 'oval'+ else:+ shape_a = 'rect'+ out.append(f"PAD|{des}|{x:.4f}|{y:.4f}|{w:.4f}|{h:.4f}|{shape_a}|{hole:.3f}|{layer}|{crp}")+ stats['pads'] += 1; stats['th'] += (kind == 'thru_hole')+ elif k == 'fp_line':+ lay = layer_of(n)+ if not lay: continue+ s, e = first(n, 'start'), first(n, 'end')+ st = first(n, 'stroke'); wd = num(first(st, 'width')[1]) if st else num(first(n, 'width')[1])+ out.append(f"LINE|{lay}|{num(s[1]):.4f}|{-num(s[2]):.4f}|{num(e[1]):.4f}|{-num(e[2]):.4f}|{wd:.3f}")+ stats['lines'] += 1; stats['layers'].add(lay)+ elif k == 'fp_circle':+ lay = layer_of(n)+ if not lay: continue+ c, e = first(n, 'center'), first(n, 'end')+ r = ((num(e[1]) - num(c[1]))**2 + (num(e[2]) - num(c[2]))**2) ** 0.5+ fill = first(n, 'fill')+ filled = bool(fill and fill[1] in ('solid', 'yes'))+ st = first(n, 'stroke'); wd = num(first(st, 'width')[1]) if st else 0.1+ if filled:+ out.append(f"DISC|{lay}|{num(c[1]):.4f}|{-num(c[2]):.4f}|{r:.4f}")+ stats['discs'] += 1+ else:+ out.append(f"ARC|{lay}|{num(c[1]):.4f}|{-num(c[2]):.4f}|{r:.4f}|{wd:.3f}|0|360")+ stats['arcs'] += 1+ stats['layers'].add(lay)+ elif k == 'fp_poly':+ lay = layer_of(n)+ if not lay: continue+ pts = first(n, 'pts')+ xy = [(num(p[1]), -num(p[2])) for p in find(pts, 'xy')]+ st = first(n, 'stroke'); wd = num(first(st, 'width')[1]) if st else 0.1+ # polygons go out as closed line loops: robust on any mechanical layer+ for i in range(len(xy)):+ a, b = xy[i], xy[(i + 1) % len(xy)]+ out.append(f"LINE|{lay}|{a[0]:.4f}|{a[1]:.4f}|{b[0]:.4f}|{b[1]:.4f}|{wd:.3f}")+ stats['lines'] += 1+ stats['polys'] += 1; stats['layers'].add(lay)+ elif k == 'fp_rect':+ lay = layer_of(n)+ if not lay: continue+ s, e = first(n, 'start'), first(n, 'end')+ x1, y1, x2, y2 = num(s[1]), -num(s[2]), num(e[1]), -num(e[2])+ st = first(n, 'stroke'); wd = num(first(st, 'width')[1]) if st else 0.1+ for a, b in (((x1, y1), (x2, y1)), ((x2, y1), (x2, y2)), ((x2, y2), (x1, y2)), ((x1, y2), (x1, y1))):+ out.append(f"LINE|{lay}|{a[0]:.4f}|{a[1]:.4f}|{b[0]:.4f}|{b[1]:.4f}|{wd:.3f}")+ stats['lines'] += 1+ stats['layers'].add(lay)+ stats['layers'] = sorted(stats['layers'])+ return "\n".join(out) + "\n", stats++if __name__ == '__main__':+ src, dst = sys.argv[1], sys.argv[2]+ name = sys.argv[3] if len(sys.argv) > 3 else None+ desc = sys.argv[4] if len(sys.argv) > 4 else ""+ text, stats = convert(src, name, desc)+ open(dst, 'w').write(text)+ print(json.dumps({"out": dst, **stats}))
tools/symbol_modes.pyadded+134@@ -0,0 +1,134 @@+#!/usr/bin/env python3+"""symbol_modes.py - emit ONE Altium symbol with N display modes (Altium's+alternate-symbol mechanism) from a KiCad symbol's pin table.++Spec lines (pipe-separated, units mils, Y up; the agent's build_symbol_modes reads it):+ META|libref|description|designator+ MODE|index|name+ RECT|mode|x1|y1|x2|y2+ PIN|mode|number|name|x|y|orient|length orient: left|right|up|down = side the pin sits on+ LINE|mode|x1|y1|x2|y2+Looks (10): std, 2col, 4side, 2col-grouped, 4side-grouped, each x {plain, chip} (chip = a+thumbnail outline: rounded body + pin-1 dot drawn as lines inside the rectangle).+"""+import re, sys, json++GROUPS = [ # order matters: grouped looks lay pins out in this order+ ("Ethernet", lambda n: n.startswith("TR") or n.startswith("REF_")),+ ("USB", lambda n: n.startswith("USB")),+ ("Crystal", lambda n: n in ("XI", "XO")),+ ("Handshake", lambda n: any(k in n for k in ("PME", "SUSPEND", "RESET", "VBUS", "TEST"))),+ ("Config", lambda n: n.startswith("EE") or "GPIO" in n or n.startswith("LED")),+ ("Power", lambda n: n.startswith("VDD") or n == "VSS"),+]++def parse_pins(path):+ s = open(path).read()+ pins = []+ for m in re.finditer(r'\(pin\s+(\w+)\s+(\w+)\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+(\d+)\)\s*\(length\s+([-\d.]+)\)', s):+ tail = s[m.end():m.end() + 400]+ name = re.search(r'\(name\s+"([^"]*)"', tail); num = re.search(r'\(number\s+"([^"]*)"', tail)+ pins.append({"num": num.group(1), "name": name.group(1)})+ pins.sort(key=lambda p: int(p["num"]) if p["num"].isdigit() else 999)+ return pins++def group_of(name):+ for g, f in GROUPS:+ if f(name): return g+ return "Other"++PITCH = 100 # mils+PLEN = 300++def two_column(pins, grouped):+ """Left: first half, right: second half (std/2col). Grouped: groups stacked, a gap between."""+ if grouped:+ order = []+ for g, _ in GROUPS:+ order.append([p for p in pins if group_of(p["name"]) == g])+ order.append([p for p in pins if group_of(p["name"]) == "Other"])+ order = [o for o in order if o]+ # split groups across the two sides by balancing counts+ left, right = [], []+ for grp in order:+ (left if sum(len(x) for x in left) <= sum(len(x) for x in right) else right).append(grp)+ def stack(cols):+ out, y = [], 0+ for grp in cols:+ for p in grp:+ out.append((p, y)); y -= PITCH+ y -= PITCH # gap between groups+ return out+ L, R = stack(left), stack(right)+ else:+ half = (len(pins) + 1) // 2+ L = [(p, -i * PITCH) for i, p in enumerate(pins[:half])]+ R = [(p, -i * PITCH) for i, p in enumerate(pins[half:])]+ h = max([-y for _, y in L] + [-y for _, y in R]) + PITCH+ W = 1400+ out = [("RECT", 0, 0, -h, W, 0)] # placeholder; we normalize later+ body = {"w": W, "h": h}+ res = []+ for p, y in L:+ res.append(("PIN", p["num"], p["name"], -PLEN, y - PITCH // 2, "left"))+ for p, y in R:+ res.append(("PIN", p["num"], p["name"], W + PLEN, y - PITCH // 2, "right"))+ return body, res++def four_side(pins, grouped):+ """Square: pins distributed on 4 sides, counter-clockwise from top-left like the package+ (left top->bottom, bottom left->right, right bottom->top, top right->left)."""+ if grouped:+ seq = []+ for g, _ in GROUPS:+ seq += [p for p in pins if group_of(p["name"]) == g]+ seq += [p for p in pins if group_of(p["name"]) == "Other"]+ else:+ seq = pins+ n = len(seq); per = (n + 3) // 4+ sides = [seq[i * per:(i + 1) * per] for i in range(4)]+ S = (per + 1) * PITCH+ res = []+ for i, p in enumerate(sides[0]): # left, top->bottom+ res.append(("PIN", p["num"], p["name"], -PLEN, -(i + 1) * PITCH, "left"))+ for i, p in enumerate(sides[1]): # bottom, left->right+ res.append(("PIN", p["num"], p["name"], (i + 1) * PITCH, -S - PLEN, "down"))+ for i, p in enumerate(sides[2]): # right, bottom->top+ res.append(("PIN", p["num"], p["name"], S + PLEN, -S + (i + 1) * PITCH, "right"))+ for i, p in enumerate(sides[3]): # top, right->left+ res.append(("PIN", p["num"], p["name"], S - (i + 1) * PITCH, PLEN, "up"))+ return {"w": S, "h": S}, res++def chip_lines(body):+ """Thumbnail outline inside the rectangle: a rounded die with a pin-1 dot (as a small box)."""+ w, h = body["w"], body["h"]+ m = 120+ x1, y1, x2, y2 = m, -h + m, w - m, -m+ L = [(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)]+ d = 60; px, py = x1 + 100, y2 - 100 # pin-1 dot top-left+ L += [(px - d, py - d, px + d, py - d), (px + d, py - d, px + d, py + d), (px + d, py + d, px - d, py + d), (px - d, py + d, px - d, py - d)]+ return L++def build(pins, libref, desc, out):+ looks = [("std", two_column, False), ("2col", two_column, False), ("4side", four_side, False),+ ("2col-grouped", two_column, True), ("4side-grouped", four_side, True)]+ lines = [f"META|{libref}|{desc}|U"]+ mode = 0+ for name, fn, grouped in looks:+ for chip in (False, True):+ body, pins_out = fn(pins, grouped)+ lines.append(f"MODE|{mode}|{name}{'-chip' if chip else ''}")+ lines.append(f"RECT|{mode}|0|{-body['h']}|{body['w']}|0")+ for _, num, pname, x, y, orient in pins_out:+ lines.append(f"PIN|{mode}|{num}|{pname}|{x}|{y}|{orient}|{PLEN}")+ if chip:+ for (a, b, c, d) in chip_lines(body):+ lines.append(f"LINE|{mode}|{a}|{b}|{c}|{d}")+ mode += 1+ open(out, "w").write("\n".join(lines) + "\n")+ return mode, len(lines)++if __name__ == "__main__":+ pins = parse_pins(sys.argv[1])+ modes, n = build(pins, sys.argv[3], sys.argv[4], sys.argv[2])+ print(json.dumps({"pins": len(pins), "modes": modes, "lines": n, "out": sys.argv[2]}))