main
ae007 Project page: AFS_Adom rev A (Rice Eclipse AFS clean-room AI redesign): highlight reel and narrated process video with… cbe460e 7d ago
"""Minimal KiCad s-expression toolkit for generating schematics and boards.

Tokens are kept raw (quoted strings keep their quotes) so library content can be
re-emitted verbatim.
"""
import re, uuid, math

TOK = re.compile(r'"(?:[^"\\]|\\.)*"|\(|\)|[^\s()"]+')


def parse(text):
    stack = [[]]
    for t in TOK.findall(text):
        if t == '(':
            stack.append([])
        elif t == ')':
            l = stack.pop()
            stack[-1].append(l)
        else:
            stack[-1].append(t)
    return stack[0]


def q(s):
    """Quote a string for s-expr output."""
    s = str(s).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n')
    return '"' + s + '"'


def uq(t):
    """Unquote a raw token."""
    if isinstance(t, str) and len(t) >= 2 and t[0] == '"' and t[-1] == '"':
        return t[1:-1].replace('\\"', '"').replace('\\\\', '\\')
    return t


def dump(node, ind=0):
    """Pretty-print a nested list as s-expr text."""
    if not isinstance(node, list):
        return str(node)
    simple = all(not isinstance(x, list) for x in node)
    if simple:
        return '(' + ' '.join(str(x) for x in node) + ')'
    out = '(' + ' '.join(str(x) for x in node if not isinstance(x, list))
    for x in node:
        if isinstance(x, list):
            out += '\n' + '\t' * (ind + 1) + dump(x, ind + 1)
    out += '\n' + '\t' * ind + ')'
    return out


def find(node, name):
    return [x for x in node if isinstance(x, list) and x and x[0] == name]


def find1(node, name):
    f = find(node, name)
    return f[0] if f else None


_NS = uuid.uuid5(uuid.NAMESPACE_URL, 'https://wiki.adom.inc/AFS_Adom')
_N = [0]


def nuid():
    """Deterministic UUIDs (uuid5 over a generation counter) so regenerated files keep their identities."""
    _N[0] += 1
    return str(uuid.uuid5(_NS, 'AFS_Adom:%d' % _N[0]))


def fmt(v):
    v = round(float(v), 4)
    if v == int(v):
        return str(int(v))
    return ('%.4f' % v).rstrip('0').rstrip('.')


class Pin:
    def __init__(self, number, name, x, y, angle, length, etype, unit):
        self.number, self.name, self.x, self.y = number, name, x, y
        self.angle, self.length, self.etype, self.unit = angle, length, etype, unit


class SymLib:
    """Loads one or more .kicad_sym files; returns flattened symbols."""

    def __init__(self):
        self.syms = {}   # name -> raw symbol list
        self.libname = {}

    def load(self, path, libname):
        tree = parse(open(path, encoding='utf-8').read())[0]
        for s in find(tree, 'symbol'):
            n = uq(s[1])
            self.syms[n] = s
            self.libname[n] = libname

    def flat(self, name):
        """Return a flattened copy (derived symbols merged into their parent)."""
        import copy
        s = copy.deepcopy(self.syms[name])
        ext = find1(s, 'extends')
        if ext is None:
            return s
        parent = self.flat(uq(ext[1]))
        pname = uq(parent[1])
        out = [x for x in parent if not (isinstance(x, list) and x[0] == 'property')]
        # child's properties replace parent's; keep parent's properties the child lacks
        cprops = {uq(p[1]): p for p in find(s, 'property')}
        pprops = {uq(p[1]): p for p in find(parent, 'property')}
        props = dict(pprops)
        props.update(cprops)
        out[1] = q(name)
        # insert properties after the header tokens
        head = [x for x in out if not isinstance(x, list)]
        body = [x for x in out if isinstance(x, list)]
        body_props = list(props.values())
        rest = [x for x in body if x[0] != 'property']
        # rename unit sub-symbols
        for u in rest:
            if u[0] == 'symbol':
                un = uq(u[1])
                if un.startswith(pname + '_'):
                    u[1] = q(name + un[len(pname):])
        return head + body_props + rest

    def pins(self, name, unit=None):
        s = self.flat(name)
        out = []
        for sub in find(s, 'symbol'):
            un = uq(sub[1])
            m = re.search(r'_(\d+)_(\d+)$', un)
            u = int(m.group(1)) if m else 0
            if unit is not None and u not in (0, unit):
                continue
            for p in find(sub, 'pin'):
                at = find1(p, 'at')
                ln = find1(p, 'length')
                out.append(Pin(uq(find1(p, 'number')[1]), uq(find1(p, 'name')[1]),
                               float(at[1]), float(at[2]), float(at[3]) if len(at) > 3 else 0,
                               float(ln[1]) if ln else 2.54, p[1], u))
        return out

    def power_flag(self, name):
        s = self.flat(name)
        return find1(s, 'power') is not None


