main
Colby Knox Restore tools/symbol_modes.py from 4d4e030 (pin electrical types): the 0.2.52 release's pkg publish (cc812e5) wrote bac… 9c53d98 1d ago
#!/usr/bin/env python3
"""symbol_modes.py v2 - ONE Altium symbol, N display modes, with geometry sized
from the actual pin-name lengths so names never collide (ralph-tested per mode).

Rules (mils): pitch 100, pin length 300, char width 55 (10pt default font),
name gap 40 from the body edge, 2-col body width = longest left name +
longest right name + 200 middle gap; 4-side body side = max(pins-per-side span,
longest top/bottom name + longest opposite + 200). Chip-outline modes draw the
die in the measured free middle and skip it when the middle is under 300 mil.
A PIN's x,y is its BODY end, on the rectangle's edge (Altium's Pin.Location; the pin runs outward from there
in its orientation). Until 2026-10-19 the outer end was written here, so every pin floated one pin length
off the body with its name in the gap.
Spec lines: META|libref|desc|designator  MODE|i|name  RECT|mode|x1|y1|x2|y2
PIN|mode|number|name|x|y|orient|len|hide|etype  LINE|mode|x1|y1|x2|y2
(etype: the KiCad pin type, input/output/bidirectional/tri_state/passive/power_in/power_out/...,
which the agent maps to Altium's pin electrical type)
"""

import sys as _sys
def _usage_guard():
    if len(_sys.argv) < 5 or _sys.argv[1] in ("-h", "--help"):
        print('usage: altium-symbol-modes <part.kicad_sym> <out.spec> <SymbolName> <description>\n  Writes ONE symbol with its display modes as the spec altium_build_symbol_modes / altium_import_part take (symbolSpec).')
        _sys.exit(0 if len(_sys.argv) > 1 and _sys.argv[1] in ("-h", "--help") else 2)
_usage_guard()

import re, sys, json

GROUPS = [
    ("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"),
]
PITCH, PLEN, CW, GAP_EDGE, GAP_MID = 100, 300, 55, 40, 200

def parse_pins(path):
    s = open(path).read()
    pins = []
    # the angle may be written 180.0 (a pin written that way was dropped, measured 2026-10-15)
    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), "etype": m.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"

def wname(p): return GAP_EDGE + CW * len(p["name"])

def grid(v): return int(round(v / 100.0) * 100)

def two_column(pins, grouped, chip=False, wrap=False):
    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]
        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 = [], -PITCH
            for grp in cols:
                for p in grp:
                    out.append((p, y)); y -= PITCH
                y -= PITCH
            return out
        L, R = stack(left), stack(right)
    else:
        half = (len(pins) + 1) // 2
        L = [(p, -(i + 1) * PITCH) for i, p in enumerate(pins[:half])]
        rp = pins[half:]
        if wrap:                                      # CCW datasheet wrap: right side bottom->top
            R = [(p, -(len(rp) - i) * PITCH) for i, p in enumerate(rp)]
        else:
            R = [(p, -(i + 1) * PITCH) for i, p in enumerate(rp)]
    H = max([-y for _, y in L] + [-y for _, y in R]) + PITCH
    lw = max([wname(p) for p, _ in L] or [0]); rw = max([wname(p) for p, _ in R] or [0])
    W = grid(lw + rw + GAP_MID + (500 if chip else 0))
    res = [("PIN", p["num"], p["name"], 0, y, "left") for p, y in L]
    res += [("PIN", p["num"], p["name"], W, y, "right") for p, y in R]
    free = (lw, -H + PITCH, W - rw, -PITCH)          # middle clear area (x1,y1,x2,y2)
    return {"w": W, "h": H, "free": free}, res

