← Commit history

v0.1.2: LAN7800 lane — footprint/symbol-mode builders, footprint models, test board, swap; dialog pre-emption

John Lauer ·b7e5070d1c ·1mo ago ·parent 0a952ab
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]}))