def rot(x, y, r):
    a = math.radians(r)
    return (x * math.cos(a) - y * math.sin(a), x * math.sin(a) + y * math.cos(a))


class Schematic:
    def __init__(self, project, filename, lib, root_uuid=None, sheet_uuid=None, paper='A3'):
        self.project = project
        self.filename = filename
        self.lib = lib
        self.uuid = sheet_uuid or nuid()
        self.root_uuid = root_uuid or self.uuid
        self.is_root = root_uuid is None
        self.items = []
        self.lib_symbols = {}
        self.paper = paper
        self.ref_count = {}
        self.sheets = []
        self.title = project

    # ---- geometry helpers
    def pin_abs(self, pin, X, Y, R):
        """Absolute connection point and outward unit vector (schematic frame, +y down)."""
        px, py = rot(pin.x, pin.y, R)
        ax, ay = X + px, Y - py
        ang = (pin.angle + R + 180) % 360  # outward direction in lib frame
        ox, oy = math.cos(math.radians(ang)), -math.sin(math.radians(ang))
        return ax, ay, round(ox), round(oy)

    def path(self):
        return '/' + self.root_uuid if self.is_root else '/' + self.root_uuid + '/' + self.uuid

    # ---- placing
    def add_symbol(self, libname, symname, ref, value, at, R=0, footprint='', nets=None,
                   fields=None, unit=1, stub=2.54, label_kind='global', hide_fields=(),
                   dnp=False, in_bom=True, on_board=True):
        """Place a symbol and connect its pins by the `nets` map {pin_number: netname}.

        Net names 'GND', '+3V3', '+5V' get power symbols; 'NC' gets a no-connect; other
        names get a global label on a short stub wire.
        """
        key = libname + ':' + symname
        if key not in self.lib_symbols:
            flat = self.lib.flat(symname)
            flat[1] = q(key)
            self.lib_symbols[key] = flat
        X, Y = at
        suid = nuid()
        pins = self.lib.pins(symname, unit)
        props = []
        fields = dict(fields or {})
        base = {'Reference': ref, 'Value': value, 'Footprint': footprint, 'Datasheet': fields.pop('Datasheet', ''),
                'Description': fields.pop('Description', '')}
        i = 0
        for k, v in list(base.items()) + list(fields.items()):
            hide = k not in ('Reference', 'Value') or k in hide_fields
            props.append(['property', q(k), q(v), ['at', fmt(X + 2), fmt(Y - 1.27 * (3 - i)), '0'],
                          ['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left']] + ([['hide', 'yes']] if hide else [])])
            i += 1
        sym = ['symbol', ['lib_id', q(key)], ['at', fmt(X), fmt(Y), fmt(R)], ['unit', str(unit)],
               ['exclude_from_sim', 'no'], ['in_bom', 'yes' if in_bom else 'no'], ['on_board', 'yes' if on_board else 'no'],
               ['dnp', 'yes' if dnp else 'no'], ['fields_autoplaced', 'yes'], ['uuid', q(suid)]] + props
        for p in pins:
            sym.append(['pin', q(p.number), ['uuid', q(nuid())]])
        sym.append(['instances', ['project', q(self.project), ['path', q(self.path()), ['reference', q(ref)], ['unit', str(unit)]]]])
        self.items.append(sym)
        # connections
        nets = nets or {}
        for p in pins:
            net = nets.get(p.number)
            ax, ay, ox, oy = self.pin_abs(p, X, Y, R)
            if net is None:
                continue
            if net == 'NC':
                self.items.append(['no_connect', ['at', fmt(ax), fmt(ay)], ['uuid', q(nuid())]])
                continue
            ex, ey = ax + ox * stub, ay + oy * stub
            if stub > 0:
                self.items.append(['wire', ['pts', ['xy', fmt(ax), fmt(ay)], ['xy', fmt(ex), fmt(ey)]],
                                   ['stroke', ['width', '0'], ['type', 'default']], ['uuid', q(nuid())]])
            if net in ('GND', '+3V3', '+5V'):
                self.add_power(net, (ex, ey), (ox, oy))
            else:
                self.add_label(net, (ex, ey), (ox, oy), kind=label_kind)
        return suid

    def add_power(self, net, at, direction=(0, 1)):
        """Power symbol with its pin at `at`; direction is the stub's outward vector."""
        ox, oy = direction
        if net == 'GND':
            R = {(0, 1): 0, (0, -1): 180, (1, 0): 270, (-1, 0): 90}[(ox, oy)]
        else:
            R = {(0, -1): 0, (0, 1): 180, (1, 0): 90, (-1, 0): 270}[(ox, oy)]
        ref = self.next_ref('#PWR')
        self.add_symbol('power', net, ref, net, at, R, stub=0, nets={})
        # fix hidden reference for power symbols: KiCad expects hidden Reference
        sym = self.items[-1]
        for p in find(sym, 'property'):
            if uq(p[1]) == 'Reference':
                p.append(['hide', 'yes']) if not find1(p, 'hide') else None
                eff = find1(p, 'effects')
                if eff is not None and not find1(eff, 'hide'):
                    eff.append(['hide', 'yes'])

    def add_pwr_flag(self, net, at):
        X, Y = at
        ref = self.next_ref('#FLG')
        self.add_symbol('power', 'PWR_FLAG', ref, 'PWR_FLAG', (X, Y), 0, stub=2.54, nets={'1': net})

    def next_ref(self, prefix):
        self.ref_count[prefix] = self.ref_count.get(prefix, 0) + 1
        return '%s%02d' % (prefix, self.ref_count[prefix])

    def add_label(self, net, at, direction=(1, 0), kind='global'):
        ox, oy = direction
        R = {(1, 0): 0, (-1, 0): 180, (0, -1): 90, (0, 1): 270}[(ox, oy)]
        X, Y = at
        if kind == 'global':
            self.items.append(['global_label', q(net), ['shape', 'bidirectional'], ['at', fmt(X), fmt(Y), str(R)],
                               ['fields_autoplaced', 'yes'], ['effects', ['font', ['size', '1.27', '1.27']],
                               ['justify', 'left' if R in (0, 90) else 'right']], ['uuid', q(nuid())],
                               ['property', q('Intersheetrefs'), q('${INTERSHEET_REFS}'), ['at', fmt(X), fmt(Y), '0'],
                                ['effects', ['font', ['size', '1.27', '1.27']], ['hide', 'yes']]]])
        else:
            self.items.append(['label', q(net), ['at', fmt(X), fmt(Y), str(R)], ['fields_autoplaced', 'yes'],
                               ['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left' if R in (0, 90) else 'right']],
                               ['uuid', q(nuid())]])

    def add_wire(self, a, b):
        self.items.append(['wire', ['pts', ['xy', fmt(a[0]), fmt(a[1])], ['xy', fmt(b[0]), fmt(b[1])]],
                           ['stroke', ['width', '0'], ['type', 'default']], ['uuid', q(nuid())]])

    def add_text(self, text, at, size=1.27):
        self.items.append(['text', q(text), ['exclude_from_sim', 'no'], ['at', fmt(at[0]), fmt(at[1]), '0'],
                           ['effects', ['font', ['size', fmt(size), fmt(size)]], ['justify', 'left', 'top']], ['uuid', q(nuid())]])

    def add_sheet(self, name, filename, at, size, sheet_uuid, page):
        X, Y = at
        W, H = size
        self.items.append(['sheet', ['at', fmt(X), fmt(Y)], ['size', fmt(W), fmt(H)], ['exclude_from_sim', 'no'],
                           ['in_bom', 'yes'], ['on_board', 'yes'], ['dnp', 'no'], ['fields_autoplaced', 'yes'],
                           ['stroke', ['width', '0.1524'], ['type', 'solid']], ['fill', ['color', '0', '0', '0', '0.0000']],
                           ['uuid', q(sheet_uuid)],
                           ['property', q('Sheetname'), q(name), ['at', fmt(X), fmt(Y - 0.7), '0'],
                            ['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left', 'bottom']]],
                           ['property', q('Sheetfile'), q(filename), ['at', fmt(X), fmt(Y + H + 0.6), '0'],
                            ['effects', ['font', ['size', '1.27', '1.27']], ['justify', 'left', 'top']]],
                           ['instances', ['project', q(self.project), ['path', q('/' + self.root_uuid), ['page', q(str(page))]]]]])

    def write(self, path, page='1'):
        tree = ['kicad_sch', ['version', '20250114'], ['generator', q('eeschema')], ['generator_version', q('9.0')],
                ['uuid', q(self.uuid)], ['paper', q(self.paper)],
                ['title_block', ['title', q(self.title)], ['date', q('2026-10-15')], ['rev', q('A')],
                 ['company', q('AFS_Adom clean-room design')]],
                ['lib_symbols'] + list(self.lib_symbols.values())]
        tree += self.items
        if self.is_root:
            tree.append(['sheet_instances', ['path', q('/'), ['page', q(page)]]])
        tree.append(['embedded_fonts', 'no'])
        open(path, 'w', encoding='utf-8').write(dump(tree) + '\n')