def four_side(pins, grouped, chip=False):
    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)]
    span = (per + 1) * PITCH
    lw = max([wname(p) for p in sides[0]] or [0]); rw = max([wname(p) for p in sides[2]] or [0])
    bw = max([wname(p) for p in sides[1]] or [0]); tw = max([wname(p) for p in sides[3]] or [0])
    S = grid(max(span, lw + rw + GAP_MID, bw + tw + GAP_MID) + (600 if chip else 0))
    res = []
    def centered(k):                                  # start offset to centre k pins on a side
        return grid((S - (k - 1) * PITCH) / 2.0)
    o = centered(len(sides[0]))
    for i, p in enumerate(sides[0]):                  # left, top->bottom
        res.append(("PIN", p["num"], p["name"], 0, -(o + i * PITCH), "left"))
    o = centered(len(sides[1]))
    for i, p in enumerate(sides[1]):                  # bottom, left->right
        res.append(("PIN", p["num"], p["name"], o + i * PITCH, -S, "down"))
    o = centered(len(sides[2]))
    for i, p in enumerate(sides[2]):                  # right, bottom->top
        res.append(("PIN", p["num"], p["name"], S, -S + (o + i * PITCH), "right"))
    o = centered(len(sides[3]))
    for i, p in enumerate(sides[3]):                  # top, right->left
        res.append(("PIN", p["num"], p["name"], S - (o + i * PITCH), 0, "up"))
    free = (lw, -S + bw, S - rw, -tw)                 # middle clear of every name run
    return {"w": S, "h": S, "free": free}, res

def chip_lines(body):
    x1, y1, x2, y2 = body["free"]
    x1 += 60; y1 += 60; x2 -= 60; y2 -= 60
    if x2 - x1 < 300 or y2 - y1 < 300:
        return []                                     # no room: skip rather than collide
    side = min(x2 - x1, y2 - y1, 1200)                # a die is square, not a sliver
    cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
    x1, x2 = cx - side / 2, cx + side / 2
    y1, y2 = cy - side / 2, cy + side / 2
    L = [(x1, y1, x2, y1), (x2, y1, x2, y2), (x2, y2, x1, y2), (x1, y2, x1, y1)]
    d = 50; px, py = x1 + 120, y2 - 120
    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


# ---- native: a small part (a resistor, a capacitor, an inductor, an LED) drawn as its KiCad symbol is,
# not as an IC box. KiCad symbols are y up in mm, as Altium's are; a KiCad pin's (at) is its wire end and
# its angle points toward the body, Altium's Location is the body end and its orientation points away.
import math

def _mil(v): return int(round(float(v) / 0.0254))

def _blocks(text, tag):
    out, i = [], 0
    key = "(" + tag
    while True:
        i = text.find(key, i)
        if i < 0: return out
        if text[i + len(key)] not in " \n\t\r":
            i += 1; continue
        depth, j = 0, i
        while j < len(text):
            if text[j] == "(": depth += 1
            elif text[j] == ")":
                depth -= 1
                if depth == 0: break
            j += 1
        out.append(text[i:j + 1]); i = j + 1

def _xy(block, key):
    m = re.search(r"\(" + key + r"\s+([-\d.]+)\s+([-\d.]+)", block)
    return (float(m.group(1)), float(m.group(2))) if m else None

