main
noah amfm-receiver-molecule: KiCad project, fab files, 3D and README (6/7) 53707c4 1d ago
"""Project-local library: ceramic filter symbols + footprints (AMFM)."""
import os
from sexp import Sym as S, dumps
from schgen import uid

HERE = os.path.dirname(os.path.abspath(__file__))
LIB = os.path.join(HERE, '..', 'lib')


def fx(size=1.27):
    return [S('effects'), [S('font'), [S('size'), size, size]]]


def sprop(name, value, x, y, hide=False, angle=0):
    p = [S('property'), name, value, [S('at'), x, y, angle], [S('show_name'), S('no')],
         [S('do_not_autoplace'), S('no')]]
    if hide:
        p.append([S('hide'), S('yes')])
    p.append(fx())
    return p


def pin(num, name, x, y, ang, length=2.54, hidden=False, ptype='passive'):
    p = [S('pin'), S(ptype), S('line'), [S('at'), x, y, ang], [S('length'), length]]
    if hidden:
        p.append([S('hide'), S('yes')])
    p += [[S('name'), name, fx()], [S('number'), num, fx()]]
    return p


def body(name):
    g = [S('symbol'), name + '_0_1',
         [S('rectangle'), [S('start'), -5.08, 3.81], [S('end'), 5.08, -2.54],
          [S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('background')]]],
         # stylised resonator plates
         [S('polyline'), [S('pts'), [S('xy'), -1.27, 2.54], [S('xy'), -1.27, -0.635]],
          [S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
         [S('polyline'), [S('pts'), [S('xy'), 1.27, 2.54], [S('xy'), 1.27, -0.635]],
          [S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
         [S('rectangle'), [S('start'), -0.635, 2.286], [S('end'), 0.635, -0.381],
          [S('stroke'), [S('width'), 0.254], [S('type'), S('default')]], [S('fill'), [S('type'), S('outline')]]],
         [S('polyline'), [S('pts'), [S('xy'), -2.54, 1.27], [S('xy'), -1.27, 1.27]],
          [S('stroke'), [S('width'), 0], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]],
         [S('polyline'), [S('pts'), [S('xy'), 1.27, 1.27], [S('xy'), 2.54, 1.27]],
          [S('stroke'), [S('width'), 0], [S('type'), S('default')]], [S('fill'), [S('type'), S('none')]]]]
    return g


def symbol(name, value, fp, descr, pins, keywords):
    return [S('symbol'), name,
            [S('pin_names'), [S('offset'), 0.254]],
            [S('exclude_from_sim'), S('yes')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],
            [S('in_pos_files'), S('yes')], [S('duplicate_pin_numbers_are_jumpers'), S('no')],
            sprop('Reference', 'FL', 0, 6.35), sprop('Value', value, 0, 5.08),
            sprop('Footprint', fp, 0, -8.89, hide=True), sprop('Datasheet', '', 0, 0, hide=True),
            sprop('Description', descr, 0, 0, hide=True), sprop('ki_keywords', keywords, 0, 0, hide=True),
            body(name),
            [S('symbol'), name + '_1_1'] + pins,
            [S('embedded_fonts'), S('no')]]


def write_symbols():
    s3 = symbol('CeramicFilter_3pin', 'HCCF3-10.700-LTCVS3L', 'AMFM:HCCF3_SMD-3P_7.0x3.0mm',
                '10.7 MHz ceramic band-pass filter, 3 terminals (In/GND/Out), 330 ohm',
                [pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'GND', 0, -5.08, 90), pin('3', 'OUT', 7.62, 1.27, 180)],
                'ceramic filter IF 10.7MHz')
    s5 = symbol('CeramicFilter_5pin', 'LTM455GW', 'AMFM:LTM455xW_5pin',
                '455 kHz ceramic IF filter, 5 leads (In, Out, 3x GND), 2 kohm, +/-4.5 kHz @6 dB',
                [pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'OUT', 7.62, 1.27, 180),
                 pin('3', 'GND', 0, -5.08, 90), pin('4', 'GND', 0, -5.08, 90, hidden=True),
                 pin('5', 'GND', 0, -5.08, 90, hidden=True)],
                'ceramic filter IF 455kHz')
    s4 = symbol('CeramicFilter_4pin', 'HCCF2-455.000-LTWCGL', 'AMFM:HCCF2_SMD-4P_11.9x6.5mm',
                '455 kHz SMD ceramic IF filter, 4 terminals (In, Out, 2x GND), 2 kohm, +/-4.5 kHz',
                [pin('1', 'IN', -7.62, 1.27, 0), pin('2', 'OUT', 7.62, 1.27, 180),
                 pin('3', 'GND', 0, -5.08, 90), pin('4', 'GND', 0, -5.08, 90, hidden=True)],
                'ceramic filter IF 455kHz')
    lib = [S('kicad_symbol_lib'), [S('version'), 20251024], [S('generator'), 'amfm_make_lib'],
           [S('generator_version'), '10.0'], s3, s4, s5]
    with open(os.path.join(LIB, 'AMFM.kicad_sym'), 'w') as f:
        f.write(dumps(lib) + '\n')


def stroke(w):
    return [S('stroke'), [S('width'), w], [S('type'), S('solid')]]


def fp_text_prop(name, value, x, y, layer, hide=False):
    p = [S('property'), name, value, [S('at'), x, y, 0], [S('layer'), layer]]
    if hide:
        p.append([S('hide'), S('yes')])
    p.append([S('effects'), [S('font'), [S('size'), 1, 1], [S('thickness'), 0.15]]])
    return p


def rect(x1, y1, x2, y2, layer, w):
    return [S('fp_rect'), [S('start'), x1, y1], [S('end'), x2, y2], stroke(w), [S('fill'), S('no')],
            [S('layer'), layer]]


def line(x1, y1, x2, y2, layer, w):
    return [S('fp_line'), [S('start'), x1, y1], [S('end'), x2, y2], stroke(w), [S('layer'), layer]]


def tht_pad(num, x, y, size, drill, square=False):
    return [S('pad'), num, S('thru_hole'), S('rect' if square else 'circle'), [S('at'), x, y],
            [S('size'), size, size], [S('drill'), drill], [S('layers'), '*.Cu', '*.Mask'],
            [S('remove_unused_layers'), S('no')]]


def silk_outline(x1, y1, x2, y2, pads, gap=0.25, w=0.12):
    """Rectangle outline on F.SilkS, broken wherever it would cross a pad (+gap)."""
    boxes = []
    for p in pads:
        at = [q for q in p if isinstance(q, list) and q[0] == 'at'][0]
        sz = [q for q in p if isinstance(q, list) and q[0] == 'size'][0]
        boxes.append((at[1] - sz[1] / 2 - gap - w, at[2] - sz[2] / 2 - gap - w,
                      at[1] + sz[1] / 2 + gap + w, at[2] + sz[2] / 2 + gap + w))
    out = []

    def seg(ax, ay, bx, by):
        # split an axis-aligned segment by the pad boxes
        if ay == by:
            lo, hi, fixed, horiz = min(ax, bx), max(ax, bx), ay, True
        else:
            lo, hi, fixed, horiz = min(ay, by), max(ay, by), ax, False
        cuts = []
        for b in boxes:
            if horiz and b[1] <= fixed <= b[3]:
                cuts.append((b[0], b[2]))
            if not horiz and b[0] <= fixed <= b[2]:
                cuts.append((b[1], b[3]))
        cuts.sort()
        pos = lo
        for c0, c1 in cuts:
            if c1 <= pos:
                continue
            if c0 > pos:
                out.append((pos, min(c0, hi)))
            pos = max(pos, c1)
            if pos >= hi:
                break
        if pos < hi:
            out.append((pos, hi))
        res = []
        for a, b in out:
            if b - a > 0.2:
                res.append(line(round(a, 3), fixed, round(b, 3), fixed, 'F.SilkS', w) if horiz
                           else line(fixed, round(a, 3), fixed, round(b, 3), 'F.SilkS', w))
        out.clear()
        return res
    items = []
    items += seg(x1, y1, x2, y1) + seg(x2, y1, x2, y2) + seg(x2, y2, x1, y2) + seg(x1, y2, x1, y1)
    return items


def smd_pad(num, x, y, w, h):
    return [S('pad'), num, S('smd'), S('roundrect'), [S('at'), x, y], [S('size'), w, h],
            [S('layers'), 'F.Cu', 'F.Paste', 'F.Mask'], [S('roundrect_rratio'), 0.1]]


def pad_extent(pads):
    xs, ys = [], []
    for p in pads:
        at = [q for q in p if isinstance(q, list) and q[0] == 'at'][0]
        sz = [q for q in p if isinstance(q, list) and q[0] == 'size'][0]
        xs += [at[1] - sz[1] / 2, at[1] + sz[1] / 2]
        ys += [at[2] - sz[2] / 2, at[2] + sz[2] / 2]
    return min(xs), min(ys), max(xs), max(ys)


def footprint(name, descr, tags, body_box, pads, extra=(), attr='through_hole', silk=True):
    x1, y1, x2, y2 = body_box
    px1, py1, px2, py2 = pad_extent(pads)
    cx1, cy1, cx2, cy2 = (round(min(x1, px1) - 0.25, 2), round(min(y1, py1) - 0.25, 2),
                          round(max(x2, px2) + 0.25, 2), round(max(y2, py2) + 0.25, 2))
    items = [S('footprint'), name, [S('version'), 20260206], [S('generator'), 'amfm_make_lib'],
             [S('layer'), 'F.Cu'], [S('descr'), descr], [S('tags'), tags],
             fp_text_prop('Reference', 'REF**', (x1 + x2) / 2, y1 - 1.5, 'F.SilkS'),
             fp_text_prop('Value', name, (x1 + x2) / 2, y2 + 1.5, 'F.Fab'),
             fp_text_prop('Datasheet', '', 0, 0, 'F.Fab', hide=True),
             fp_text_prop('Description', descr, 0, 0, 'F.Fab', hide=True),
             [S('attr'), S(attr)], [S('duplicate_pad_numbers_are_jumpers'), S('no')],
             rect(x1, y1, x2, y2, 'F.Fab', 0.1),
             rect(cx1, cy1, cx2, cy2, 'F.CrtYd', 0.05),
             [S('fp_text'), S('user'), '${REFERENCE}', [S('at'), (x1 + x2) / 2, (y1 + y2) / 2, 0],
              [S('layer'), 'F.Fab'], [S('effects'), [S('font'), [S('size'), 0.8, 0.8], [S('thickness'), 0.12]]]]]
    if silk:
        items += silk_outline(x1 - 0.12, y1 - 0.12, x2 + 0.12, y2 + 0.12, pads)
    items += list(extra)
    items += pads
    items.append([S('embedded_fonts'), S('no')])
    with open(os.path.join(LIB, 'AMFM.pretty', name + '.kicad_mod'), 'w') as f:
        f.write(dumps(items) + '\n')


def write_footprints_jlc():
    # HCI HCCF3-10.700-LTCVS3L (LCSC C19191232): SMD 7.0 x 3.0 mm, 1=In 2=GND 3=Out (LCSC land pattern)
    footprint('HCCF3_SMD-3P_7.0x3.0mm',
              'HCI HCCF3 10.7 MHz SMD ceramic filter, 7.0 x 3.0 mm, 3 terminals (1=In 2=GND 3=Out), LCSC C19191232',
              'ceramic filter 10.7MHz SMD HCCF3',
              (-3.5, -1.5, 3.5, 1.5),
              [smd_pad('1', -2.97, 0, 1.6, 3.24), smd_pad('2', 0, 0, 1.4, 3.24), smd_pad('3', 2.97, 0, 1.6, 3.24)],
              extra=[line(-3.9, 2.1, -3.9, 1.3, 'F.SilkS', 0.12)], attr='smd')
    # HCI HCCF2-455.000-LTWCGL (LCSC C19186014): SMD 11.9 x 6.5 mm, 1=In 2=Out 3,4=GND
    footprint('HCCF2_SMD-4P_11.9x6.5mm',
              'HCI HCCF2 455 kHz SMD ceramic filter, 11.9 x 6.5 mm (1=In 2=Out 3,4=GND), LCSC C19186014',
              'ceramic filter 455kHz SMD HCCF2 CFWLA',
              (-5.95, -3.25, 5.95, 3.25),
              [smd_pad('1', -2.75, 2.5, 1.6, 3.0), smd_pad('2', 2.75, 2.5, 1.6, 3.0),
               smd_pad('3', 2.75, -2.5, 1.6, 3.0), smd_pad('4', -2.75, -2.5, 1.6, 3.0)],
              extra=[line(-6.3, 3.9, -6.3, 2.2, 'F.SilkS', 0.12)], attr='smd')
    # XKB TS-1187A-B-A-B (LCSC C318884, JLC basic): 5.1 x 5.1 mm SMD tact switch.
    # Pads on the same row are one terminal: (1,1) = A/B, (2,2) = C/D.
    footprint('SW_XKB_TS-1187A_5.1x5.1mm',
              'XKB TS-1187A 5.1 x 5.1 mm SMD tactile switch (LCSC C318884); pads of a row are common',
              'switch tactile SMD TS-1187A',
              (-2.55, -2.55, 2.55, 2.55),
              [smd_pad('1', -3.0, -1.85, 1.0, 0.75), smd_pad('1', 3.0, -1.85, 1.0, 0.75),
               smd_pad('2', -3.0, 1.85, 1.0, 0.75), smd_pad('2', 3.0, 1.85, 1.0, 0.75)],
              attr='smd')


def write_footprints():
    # Murata SFELF10M7 (and SFELA/SFE10.7MA5 family): 3 leads, 2.5 mm pitch; body ~7.0 x 3.0 mm
    footprint('Murata_SFELF_3pin_P2.50mm',
              'Murata CERAFIL SFELF10M7 / SFELA10M7 10.7 MHz ceramic filter, 3 leads, 2.5 mm pitch (1=In 2=GND 3=Out)',
              'ceramic filter 10.7MHz SFELF',
              (-3.5 + 2.5, -1.6, 3.5 + 2.5, 1.6),
              [tht_pad('1', 0, 0, 1.5, 0.8, square=True), tht_pad('2', 2.5, 0, 1.5, 0.8),
               tht_pad('3', 5.0, 0, 1.5, 0.8)],
              extra=[line(-1.2, 1.9, -0.2, 1.9, 'F.SilkS', 0.12)])
    # LTM455xW / Murata CFWM455x: 5 leads, per Transko LTM455W drawing (bottom row 3x GND)
    footprint('LTM455xW_5pin',
              '455 kHz ceramic filter LTM455xW / CFWM455x, 5 leads, 9.5 x 6.5 mm body (1=In 2=Out 3,4,5=GND)',
              'ceramic filter 455kHz LTM455 CFWM455',
              (-0.75, -1.2, 8.75, 5.3),
              [tht_pad('1', 0, 0, 1.6, 0.9, square=True), tht_pad('2', 7.1, 0, 1.6, 0.9),
               tht_pad('3', 2.5, 4.3, 1.6, 0.9), tht_pad('4', 5.2, 4.3, 1.6, 0.9),
               tht_pad('5', 8.0, 4.3, 1.6, 0.9)])


def write_adom_machine():
    """Project copy of Adom's machine parts library (adom/adom-machine-parts-library, KiCad).
    Pads are the official ones; added: Reference/Value fields, silk ring, courtyard and fab outline like
    Adom's example molecules, the STEP model from each component page, and passive pin type (ERC)."""
    import copy
    from sexp import parse, find, findall
    src_sym = os.path.join(HERE, '..', 'build', 'AdomMachine_src.kicad_sym')
    src_fp = os.path.join(HERE, '..', 'build', 'AdomMachine_src.pretty')
    lib = parse(open(src_sym).read())
    out = [S('kicad_symbol_lib'), [S('version'), 20251024], [S('generator'), 'amfm_make_lib'],
           [S('generator_version'), '10.0']]
    for sym in findall(lib, 'symbol'):
        sym = copy.deepcopy(sym)

        def fix(node):
            for it in node:
                if isinstance(it, list):
                    if it and it[0] == 'pin' and len(it) > 1 and it[1] == 'unspecified':
                        it[1] = S('passive')
                    fix(it)
        fix(sym)
        out.append(sym)
    with open(os.path.join(LIB, 'AdomMachine.kicad_sym'), 'w') as f:
        f.write(dumps(out) + '\n')
    os.makedirs(os.path.join(LIB, 'AdomMachine.pretty'), exist_ok=True)
    # name, silk ring radius, courtyard radius (= pincer keep-out, as on the component pages), STEP model
    for name, ring, crt, model in (('MachinePinLargeStandard', 2.7, 3.0, 'MachinePinLargeStandard.step'),
                                   ('MachinePinMediumStandard', 0.95, 1.0, 'MachinePinMediumStandard.step'),
                                   ('MachineContactMedium', 0.8, 1.0, 'MachineContactMedium.step')):
        fp = parse(open(os.path.join(src_fp, name + '.kicad_mod')).read())
        pad = find(fp, 'pad')
        items = [S('footprint'), name, [S('version'), 20260206], [S('generator'), 'amfm_make_lib'],
                 [S('layer'), 'F.Cu'],
                 [S('descr'), 'Adom %s (adom/adom-machine-parts-library); press-fit by the Adom Factory' % name],
                 [S('tags'), 'adom molecule machine pin contact'],
                 fp_text_prop('Reference', 'REF**', 0, 0, 'F.Fab'),
                 fp_text_prop('Value', name, 0, 1.6, 'F.Fab', hide=True),
                 fp_text_prop('Datasheet', 'https://wiki.adom.inc/adom/' + name.lower(), 0, 0, 'F.Fab', hide=True),
                 fp_text_prop('Description', 'Adom ' + name, 0, 0, 'F.Fab', hide=True),
                 [S('attr'), S('through_hole')], [S('duplicate_pad_numbers_are_jumpers'), S('no')],
                 [S('fp_circle'), [S('center'), 0, 0], [S('end'), ring, 0], stroke(0.12), [S('fill'), S('no')],
                  [S('layer'), 'F.SilkS']],
                 [S('fp_circle'), [S('center'), 0, 0], [S('end'), crt, 0], stroke(0.05), [S('fill'), S('no')],
                  [S('layer'), 'F.CrtYd']],
                 [S('fp_circle'), [S('center'), 0, 0], [S('end'), crt, 0], stroke(0.05), [S('fill'), S('no')],
                  [S('layer'), 'B.CrtYd']],
                 rect(-crt, -crt, crt, crt, 'F.Fab', 0.05),
                 pad,
                 [S('model'), '${KIPRJMOD}/lib/AdomMachine.3dshapes/' + model,
                  [S('offset'), [S('xyz'), 0, 0, 0]], [S('scale'), [S('xyz'), 1, 1, 1]],
                  [S('rotate'), [S('xyz'), 0, 0, 0]]],
                 [S('embedded_fonts'), S('no')]]
        with open(os.path.join(LIB, 'AdomMachine.pretty', name + '.kicad_mod'), 'w') as f:
            f.write(dumps(items) + '\n')


QFN_SRC = '/usr/share/kicad/footprints/Package_DFN_QFN.pretty/QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias.kicad_mod'
QFN_NAME = 'QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias0.3'


def write_qfn_thermal():
    """KiCad's 7x7 QFN-48 thermal-via land pattern, reworked for this board's 0.3 mm minimum drill: keep the
    inner 3 x 3 vias of the 5 x 5 grid (the paste windows were drawn around them) as 0.6 / 0.3 mm vias."""
    from sexp import parse, find
    fp = parse(open(QFN_SRC).read())
    fp[1] = QFN_NAME
    out, kept = [], 0
    for it in fp:
        if isinstance(it, list) and it and it[0] == 'pad' and it[1] == '49' and it[2] == 'thru_hole':
            at = find(it, 'at')
            if max(abs(float(at[1])), abs(float(at[2]))) > 2.0:
                continue
            find(it, 'size')[1:3] = [0.6, 0.6]
            find(it, 'drill')[1] = 0.3
            kept += 1
        out.append(it)
    assert kept == 9, kept
    with open(os.path.join(LIB, 'AMFM.pretty', QFN_NAME + '.kicad_mod'), 'w') as f:
        f.write(dumps(out) + '\n')

# 3D models for the project footprints the KiCad library has no model for (LCSC/EasyEDA models, fetched with
# easyeda2kicad and aligned to our pads): footprint -> (model file in lib/AMFM.3dshapes, offset xyz, rotate xyz).
# Note: KiCad stores model Z rotation with the opposite sign to the usual CCW-positive maths angle.
MODELS = {
    'USB_C_Receptacle_HRO_TYPE-C-31-M-12_EdgeMount': ('USB_C_HRO_TYPE-C-31-M-12.wrl', (0, -0.98, 0), (0, 0, 180)),   # offset set by Noah in KiCad, 2026-10-02
    'SMA_BAT_Wireless_BWSMA-KWE-Z001_Edge': ('SMA_BWSMA-KWE-Z001.wrl', (0, 0, 0), (0, 0, 270)),
    'HCCF3_SMD-3P_7.0x3.0mm': ('HCCF3_SMD-3P_7.0x3.0mm.wrl', (0, 0, 0), (0, 0, 0)),
    'HCCF2_SMD-4P_11.9x6.5mm': ('HCCF2_SMD-4P_11.9x6.5mm.wrl', (0, 0, 0), (0, 0, 0)),
    QFN_NAME: ('UFQFPN-48_7x7mm_P0.5mm.wrl', (0, 0, 0), (0, 0, 0)),
}


def model_node(fname, offset, rotate):
    return [S('model'), '${KIPRJMOD}/lib/AMFM.3dshapes/' + fname,
            [S('offset'), [S('xyz')] + list(offset)], [S('scale'), [S('xyz'), 1, 1, 1]],
            [S('rotate'), [S('xyz')] + list(rotate)]]


def attach_models():
    """Point each listed project footprint at its 3D model (replacing any stale KiCad-library reference)."""
    from sexp import parse
    for name, (fname, off, rot) in MODELS.items():
        path = os.path.join(LIB, 'AMFM.pretty', name + '.kicad_mod')
        fp = parse(open(path).read())
        fp = [it for it in fp if not (isinstance(it, list) and it and it[0] == 'model')]
        fp.append(model_node(fname, off, rot))
        with open(path, 'w') as f:
            f.write(dumps(fp) + '\n')


if __name__ == '__main__':
    os.makedirs(os.path.join(LIB, 'AMFM.pretty'), exist_ok=True)
    write_symbols()
    write_footprints()
    write_footprints_jlc()
    write_adom_machine()
    write_qfn_thermal()
    attach_models()
    print('library written to', os.path.abspath(LIB))