main
Colby Knox 0.2.47 source (not yet released, needs signing): AI Flow Altium lane verbs. Live board edits (routing_state, route_net,… 2f3deab 5d ago
#!/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 sys as _sys
def _usage_guard():
    if len(_sys.argv) < 3 or _sys.argv[1] in ("-h", "--help"):
        print('usage: altium-kicad2altium <part.kicad_mod> <out.spec> [FootprintName] [description]\n  Writes the line-oriented footprint spec altium_build_footprint / altium_import_part take (footprintSpec).')
        _sys.exit(0 if len(_sys.argv) > 1 and _sys.argv[1] in ("-h", "--help") else 2)
_usage_guard()

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}))