def native_lines(path):
    """The symbol's own drawing and pins as spec lines (mode 0), or None when it is not a small part."""
    s = open(path).read()
    pins = _blocks(s, "pin")
    if not pins or len(pins) > 3: return None
    g = []
    for r in _blocks(s, "rectangle"):
        a, b = _xy(r, "start"), _xy(r, "end")
        if a and b: g.append("RECT|0|%d|%d|%d|%d" % (_mil(min(a[0], b[0])), _mil(min(a[1], b[1])), _mil(max(a[0], b[0])), _mil(max(a[1], b[1]))))
    for pl in _blocks(s, "polyline"):
        pts = [(_mil(x), _mil(y)) for x, y in re.findall(r"\(xy\s+([-\d.]+)\s+([-\d.]+)\)", pl)]
        for (x1, y1), (x2, y2) in zip(pts, pts[1:]): g.append("LINE|0|%d|%d|%d|%d" % (x1, y1, x2, y2))
    for ar in _blocks(s, "arc"):
        a, m, b = _xy(ar, "start"), _xy(ar, "mid"), _xy(ar, "end")
        if not (a and m and b): continue
        # the circle through the three points; the arc runs counterclockwise from one end through mid
        ax, ay, bx, by, cx_, cy_ = a[0], a[1], m[0], m[1], b[0], b[1]
        d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by))
        if abs(d) < 1e-9: g.append("LINE|0|%d|%d|%d|%d" % (_mil(ax), _mil(ay), _mil(cx_), _mil(cy_))); continue
        ux = ((ax*ax + ay*ay) * (by - cy_) + (bx*bx + by*by) * (cy_ - ay) + (cx_*cx_ + cy_*cy_) * (ay - by)) / d
        uy = ((ax*ax + ay*ay) * (cx_ - bx) + (bx*bx + by*by) * (ax - cx_) + (cx_*cx_ + cy_*cy_) * (bx - ax)) / d
        rad = math.hypot(ax - ux, ay - uy)
        ang = lambda px, py: math.degrees(math.atan2(py - uy, px - ux)) % 360
        sa, ma, ea = ang(ax, ay), ang(bx, by), ang(cx_, cy_)
        ccw = (ma - sa) % 360 < (ea - sa) % 360
        a1, a2 = (sa, ea) if ccw else (ea, sa)
        g.append("ARC|0|%d|%d|%d|%d|%d" % (_mil(ux), _mil(uy), _mil(rad), round(a1), round(a2)))
    for c in _blocks(s, "circle"):
        ctr = _xy(c, "center"); rm = re.search(r"\(radius\s+([-\d.]+)", c)
        if ctr and rm: g.append("ARC|0|%d|%d|%d|0|360" % (_mil(ctr[0]), _mil(ctr[1]), _mil(rm.group(1))))
    if not g: return None
    out = []
    for p in pins:
        m = re.search(r"\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)", p); ln = re.search(r"\(length\s+([-\d.]+)\)", p)
        num = re.search(r'\(number\s+"([^"]*)"', p); nm = re.search(r'\(name\s+"([^"]*)"', p)
        x, y, a = float(m.group(1)), float(m.group(2)), int(round(float(m.group(3)))) % 360
        L = float(ln.group(1)) if ln else 2.54
        dx, dy = {0: (1, 0), 90: (0, 1), 180: (-1, 0), 270: (0, -1)}.get(a, (1, 0))
        orient = {0: "left", 90: "down", 180: "right", 270: "up"}.get(a, "left")
        name = nm.group(1) if nm and nm.group(1) != "~" else ""
        et = re.match(r"\(pin\s+(\w+)", p)
        out.append("PIN|0|%s|%s|%d|%d|%s|%d|hide|" % (num.group(1) if num else "?", name, _mil(x + dx * L), _mil(y + dy * L), orient, _mil(L)) + (et.group(1) if et else ""))
    ref = re.search(r'\(property\s+"Reference"\s+"([A-Za-z]+)', s)
    return (ref.group(1) if ref else "U"), g + out

def build(pins, libref, desc, out):
    looks = [("std", lambda p, g, c: two_column(p, g, c, wrap=True), 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, chip)
            lines.append(f"MODE|{mode}|{name}{'-chip' if chip else ''}")
            lines.append(f"RECT|{mode}|0|{-body['h']}|{body['w']}|0")
            et = {p["num"]: p.get("etype", "") for p in pins}
            for _, num, pname, x, y, orient in pins_out:
                lines.append(f"PIN|{mode}|{num}|{pname}|{int(x)}|{int(y)}|{orient}|{PLEN}||{et.get(num, '')}")
            if chip:
                for (a, b, c, d) in chip_lines(body):
                    lines.append(f"LINE|{mode}|{int(a)}|{int(b)}|{int(c)}|{int(d)}")
            mode += 1
    open(out, "w").write("\n".join(lines) + "\n")
    return mode, len(lines)

if __name__ == "__main__":
    pins = parse_pins(sys.argv[1])
    nat = native_lines(sys.argv[1])
    if nat:
        # one mode, the part as its symbol draws it (a resistor reads as a resistor)
        des, body = nat
        open(sys.argv[2], "w").write("\n".join([f"META|{sys.argv[3]}|{sys.argv[4]}|{des}", "MODE|0|native"] + body) + "\n")
        print(json.dumps({"pins": len(pins), "modes": 1, "native": True, "lines": len(body) + 2, "out": sys.argv[2]}))
        sys.exit(0)
    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]}))