← Commit history

amfm-receiver-molecule: KiCad project, fab files, 3D and README (7/7)

noah ·ef18fb32b1 ·1h ago ·parent 53707c4
7 files changed +949−73
package.jsonadded+153
@@ -0,0 +1,153 @@+{+ "slug": "amfm-receiver-molecule",+ "title": "AM/FM Receiver Molecule",+ "version": "0.1.0",+ "type": "hardware",+ "kind": "molecule",+ "description": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",+ "tags": [+  "molecule",+  "board",+  "kicad",+  "radio",+  "am",+  "fm",+  "superhet",+  "stm32g0b1",+  "usb-audio",+  "adom-machine-pins",+  "jlcpcb"+ ],+ "license": "CC-BY-SA-4.0",+ "component": {+  "mpn": "amfm-receiver-molecule",+  "manufacturer": "Adom",+  "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",+  "category": "board",+  "model_3d_path": "amfm-receiver-molecule.glb"+ },+ "dependencies": {+  "adom/samsung-cl21a-x5r-0805": "^0.1.0",+  "adom/cc0603-100nf": "^0.1.0",+  "adom/cl10a105kb8nnnc": "^1.0.0",+  "adom/fenghua-0603-mlcc": "^0.1.0",+  "adom/cl10c180jb8nnnc": "^1.0.0",+  "adom/murata-grm18-gcm18-c0g-0603": "^0.1.0",+  "adom/samsung-cl10c-c0g-0603": "^0.1.0",+  "adom/cl10b102kb8nnnc": "^1.0.0",+  "adom/yageo-cc0603-npo": "^0.1.0",+  "adom/0603n222j500ct": "^0.1.0",+  "barrett-land/kt-0603r": "^0.1.0",+  "adom/b5819w": "^0.1.0",+  "adom/kt-0603g": "^0.1.0",+  "adom/kt-0603y": "^0.1.0",+  "barrett-land/kt-0603b": "^0.1.0",+  "adom/bb910": "^0.1.0",+  "adom/bat54ws-7-f": "^0.1.0",+  "adom/bat54s": "^0.1.0",+  "adom/nsmd050-33v": "^0.1.0",+  "adom/gz1608d601tf": "^0.1.0",+  "adom/hccf3-10-700-ltcvs3l": "^0.1.0",+  "adom/hccf2-455-000-ltwcgl": "^0.1.0",+  "adom/type-c-31-m-12": "^1.0.0",+  "adom/bwsma-kwe-z001": "^0.1.0",+  "adom/nlv32t-2r2j-pf": "^0.1.0",+  "adom/yscm322522-8r2j": "^0.1.0",+  "adom/murata-lqw18an": "^0.1.0",+  "adom/mmbt3906": "^0.1.0",+  "adom/mmbth10": "^0.1.0",+  "adom/mmbt3904": "^0.1.0",+  "adom/bc847bs": "^0.1.0",+  "adom/0603waf5101t5e": "^1.0.0",+  "adom/0603waf1002t5e": "^1.0.0",+  "adom/uniroyal-0603waf": "^0.1.0",+  "adom/0603waf1001t5e": "^1.0.0",+  "adom/0603waf1000t5e": "^1.0.0",+  "adom/0603waf4701t5e": "^1.0.0",+  "adom/0603waf4702t5e": "^1.0.0",+  "adom/0603waf5102t5e": "^1.0.0",+  "ray/usblc6-2sc6": "^0.1.0",+  "adom/ap2112k-3-3trg1": "^1.0.0",+  "adom/stm32g0b1ccu7": "^0.1.0",+  "adom/tlv9062idr": "^0.1.0",+  "adom/sx32y040000bc1t001": "^0.1.0",+  "adom/0132m4-10-240f12dzng": "^0.1.0",+  "adom/machinecontactmedium": "^1.2.3",+  "adom/machinepinlargestandard": "^1.2.3"+ },+ "hero": {+  "type": "image",+  "path": "docs/hero.png"+ },+ "visibility": "public",+ "files": [+  "LICENSE",+  "README.md",+  "amfm-receiver-molecule.glb",+  "amfm-receiver-molecule_footprint.json",+  "amfm-receiver-molecule_symbol.json",+  "docs/hero.png",+  "fab/amfm_receiver_BOM.csv",+  "fab/amfm_receiver_BOM_JLCPCB.csv",+  "fab/amfm_receiver_CPL_JLCPCB.csv",+  "fab/amfm_receiver_gerbers.zip",+  "fab/amfm_receiver_pos.csv",+  "fab/amfm_receiver_schematic.pdf",+  "kicad/am_frontend.kicad_sch",+  "kicad/am_if.kicad_sch",+  "kicad/amfm_receiver.kicad_pcb",+  "kicad/amfm_receiver.kicad_pro",+  "kicad/amfm_receiver.kicad_sch",+  "kicad/fm_frontend.kicad_sch",+  "kicad/fm_if.kicad_sch",+  "kicad/fp-lib-table",+  "kicad/if_10m7.kicad_sch",+  "kicad/lib/AMFM.3dshapes/HCCF2_SMD-4P_11.9x6.5mm.step",+  "kicad/lib/AMFM.3dshapes/HCCF3_SMD-3P_7.0x3.0mm.step",+  "kicad/lib/AMFM.3dshapes/SMA_BWSMA-KWE-Z001.step",+  "kicad/lib/AMFM.3dshapes/UFQFPN-48_7x7mm_P0.5mm.step",+  "kicad/lib/AMFM.3dshapes/USB_C_HRO_TYPE-C-31-M-12.step",+  "kicad/lib/AMFM.kicad_sym",+  "kicad/lib/AMFM.pretty/HCCF2_SMD-4P_11.9x6.5mm.kicad_mod",+  "kicad/lib/AMFM.pretty/HCCF3_SMD-3P_7.0x3.0mm.kicad_mod",+  "kicad/lib/AMFM.pretty/LTM455xW_5pin.kicad_mod",+  "kicad/lib/AMFM.pretty/Murata_SFELF_3pin_P2.50mm.kicad_mod",+  "kicad/lib/AMFM.pretty/QFN-48-1EP_7x7mm_P0.5mm_EP5.6x5.6mm_ThermalVias0.3.kicad_mod",+  "kicad/lib/AMFM.pretty/SMA_BAT_Wireless_BWSMA-KWE-Z001_Edge.kicad_mod",+  "kicad/lib/AMFM.pretty/SW_XKB_TS-1187A_5.1x5.1mm.kicad_mod",+  "kicad/lib/AMFM.pretty/USB_C_Receptacle_HRO_TYPE-C-31-M-12_EdgeMount.kicad_mod",+  "kicad/lib/AdomMachine.3dshapes/MachineContactMedium.step",+  "kicad/lib/AdomMachine.3dshapes/MachinePinLargeStandard.step",+  "kicad/lib/AdomMachine.3dshapes/MachinePinMediumStandard.step",+  "kicad/lib/AdomMachine.kicad_sym",+  "kicad/lib/AdomMachine.pretty/MachineContactMedium.kicad_mod",+  "kicad/lib/AdomMachine.pretty/MachinePinLargeStandard.kicad_mod",+  "kicad/lib/AdomMachine.pretty/MachinePinMediumStandard.kicad_mod",+  "kicad/molecule_io.kicad_sch",+  "kicad/power_mcu.kicad_sch",+  "kicad/sym-lib-table",+  "kicad/tuning_ui.kicad_sch",+  "tools/audit_jlc.py",+  "tools/check_sch.py",+  "tools/design.py",+  "tools/drc_report.py",+  "tools/floorplan.py",+  "tools/gen_pcb.py",+  "tools/gen_sch.py",+  "tools/jlc.py",+  "tools/make_bom.py",+  "tools/make_fab.sh",+  "tools/make_jlc.py",+  "tools/make_lib.py",+  "tools/pcbgeom.py",+  "tools/placer.py",+  "tools/route_fix.py",+  "tools/schgen.py",+  "tools/select_parts.py",+  "tools/sexp.py",+  "tools/symlib.py",+  "verification.json",+  "package.json",+  "page.json"+ ]+}\ No newline at end of file
page.json+115−73
@@ -1,76 +1,118 @@ {-  "schema_version": 1,-  "slug": "amfm-receiver-molecule",-  "type": "component",-  "kind": "molecule",-  "title": "AM/FM Receiver Molecule",-  "brief": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",-  "version": "0.1.0",-  "because": "No wiki page holds this board: it is a new design (the AM/FM receiver built in this project), and its parts link to their own component pages.",-  "hero": {-    "type": "billboard",-    "headline": "AM/FM Receiver Molecule",-    "subhead": "Discrete superhet radio with STM32 USB audio, tuning and DFU over one USB-C cable",-    "screenshot": "docs/hero.png",-    "chrome": false+ "schema_version": 1,+ "type": "hardware",+ "kind": "molecule",+ "slug": "amfm-receiver-molecule",+ "title": "AM/FM Receiver Molecule",+ "brief": "USB-powered AM/FM receiver on an Adom Molecule: discrete BJT and op-amp superhet (10.7 MHz and 455 kHz IFs), STM32G0B1 tuning, self-calibration and USB audio over one USB-C cable. 96 x 96 mm pin grid, 4 layers, KiCad 10.",+ "version": "0.1.0",+ "license": "CC-BY-SA-4.0",+ "tags": [+  "molecule",+  "board",+  "kicad",+  "radio",+  "am",+  "fm",+  "superhet",+  "stm32g0b1",+  "usb-audio",+  "adom-machine-pins",+  "jlcpcb"+ ],+ "component": {+  "mpn": "amfm-receiver-molecule",+  "manufacturer": "Adom",+  "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",+  "category": "board",+  "model_3d_path": "amfm-receiver-molecule.glb",+  "parts": {+   "model_3d": "amfm-receiver-molecule.glb",+   "footprint_json": "amfm-receiver-molecule_footprint.json",+   "symbol_json": "amfm-receiver-molecule_symbol.json",+   "schematic": "fab/amfm_receiver_schematic.pdf",+   "board": "kicad/amfm_receiver.kicad_pcb",+   "gerbers": "fab/amfm_receiver_gerbers.zip",+   "bom": "fab/amfm_receiver_BOM_JLCPCB.csv",+   "cpl": "fab/amfm_receiver_CPL_JLCPCB.csv"   },-  "component": {-    "mpn": "amfm-receiver-molecule",-    "manufacturer": "Adom",-    "package": "PCBA 102.5 x 102.5 mm, 96 x 96 mm large-pin grid, 4-layer",-    "pin_count": null,-    "category": "board",-    "subcategory": "",-    "body_size": null,-    "parts": {-      "model_3d": "amfm-receiver-molecule.glb",-      "footprint_json": "amfm-receiver-molecule_footprint.json",-      "symbol_json": "amfm-receiver-molecule_symbol.json",-      "schematic": "fab/amfm_receiver_schematic.pdf",-      "board": "kicad/amfm_receiver.kicad_pcb",-      "gerbers": "fab/amfm_receiver_gerbers.zip",-      "bom": "fab/amfm_receiver_BOM_JLCPCB.csv",-      "cpl": "fab/amfm_receiver_CPL_JLCPCB.csv"-    },-    "distributor_links": {},-    "model_3d_path": "amfm-receiver-molecule.glb",-    "source": {-      "tool": "kicad",-      "version": "10.0"-    }-  },-  "tags": [-    "molecule",-    "board",-    "kicad",-    "radio",-    "am",-    "fm",-    "superhet",-    "stm32g0b1",-    "usb-audio",-    "adom-machine-pins",-    "jlcpcb"-  ],-  "discovery_triggers": [-    "am fm receiver",-    "am/fm radio molecule",-    "discrete superhet radio",-    "stm32 usb audio radio",-    "fm radio pcb"-  ],-  "license": "CC-BY-SA-4.0",-  "visibility": {-    "public": true-  },-  "author": {-    "name": "noah",-    "email": "[email protected]"-  },-  "sample_prompts": [],-  "discovery_pitch": null,-  "metadata": {},-  "created_at": "2026-10-08T16:04:27.311Z",-  "updated_at": "2026-10-08T16:04:27.311Z",-  "org": "adom"+  "source": {+   "tool": "kicad",+   "version": "10.0"+  }+ },+ "discovery_triggers": [+  "am fm receiver",+  "am/fm radio molecule",+  "discrete superhet radio",+  "stm32 usb audio radio",+  "fm radio pcb"+ ],+ "discovery_pitch": "AM/FM receiver molecule: discrete superhet radio with STM32 USB audio, tuning and DFU over one USB-C cable.",+ "author": {+  "name": "Noah",+  "email": "[email protected]"+ },+ "dependencies": {+  "adom/samsung-cl21a-x5r-0805": "^0.1.0",+  "adom/cc0603-100nf": "^0.1.0",+  "adom/cl10a105kb8nnnc": "^1.0.0",+  "adom/fenghua-0603-mlcc": "^0.1.0",+  "adom/cl10c180jb8nnnc": "^1.0.0",+  "adom/murata-grm18-gcm18-c0g-0603": "^0.1.0",+  "adom/samsung-cl10c-c0g-0603": "^0.1.0",+  "adom/cl10b102kb8nnnc": "^1.0.0",+  "adom/yageo-cc0603-npo": "^0.1.0",+  "adom/0603n222j500ct": "^0.1.0",+  "barrett-land/kt-0603r": "^0.1.0",+  "adom/b5819w": "^0.1.0",+  "adom/kt-0603g": "^0.1.0",+  "adom/kt-0603y": "^0.1.0",+  "barrett-land/kt-0603b": "^0.1.0",+  "adom/bb910": "^0.1.0",+  "adom/bat54ws-7-f": "^0.1.0",+  "adom/bat54s": "^0.1.0",+  "adom/nsmd050-33v": "^0.1.0",+  "adom/gz1608d601tf": "^0.1.0",+  "adom/hccf3-10-700-ltcvs3l": "^0.1.0",+  "adom/hccf2-455-000-ltwcgl": "^0.1.0",+  "adom/type-c-31-m-12": "^1.0.0",+  "adom/bwsma-kwe-z001": "^0.1.0",+  "adom/nlv32t-2r2j-pf": "^0.1.0",+  "adom/yscm322522-8r2j": "^0.1.0",+  "adom/murata-lqw18an": "^0.1.0",+  "adom/mmbt3906": "^0.1.0",+  "adom/mmbth10": "^0.1.0",+  "adom/mmbt3904": "^0.1.0",+  "adom/bc847bs": "^0.1.0",+  "adom/0603waf5101t5e": "^1.0.0",+  "adom/0603waf1002t5e": "^1.0.0",+  "adom/uniroyal-0603waf": "^0.1.0",+  "adom/0603waf1001t5e": "^1.0.0",+  "adom/0603waf1000t5e": "^1.0.0",+  "adom/0603waf4701t5e": "^1.0.0",+  "adom/0603waf4702t5e": "^1.0.0",+  "adom/0603waf5102t5e": "^1.0.0",+  "ray/usblc6-2sc6": "^0.1.0",+  "adom/ap2112k-3-3trg1": "^1.0.0",+  "adom/stm32g0b1ccu7": "^0.1.0",+  "adom/tlv9062idr": "^0.1.0",+  "adom/sx32y040000bc1t001": "^0.1.0",+  "adom/0132m4-10-240f12dzng": "^0.1.0",+  "adom/machinecontactmedium": "^1.2.3",+  "adom/machinepinlargestandard": "^1.2.3"+ },+ "hero": {+  "type": "image",+  "path": "docs/hero.png"+ },+ "sections": [+  "hero",+  "viewers",+  "readme",+  "deps",+  "breadcrumbs",+  "info"+ ],+ "visibility": "public" }\ No newline at end of file
tools/schgen.pyadded+373
@@ -0,0 +1,373 @@+"""KiCad 10 schematic writer: places symbols, wire stubs, labels, power ports.++Coordinates are in mm, schematic space (+y down).  All placement is snapped to+the 1.27 mm grid so that KiCad sees the connections.+"""+import math, uuid as _uuid+from collections import OrderedDict+from sexp import Sym, dumps, find, findall+import symlib++S = Sym+GRID = 1.27+NS = _uuid.UUID('6f1d3c2a-9a51-4a0e-bb52-2f0c0de4a7f0')+++def uid(*parts):+    return str(_uuid.uuid5(NS, '/'.join(str(p) for p in parts)))+++def snap(v):+    return round(round(v / GRID) * GRID, 4)+++def rot(dx, dy, r):+    """Rotate a screen-space vector by r degrees counter-clockwise (visual)."""+    r %= 360+    if r == 0:+        return dx, dy+    if r == 90:+        return dy, -dx+    if r == 180:+        return -dx, -dy+    if r == 270:+        return -dy, dx+    raise ValueError(r)+++def font(size=1.27, bold=False, italic=False):+    f = [S('font'), [S('size'), size, size]]+    if bold:+        f.append([S('bold'), S('yes')])+    if italic:+        f.append([S('italic'), S('yes')])+    return f+++def effects(size=1.27, justify=None, bold=False):+    e = [S('effects'), font(size, bold)]+    if justify:+        e.append([S('justify')] + [S(j) for j in justify.split()])+    return e+++def prop(name, value, x, y, angle=0, hide=False, justify=None, size=1.27):+    p = [S('property'), name, value, [S('at'), x, y, angle]]+    if hide:+        p.append([S('hide'), S('yes')])+    p.append(effects(size, justify))+    return p+++POWER_UP = {'+5V', '+3V3', 'VBUS', '+5VA', '+3.3VA'}+POWER_DOWN = {'GND'}+POWER_LIB = {'+5V': 'power:+5V', '+3V3': 'power:+3V3', 'VBUS': 'power:VBUS',+             '+5VA': 'power:+5VA', '+3.3VA': 'power:+3.3VA', 'GND': 'power:GND'}++++DIRV = {'R': (1, 0), 'L': (-1, 0), 'U': (0, -1), 'D': (0, 1)}+++def plan_runs(part, pins_here, net_of_pin, stub):+    """Return (no_connect points, runs).  run = (side, members, stub_len, stub_ends, anchor, net)."""+    by_loc = OrderedDict()+    for p, ax, ay, d in pins_here:+        by_loc.setdefault((ax, ay), []).append((p, d))+    ends, ncs = [], []+    for (ax, ay), plist in by_loc.items():+        nets = set()+        for p, d in plist:+            n = net_of_pin.get(p['number'])+            if n is not None:+                nets.add(n)+        visible = [pd for pd in plist if not pd[0]['hidden']]+        if len(nets) > 1:+            raise ValueError('%s: stacked pins at same location on different nets %s' % (part.ref, nets))+        if not visible:+            if nets:+                raise ValueError('%s: hidden pin(s) %s carry net %s but have no visible partner'+                                 % (part.ref, [p['number'] for p, _ in plist], nets))+            continue+        p, d = visible[0]+        if not nets:+            if p['type'] != 'no_connect':+                ncs.append((ax, ay))+            continue+        ends.append((ax, ay, d, nets.pop()))+    runs = []+    for side in ('L', 'R', 'U', 'D'):+        sp = [e for e in ends if e[2] == side]+        if not sp:+            continue+        along = (lambda e: e[1]) if side in ('L', 'R') else (lambda e: e[0])+        perp = (lambda e: e[0]) if side in ('L', 'R') else (lambda e: e[1])+        sp.sort(key=lambda e: (perp(e), along(e)))+        cur = []+        for e in sp:+            if cur and cur[-1][3] == e[3] and perp(cur[-1]) == perp(e) and \+                    abs(along(e) - along(cur[-1])) <= 2.54 + 1e-6:+                cur.append(e)+            else:+                if cur:+                    runs.append([side, cur])+                cur = [e]+        if cur:+            runs.append([side, cur])+    out = []+    for side in ('L', 'R', 'U', 'D'):+        rs = [r for r in runs if r[0] == side]+        along = (lambda e: e[1]) if side in ('L', 'R') else (lambda e: e[0])+        rs.sort(key=lambda r: min(along(e) for e in r[1]))+        prev_end, prev_long = None, False+        for side_, members in rs:+            net = members[0][3]+            is_pwr = net in POWER_LIB+            lo = min(along(e) for e in members)+            hi = max(along(e) for e in members)+            long_ = bool(is_pwr and prev_end is not None and lo - prev_end <= 5.08 + 1e-6 and not prev_long)+            L = stub * 3 if long_ else stub+            if is_pwr:+                prev_end, prev_long = hi, long_+            else:+                prev_end, prev_long = None, False+            dx, dy = DIRV[side]+            stub_ends = [(snap(m[0] + dx * L), snap(m[1] + dy * L)) for m in members]+            if side in ('L', 'R'):+                anchor = min(stub_ends, key=lambda t: t[1])+            else:+                anchor = min(stub_ends, key=lambda t: t[0])+            out.append((side, members, L, stub_ends, anchor, net))+    return ncs, out+++class LibCache:+    def __init__(self, extra_libs=None):+        self.extra = extra_libs or {}+        self.syms = {}++    def get(self, lib_id):+        if lib_id not in self.syms:+            self.syms[lib_id] = symlib.get_symbol(lib_id, self.extra)+        return self.syms[lib_id]++    def pins(self, lib_id):+        return symlib.pins(self.get(lib_id))++    def lib_entry(self, lib_id):+        """Symbol definition for the schematic's lib_symbols section."""+        import copy+        s = copy.deepcopy(self.get(lib_id))+        s[1] = lib_id+        return s+++class SheetWriter:+    def __init__(self, project, root_uuid, sheet_path, file_uuid, libs, title='', paper='A3',+                 page_label=''):+        self.project = project+        self.root_uuid = root_uuid+        self.sheet_path = sheet_path          # e.g. "/<root>/<sheet>" or "/<root>"+        self.file_uuid = file_uuid+        self.libs = libs+        self.items = []+        self.used = set()+        self.title = title+        self.paper = paper+        self.page_label = page_label+        self.pwr_count = 0+        self.flag_count = 0+        self.global_nets = set()+        self.pin_net_seen = {}                # debug: (ref, pin) -> net+        self.label_points = []++    # ---------------------------------------------------------------- primitives+    def wire(self, x1, y1, x2, y2):+        self.items.append([S('wire'), [S('pts'), [S('xy'), x1, y1], [S('xy'), x2, y2]],+                           [S('stroke'), [S('width'), 0], [S('type'), S('default')]],+                           [S('uuid'), uid(self.file_uuid, 'w', x1, y1, x2, y2)]])++    def no_connect(self, x, y):+        self.items.append([S('no_connect'), [S('at'), x, y], [S('uuid'), uid(self.file_uuid, 'nc', x, y)]])++    def text(self, s, x, y, size=1.27, bold=False, angle=0):+        self.items.append([S('text'), s, [S('exclude_from_sim'), S('no')], [S('at'), x, y, angle],+                           effects(size, 'left bottom', bold), [S('uuid'), uid(self.file_uuid, 't', s, x, y)]])++    def rect(self, x1, y1, x2, y2, dash=True):+        self.items.append([S('rectangle'), [S('start'), x1, y1], [S('end'), x2, y2],+                           [S('stroke'), [S('width'), 0.254], [S('type'), S('dash' if dash else 'default')]],+                           [S('fill'), [S('type'), S('none')]],+                           [S('uuid'), uid(self.file_uuid, 'r', x1, y1, x2, y2)]])++    def label(self, net, x, y, direction, is_global):+        """direction: outward screen direction 'R','L','U','D' of the stub end."""+        angle = {'R': 0, 'U': 90, 'L': 180, 'D': 270}[direction]+        just = {'R': 'left', 'U': 'left', 'L': 'right', 'D': 'right'}[direction]+        self.label_points.append((x, y, net))+        if is_global:+            self.global_nets.add(net)+            self.items.append([S('global_label'), net, [S('shape'), S('bidirectional')],+                               [S('at'), x, y, angle], [S('fields_autoplaced'), S('yes')],+                               effects(1.27, just), [S('uuid'), uid(self.file_uuid, 'gl', net, x, y)],+                               [S('property'), 'Intersheetrefs', '${INTERSHEET_REFS}', [S('at'), x, y, 0],+                                [S('hide'), S('yes')], effects(1.27, just)]])+        else:+            self.items.append([S('label'), net, [S('at'), x, y, angle], [S('fields_autoplaced'), S('yes')],+                               effects(1.27, just + ' bottom'), [S('uuid'), uid(self.file_uuid, 'l', net, x, y)]])++    def power_port(self, net, x, y, direction):+        lib_id = POWER_LIB[net]+        self.used.add(lib_id)+        self.pwr_count += 1+        ref = '#PWR%s%03d' % (self.page_label, self.pwr_count)+        # natural orientation (supply up, ground down) unless that would fold back onto the stub+        if net in POWER_DOWN:+            r = 180 if direction == 'U' else 0+        else:+            r = 180 if direction == 'D' else 0+        su = uid(self.file_uuid, 'pwr', ref)+        # value text placed beyond the symbol+        vx, vy = rot(0, -3.2 if net not in POWER_DOWN else 3.8, r)+        self.items.append([S('symbol'), [S('lib_id'), lib_id], [S('at'), x, y, r], [S('unit'), 1],+                           [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+                           [S('dnp'), S('no')], [S('uuid'), su],+                           prop('Reference', ref, x, y, 0, hide=True),+                           prop('Value', net, snap(x + vx), snap(y + vy), 0),+                           prop('Footprint', '', x, y, 0, hide=True),+                           prop('Datasheet', '', x, y, 0, hide=True),+                           prop('Description', '', x, y, 0, hide=True),+                           [S('pin'), '1', [S('uuid'), uid(su, 'p1')]],+                           [S('instances'), [S('project'), self.project,+                                             [S('path'), self.sheet_path, [S('reference'), ref], [S('unit'), 1]]]]])++    def pwr_flag(self, net, x, y):+        """PWR_FLAG attached by a short stub + power port / label."""+        self.used.add('power:PWR_FLAG')+        self.flag_count += 1+        ref = '#FLG%s%02d' % (self.page_label, self.flag_count)+        su = uid(self.file_uuid, 'flg', ref)+        self.items.append([S('symbol'), [S('lib_id'), 'power:PWR_FLAG'], [S('at'), x, y, 0], [S('unit'), 1],+                           [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+                           [S('dnp'), S('no')], [S('uuid'), su],+                           prop('Reference', ref, x, y - 5, 0, hide=True),+                           prop('Value', 'PWR_FLAG', x, y - 3.8, 0),+                           prop('Footprint', '', x, y, 0, hide=True),+                           prop('Datasheet', '', x, y, 0, hide=True),+                           prop('Description', '', x, y, 0, hide=True),+                           [S('pin'), '1', [S('uuid'), uid(su, 'p1')]],+                           [S('instances'), [S('project'), self.project,+                                             [S('path'), self.sheet_path, [S('reference'), ref], [S('unit'), 1]]]]])+        # stub down to the net connection+        self.wire(x, y, x, y + 2.54)+        self.attach_net(net, x, y + 2.54, 'D', is_global=True)++    def attach_net(self, net, x, y, direction, is_global):+        if net in POWER_LIB:+            self.power_port(net, x, y, direction)+        else:+            self.label(net, x, y, direction, is_global)++    # ---------------------------------------------------------------- symbols+    def pin_geometry(self, lib_id, unit, x, y, r):+        """Yield (pin dict, abs_x, abs_y, outward_direction)."""+        out = []+        for p in self.libs.pins(lib_id):+            if p['unit'] not in (0, unit) or p['convert'] not in (0, 1):+                continue+            dx, dy = rot(p['x'], -p['y'], r)+            ax, ay = snap(x + dx), snap(y + dy)+            a = math.radians(p['angle'] + 180)+            ox, oy = rot(round(math.cos(a)), -round(math.sin(a)), r)+            d = {(1, 0): 'R', (-1, 0): 'L', (0, -1): 'U', (0, 1): 'D'}[(ox, oy)]+            out.append((p, ax, ay, d))+        return out++    def place(self, part, unit, x, y, r, net_of_pin, is_global, stub=2.54, ref_pos=None, val_pos=None,+              show_value=True):+        """Place a symbol unit and wire every visible pin to its net (label/power port) or NC flag."""+        lib_id = part.lib_id+        self.used.add(lib_id)+        su = uid(self.file_uuid, 'sym', part.ref, unit)+        # Property text positions (default: to the right of the symbol)+        rx, ry, ra, rj = ref_pos if ref_pos else (x + 3.0, y - 1.3, 0, 'left')+        vx, vy, va, vj = val_pos if val_pos else (x + 3.0, y + 1.3, 0, 'left')+        props = [prop('Reference', part.ref, round(rx, 3), round(ry, 3), ra, justify=rj),+                 prop('Value', part.value, round(vx, 3), round(vy, 3), va, justify=vj, hide=not show_value),+                 prop('Footprint', part.footprint, x, y, 0, hide=True),+                 prop('Datasheet', part.datasheet or '', x, y, 0, hide=True),+                 prop('Description', part.description or '', x, y, 0, hide=True)]+        for k, v in part.fields.items():+            props.append(prop(k, v, x, y, 0, hide=True))+        pins_here = self.pin_geometry(lib_id, unit, x, y, r)+        pin_entries = []+        seen_numbers = set()+        for p, ax, ay, d in pins_here:+            if p['number'] not in seen_numbers:+                pin_entries.append([S('pin'), p['number'], [S('uuid'), uid(su, 'pin', p['number'])]])+                seen_numbers.add(p['number'])+        self.items.append([S('symbol'), [S('lib_id'), lib_id], [S('at'), x, y, r], [S('unit'), unit],+                           [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes' if part.in_bom else 'no')],+                           [S('on_board'), S('yes')], [S('dnp'), S('no')],+                           [S('uuid'), su]] + props + pin_entries ++                          [[S('instances'), [S('project'), self.project,+                                             [S('path'), self.sheet_path, [S('reference'), part.ref],+                                              [S('unit'), unit]]]]])+        self.connect_pins(part, pins_here, net_of_pin, is_global, stub)+        return pins_here++    def connect_pins(self, part, pins_here, net_of_pin, is_global, stub):+        ncs, runs = plan_runs(part, pins_here, net_of_pin, stub)+        for ax, ay in ncs:+            self.no_connect(ax, ay)+        for side, members, L, stub_ends, (ex, ey), net in runs:+            for (ax, ay, d, n), (sx, sy) in zip(members, [(snap(m[0] + DIRV[side][0] * L),+                                                           snap(m[1] + DIRV[side][1] * L)) for m in members]):+                self.wire(ax, ay, sx, sy)+            if len(stub_ends) > 1:+                se = sorted(stub_ends)+                for (x1, y1), (x2, y2) in zip(se, se[1:]):+                    self.wire(x1, y1, x2, y2)+                for jx, jy in se[1:-1]:+                    self.junction(jx, jy)+            if net in POWER_LIB:+                self.power_port(net, ex, ey, side)+            else:+                self.label(net, ex, ey, side if side in ('L', 'R') else 'R', is_global(net))++    def junction(self, x, y):+        self.items.append([S('junction'), [S('at'), x, y], [S('diameter'), 0], [S('color'), 0, 0, 0, 0],+                           [S('uuid'), uid(self.file_uuid, 'j', x, y)]])++    # ---------------------------------------------------------------- output+    def sheet_symbol(self, name, fname, x, y, w, h, sheet_uuid, page):+        self.items.append([S('sheet'), [S('at'), x, y], [S('size'), w, h],+                           [S('exclude_from_sim'), S('no')], [S('in_bom'), S('yes')], [S('on_board'), S('yes')],+                           [S('dnp'), S('no')], [S('fields_autoplaced'), S('yes')],+                           [S('stroke'), [S('width'), 0.1524], [S('type'), S('solid')]],+                           [S('fill'), [S('color'), 0, 0, 0, 0.0]],+                           [S('uuid'), sheet_uuid],+                           [S('property'), 'Sheetname', name, [S('at'), x, snap(y - 0.7) if False else y - 0.7112, 0],+                            effects(1.27, 'left bottom')],+                           [S('property'), 'Sheetfile', fname, [S('at'), x, y + h + 0.5938, 0],+                            effects(1.27, 'left top')],+                           [S('instances'), [S('project'), self.project,+                                             [S('path'), '/' + self.root_uuid, [S('page'), str(page)]]]]])++    def render(self, is_root=False, title_block=None):+        head = [S('kicad_sch'), [S('version'), 20251024], [S('generator'), 'eeschema'],+                [S('generator_version'), '10.0'], [S('uuid'), self.file_uuid], [S('paper'), self.paper]]+        if title_block:+            tb = [S('title_block')]+            for k in ('title', 'date', 'rev', 'company'):+                if title_block.get(k):+                    tb.append([S(k), title_block[k]])+            for i, c in enumerate(title_block.get('comments', []), 1):+                tb.append([S('comment'), i, c])+            head.append(tb)+        libsec = [S('lib_symbols')] + [self.libs.lib_entry(l) for l in sorted(self.used)]+        body = head + [libsec] + self.items+        if is_root:+            body.append([S('sheet_instances'), [S('path'), '/', [S('page'), '1']]])+        body.append([S('embedded_fonts'), S('no')])+        return dumps(body) + '\n'
tools/select_parts.pyadded+137
@@ -0,0 +1,137 @@+"""Pick a JLCPCB (LCSC) part for every passive BOM group, with value/package/dielectric checks."""+import json, os, re, sys+from collections import OrderedDict+from design import DESIGN+import jlc++OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'build', 'jlc_passives.json')++# capacitors that must be C0G/NP0 even above 10 nF (none) / may be X7R even below (coupling / decoupling)+C0G_MAX = 10e-9          # every capacitor <= 10 nF is C0G/NP0 unless listed below+X7R_OK_SMALL = {'10n'}   # 10 nF parts are coupling/bypass: X7R fine+++def val_to_float(v, unit):+    m = re.match(r'^([\d.]+)([pnumkM]?)$', v)+    mult = {'p': 1e-12, 'n': 1e-9, 'u': 1e-6, 'm': 1e-3, 'k': 1e3, 'M': 1e6, '': 1.0}+    return float(m.group(1)) * mult[m.group(2)]+++def jlc_value_text(v, unit):+    """Design value ('4.7k', '100n') -> JLC attribute text ('4.7kΩ', '100nF')."""+    if unit == 'Ω':+        return v + 'Ω'+    if unit == 'F':+        return v.replace('u', 'u') + 'F'+    if unit == 'H':+        return v + 'H'+    return v+++def attr(p, *names):+    for n in names:+        if n in p['attrs']:+            return p['attrs'][n]+    return None+++def voltage(p):+    v = attr(p, 'Voltage Rating', 'Rated Voltage')+    m = re.search(r'([\d.]+)\s*V', v or p['describe'])+    return float(m.group(1)) if m else 0.0+++def rank(p):+    return (p['type'] == 'base', p['preferred'], p['stock'])+++def pick_resistor(value, pkg='0603'):+    want = jlc_value_text(value, 'Ω')+    cands = []+    for q in ('%s %s' % (want, pkg), '%s %s resistor 1%%' % (want, pkg)):+        for p in jlc.search(q, size=50):+            if p['package'] != pkg or 'Resistor' not in (p['category'] or ''):+                continue+            if attr(p, 'Resistance') != want or p['stock'] < 500:+                continue+            if '±1%' not in p['describe']:+                continue+            cands.append(p)+    return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def pick_capacitor(value, pkg, need_c0g, vmin):+    want = jlc_value_text(value, 'F')+    cands = []+    for q in ('%s %s' % (want, pkg), '%s %s %s' % (want, pkg, 'C0G' if need_c0g else 'X7R')):+        for p in jlc.search(q, size=50):+            if p['package'] != pkg or 'Capacitor' not in (p['category'] or ''):+                continue+            if attr(p, 'Capacitance') != want or p['stock'] < 500:+                continue+            d = p['describe']+            diel = 'C0G' in d or 'NP0' in d or 'NPO' in d+            if need_c0g and not diel:+                continue+            if not need_c0g and not (diel or 'X7R' in d or 'X5R' in d):+                continue+            if voltage(p) < vmin:+                continue+            cands.append(p)+    return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def pick_inductor(value, pkg, wirewound):+    want = jlc_value_text(value, 'H')+    cands = []+    for q in ('%s %s' % (want, pkg), '%s %s inductor' % (want, pkg)):+        for p in jlc.search(q, size=50):+            if p['package'] != pkg or 'nductor' not in (p['category'] or '') + p['describe']:+                continue+            if attr(p, 'Inductance') != want and (' ' + want + ' ') not in (' ' + p['describe'] + ' '):+                continue+            if p['stock'] < 100:+                continue+            if wirewound and not ('LQW' in (p['mpn'] or '') or 'ire' in p['describe'] or 'CS' in (p['mpn'] or '')):+                continue+            cands.append(p)+    return sorted({c['code']: c for c in cands}.values(), key=rank, reverse=True)+++def groups():+    g = OrderedDict()+    for p in DESIGN.parts:+        if not p.in_bom:+            continue+        g.setdefault((p.lib_id, p.value, p.footprint), []).append(p)+    return g+++def main():+    res = OrderedDict()+    for (lib, value, fp), parts in groups().items():+        refs = [p.ref for p in parts]+        pkg = re.search(r'_(\d{4})_', fp)+        pkg = pkg.group(1) if pkg else None+        notes = ' '.join(p.description for p in parts)+        if lib == 'Device:R':+            c = pick_resistor(value, pkg)+        elif lib == 'Device:C':+            f = val_to_float(value, 'F')+            need_c0g = f <= C0G_MAX and value not in X7R_OK_SMALL+            vmin = 16 if f < 4e-6 else 10+            c = pick_capacitor(value, pkg, need_c0g, vmin)+        elif lib == 'Device:L':+            c = pick_inductor(value, pkg, wirewound=pkg == '0603')+        else:+            continue+        key = '%s|%s|%s' % (lib, value, fp)+        res[key] = dict(refs=refs, candidates=c[:4])+        best = c[0] if c else None+        print('%-8s %-6s %-5s x%-2d -> %s' % (lib.split(':')[1], value, pkg, len(refs),+                                             jlc.fmt(best) if best else 'NONE FOUND'))+    json.dump(res, open(OUT, 'w'), indent=1)+++if __name__ == '__main__':+    main()
tools/sexp.pyadded+78
@@ -0,0 +1,78 @@+"""Minimal S-expression reader/writer for KiCad files."""+import re++class Sym(str):+    """Unquoted atom (keyword / number / bare word)."""+    __slots__ = ()++_tok = re.compile(r'\s*(?:(\()|(\))|("(?:[^"\\]|\\.)*")|([^\s()"]+))', re.S)++def parse(text):+    pos = 0+    stack = [[]]+    n = len(text)+    while pos < n:+        m = _tok.match(text, pos)+        if not m:+            if text[pos:].strip() == '':+                break+            raise ValueError('bad token at %d: %r' % (pos, text[pos:pos+40]))+        pos = m.end()+        if m.group(1):+            stack.append([])+        elif m.group(2):+            lst = stack.pop()+            stack[-1].append(lst)+        elif m.group(3) is not None:+            s = m.group(3)[1:-1]+            s = s.replace('\\"', '"').replace('\\n', '\n').replace('\\\\', '\\')+            stack[-1].append(s)+        else:+            stack[-1].append(Sym(m.group(4)))+    assert len(stack) == 1, 'unbalanced'+    return stack[0][0] if len(stack[0]) == 1 else stack[0]++def q(s):+    return '"' + str(s).replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') + '"'++def dumps(node, indent=0):+    """Serialize: lists -> (..), Sym -> bare, str -> quoted, numbers -> bare."""+    if isinstance(node, list):+        if not node:+            return '()'+        # short lists on one line+        parts = [dumps(x, indent + 1) for x in node]+        oneline = '(' + ' '.join(parts) + ')'+        if len(oneline) < 100 and '\n' not in oneline:+            return oneline+        head = parts[0]+        out = '(' + head+        i = 1+        # keep leading atoms on the first line+        while i < len(node) and not isinstance(node[i], list):+            out += ' ' + parts[i]+            i += 1+        for p in parts[i:]:+            out += '\n' + '\t' * (indent + 1) + p+        out += '\n' + '\t' * indent + ')'+        return out+    if isinstance(node, Sym):+        return str(node)+    if isinstance(node, bool):+        return 'yes' if node else 'no'+    if isinstance(node, int):+        return str(node)+    if isinstance(node, float):+        s = ('%.6f' % node).rstrip('0').rstrip('.')+        return s if s not in ('-0', '') else '0'+    return q(node)++def find(node, key):+    """First child list whose head == key."""+    for x in node:+        if isinstance(x, list) and x and x[0] == key:+            return x+    return None++def findall(node, key):+    return [x for x in node if isinstance(x, list) and x and x[0] == key]
tools/symlib.pyadded+87
@@ -0,0 +1,87 @@+"""Load KiCad symbols from .kicad_sym libraries, flatten 'extends', report pins."""+import copy, os+from sexp import parse, find, findall, Sym++SYMDIR = '/usr/share/kicad/symbols'+_cache = {}++def load_lib(path):+    if path not in _cache:+        with open(path) as f:+            _cache[path] = parse(f.read())+    return _cache[path]++def get_symbol(lib_id, extra_libs=None):+    """Return flattened symbol s-expr (list) named like the lib item (no lib prefix)."""+    lib, name = lib_id.split(':')+    path = (extra_libs or {}).get(lib, os.path.join(SYMDIR, lib + '.kicad_sym'))+    root = load_lib(path)+    syms = {s[1]: s for s in findall(root, 'symbol')}+    s = copy.deepcopy(syms[name])+    ext = find(s, 'extends')+    if ext:+        parent = copy.deepcopy(syms[ext[1]])+        # parent may itself extend+        while find(parent, 'extends'):+            gp = copy.deepcopy(syms[find(parent, 'extends')[1]])+            parent = merge(gp, parent)+        s = merge(parent, s)+    return s++def merge(parent, child):+    """Child properties override parent's; graphics/pins from parent (units renamed)."""+    pname, cname = parent[1], child[1]+    out = [Sym('symbol'), cname]+    child_props = {p[1]: p for p in findall(child, 'property')}+    for item in parent[2:]:+        if isinstance(item, list) and item and item[0] == 'property':+            out.append(child_props.pop(item[1], item))+        elif isinstance(item, list) and item and item[0] == 'symbol':+            sub = copy.deepcopy(item)+            sub[1] = sub[1].replace(pname, cname, 1)+            out.append(sub)+        else:+            out.append(item)+    for p in child_props.values():+        out.append(p)+    # other child-level keys (e.g. in_bom) override+    for item in child[2:]:+        if isinstance(item, list) and item and item[0] not in ('property', 'extends', 'symbol'):+            for i, o in enumerate(out):+                if isinstance(o, list) and o and o[0] == item[0]:+                    out[i] = item+                    break+    return out++def pins(sym):+    """List of dicts: unit, convert, number, name, type, x, y, angle, length."""+    res = []+    for sub in findall(sym, 'symbol'):+        nm = sub[1]+        parts = nm.rsplit('_', 2)+        unit, conv = int(parts[-2]), int(parts[-1])+        for p in findall(sub, 'pin'):+            at = find(p, 'at')+            res.append(dict(unit=unit, convert=conv, type=str(p[1]), shape=str(p[2]),+                            x=float(at[1]), y=float(at[2]), angle=float(at[3]) if len(at) > 3 else 0.0,+                            length=float(find(p, 'length')[1]),+                            name=find(p, 'name')[1], number=find(p, 'number')[1],+                            hidden=any(isinstance(z, Sym) and z == 'hide' for z in p) or+                                   bool(find(p, 'hide') and find(p, 'hide')[1] == 'yes')))+    return res++def prop(sym, key):+    for p in findall(sym, 'property'):+        if p[1] == key:+            return p[2]+    return None++if __name__ == '__main__':+    import sys+    for lid in sys.argv[1:]:+        s = get_symbol(lid)+        print('==', lid, '| footprint:', prop(s, 'Footprint'), '| ref:', prop(s, 'Reference'))+        units = sorted(set(p['unit'] for p in pins(s)))+        for p in sorted(pins(s), key=lambda p: (p['unit'], int(p['number']) if p['number'].isdigit() else 999, p['number'])):+            print('   u%d c%d pin %-4s %-14s %-14s at(%7.2f,%7.2f) ang %3d len %.2f %s' % (+                p['unit'], p['convert'], p['number'], p['name'], p['type'], p['x'], p['y'], p['angle'], p['length'], 'HIDDEN' if p['hidden'] else ''))
verification.jsonadded+6
@@ -0,0 +1,6 @@+{+ "schema": "adom/verification@1",+ "part": "AM/FM Receiver Molecule",+ "variants": [],+ "entries": []+}\ No newline at end of file