main
John Lauer Saif Abdessayed's diode-bridge VCA: KiCad project, Gerbers, BOM, simulations, reports, and the earlier RC filter test 45f3ff6 11d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004
#!/usr/bin/env python3
"""Generate the complete native KiCad source for the diode VCA attenuator.

The generated libraries are project-local and all footprints are through-hole.
KiCad 10 is the target file format.  The board is intentionally deterministic:
UUIDs are UUIDv5 values derived from stable object names so regeneration does
not create meaningless diffs.
"""

from __future__ import annotations

from pathlib import Path
import heapq
import json
import math
import uuid


ROOT = Path(__file__).resolve().parent
NAME = "diode-vca-attenuator"
NS = uuid.UUID("9157e8cb-4d6b-4dcc-af4c-4ee7d9a14700")


def uid(name: str) -> str:
    return str(uuid.uuid5(NS, name))


def ensure_dirs() -> None:
    for directory in (
        "libraries/AnalogVCA_THT.pretty",
        "simulation/decks",
        "simulation/results",
        "gerbers",
        "manufacturing",
        "docs",
        "screenshots",
        "schematics",
        "2d-layouts",
    ):
        (ROOT / directory).mkdir(parents=True, exist_ok=True)


FONT = """(effects (font (size 1.27 1.27)))"""
FONT_SMALL = """(effects (font (size 1.0 1.0)))"""


def property_block(name: str, value: str, x: float, y: float, *, hide: bool = False) -> str:
    hidden = "\n\t\t\t(hide yes)" if hide else ""
    return f'''\t\t(property "{name}" "{value}"
\t\t\t(at {x:g} {y:g} 0){hidden}
\t\t\t(show_name no)
\t\t\t(do_not_autoplace no)
\t\t\t{FONT}
\t\t)'''


def pin_def(number: str, name: str, x: float, y: float, angle: int, ptype: str = "passive", length: float = 2.54) -> str:
    return f'''\t\t\t(pin {ptype} line
\t\t\t\t(at {x:g} {y:g} {angle})
\t\t\t\t(length {length:g})
\t\t\t\t(name "{name}" {FONT_SMALL})
\t\t\t\t(number "{number}" {FONT_SMALL})
\t\t\t)'''


def symbol_definition(name: str, reference: str, description: str, body: str, pins: list[str]) -> str:
    return f'''\t(symbol "{name}"
\t\t(pin_names (offset 0.8) hide)
\t\t(exclude_from_sim no)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(in_pos_files yes)
\t\t(duplicate_pin_numbers_are_jumpers no)
{property_block("Reference", reference, 0, -3.8)}
{property_block("Value", name.split(":")[-1], 0, 3.8)}
{property_block("Footprint", "", 0, 0, hide=True)}
{property_block("Datasheet", "", 0, 0, hide=True)}
{property_block("Description", description, 0, 0, hide=True)}
\t\t(symbol "{name.split(':')[-1]}_0_1"
{body}
\t\t)
\t\t(symbol "{name.split(':')[-1]}_1_1"
{chr(10).join(pins)}
\t\t)
\t\t(embedded_fonts no)
\t)'''


def make_symbol_definitions(prefixed: bool) -> list[str]:
    pre = "AnalogVCA:" if prefixed else ""
    rect_body = '''\t\t\t(rectangle
\t\t\t\t(start -2.54 -1.27)
\t\t\t\t(end 2.54 1.27)
\t\t\t\t(stroke (width 0.254) (type default))
\t\t\t\t(fill (type background))
\t\t\t)'''
    defs = [
        symbol_definition(
            pre + "R",
            "R",
            "Resistor, project-local generic symbol",
            rect_body,
            [pin_def("1", "", -5.08, 0, 0), pin_def("2", "", 5.08, 0, 180)],
        ),
        symbol_definition(
            pre + "C",
            "C",
            "Capacitor, project-local generic symbol",
            '''\t\t\t(polyline (pts (xy -0.635 -2.032) (xy -0.635 2.032))
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t(polyline (pts (xy 0.635 -2.032) (xy 0.635 2.032))
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))''',
            [pin_def("1", "", -5.08, 0, 0), pin_def("2", "", 5.08, 0, 180)],
        ),
        symbol_definition(
            pre + "D",
            "D",
            "Small-signal diode; pin 1 cathode, pin 2 anode",
            '''\t\t\t(polyline (pts (xy -1.27 -1.27) (xy 1.27 0) (xy -1.27 1.27) (xy -1.27 -1.27))
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t(polyline (pts (xy 1.27 -1.27) (xy 1.27 1.27))
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))''',
            [pin_def("1", "K", -5.08, 0, 0), pin_def("2", "A", 5.08, 0, 180)],
        ),
    ]

    conn2_body = '''\t\t\t(rectangle (start -2.54 -2.54) (end 2.54 2.54)
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type background)))'''
    defs.append(symbol_definition(
        pre + "Conn_01x02", "J", "Two-position connector",
        conn2_body,
        [pin_def("1", "1", 5.08, -1.27, 180), pin_def("2", "2", 5.08, 1.27, 180)],
    ))
    conn3_body = '''\t\t\t(rectangle (start -2.54 -3.81) (end 2.54 3.81)
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type background)))'''
    defs.append(symbol_definition(
        pre + "Conn_01x03", "J", "Three-position connector",
        conn3_body,
        [pin_def("1", "1", 5.08, -2.54, 180), pin_def("2", "2", 5.08, 0, 180), pin_def("3", "3", 5.08, 2.54, 180)],
    ))

    u_body = '''\t\t\t(rectangle (start -7.62 -6.35) (end 7.62 6.35)
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type background)))
\t\t\t(text "A: voltage buffer" (at 0 -3.81 0) (effects (font (size 1 1))))
\t\t\t(text "B: control-current servo" (at 0 2.54 0) (effects (font (size 1 1))))'''
    defs.append(symbol_definition(
        pre + "TL072CP_DIP8", "U", "TL072CP dual JFET op amp in PDIP-8",
        u_body,
        [
            pin_def("3", "IN_A+", -10.16, -3.81, 0, "passive"),
            pin_def("2", "IN_A-", -10.16, -1.27, 0, "passive"),
            pin_def("5", "IN_B+", -10.16, 2.54, 0, "passive"),
            pin_def("6", "IN_B-", -10.16, 5.08, 0, "passive"),
            pin_def("1", "OUT_A", 10.16, -3.81, 180, "output"),
            pin_def("7", "OUT_B", 10.16, 3.81, 180, "output"),
            pin_def("8", "V+", 0, -8.89, 270, "power_in"),
            pin_def("4", "V-", 0, 8.89, 90, "power_in"),
        ],
    ))

    q_body = '''\t\t\t(circle (center 0 0) (radius 3.175)
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type background)))
\t\t\t(text "NPN" (at 0 0 0) (effects (font (size 1 1))))'''
    defs.append(symbol_definition(
        pre + "Q_NPN_EBC", "Q", "NPN transistor; pins 1 emitter, 2 base, 3 collector",
        q_body,
        [
            pin_def("2", "B", -7.62, 0, 0, "input", 4.445),
            pin_def("3", "C", 0, -7.62, 270, "passive", 4.445),
            pin_def("1", "E", 0, 7.62, 90, "passive", 4.445),
        ],
    ))

    flag = symbol_definition(
        pre + "PWR_FLAG", "#FLG", "ERC declaration that an external connector drives this rail",
        '''\t\t\t(polyline (pts (xy -1.27 -1.27) (xy 0 -2.54) (xy 1.27 -1.27))
\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))''',
        [pin_def("1", "pwr", 0, 0, 0, "power_out", 0)],
    )
    flag = flag.replace("\t\t(in_bom yes)", "\t\t(in_bom no)").replace("\t\t(on_board yes)", "\t\t(on_board no)")
    defs.append(flag)
    return defs


SYMBOL_PIN_REL = {
    "R": {"1": (-5.08, 0), "2": (5.08, 0)},
    "C": {"1": (-5.08, 0), "2": (5.08, 0)},
    "D": {"1": (-5.08, 0), "2": (5.08, 0)},
    "Conn_01x02": {"1": (5.08, -1.27), "2": (5.08, 1.27)},
    "Conn_01x03": {"1": (5.08, -2.54), "2": (5.08, 0), "3": (5.08, 2.54)},
    "TL072CP_DIP8": {
        "1": (10.16, -3.81), "2": (-10.16, -1.27), "3": (-10.16, -3.81),
        "4": (0, 8.89), "5": (-10.16, 2.54), "6": (-10.16, 5.08),
        "7": (10.16, 3.81), "8": (0, -8.89),
    },
    "Q_NPN_EBC": {"1": (0, 7.62), "2": (-7.62, 0), "3": (0, -7.62)},
    "PWR_FLAG": {"1": (0, 0)},
}


SCHEMATIC_PARTS = [
    # ref, symbol, value, footprint, MPN, x, y, pin-to-net
    ("J1", "Conn_01x03", "POWER: +12 / GND / -12", "AnalogVCA_THT:Terminal_1x03_P5.08mm", "1715734", 25, 180, {"1": "+12V", "2": "GND", "3": "-12V"}),
    ("J2", "Conn_01x02", "AUDIO INPUT", "AnalogVCA_THT:Terminal_1x02_P5.08mm", "1715721", 25, 45, {"1": "INPUT", "2": "GND"}),
    ("J3", "Conn_01x02", "CONTROL 0-2 V", "AnalogVCA_THT:Terminal_1x02_P5.08mm", "1715721", 25, 125, {"1": "CTRL_IN", "2": "GND"}),
    ("J4", "Conn_01x02", "AUDIO OUTPUT", "AnalogVCA_THT:Terminal_1x02_P5.08mm", "1715721", 185, 45, {"1": "OUTPUT", "2": "GND"}),
    ("R1", "R", "1 k 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-1K", 52, 42, {"1": "INPUT", "2": "BUF_IN"}),
    ("R2", "R", "1 M 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-1M", 52, 56, {"1": "GND", "2": "BUF_IN"}),
    ("R3", "R", "2.2 k 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-2K2", 135, 42, {"1": "BUF_OUT", "2": "OUTPUT"}),
    ("R4", "R", "100 k 1% LOAD", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-100K", 160, 58, {"1": "OUTPUT", "2": "GND"}),
    ("R5", "R", "22 k 1%", "AnalogVCA_THT:Axial_V_P1Bottom_P10.16mm", "MFR-25FBF52-22K", 185, 100, {"1": "+12V", "2": "BR_P"}),
    ("R6", "R", "8.06 k 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-8K06", 125, 137, {"1": "GND", "2": "SENSE"}),
    ("R7", "R", "10 k 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-10K", 52, 122, {"1": "CTRL_IN", "2": "CTRL_FILT"}),
    ("R8", "R", "1 M 1%", "AnalogVCA_THT:Axial_DIN0207_P10.16mm", "MFR-25FBF52-1M", 73, 137, {"1": "GND", "2": "CTRL_FILT"}),
    ("R9", "R", "4.7 k 1%", "AnalogVCA_THT:Axial_V_P1Top_P10.16mm", "MFR-25FBF52-4K7", 125, 120, {"1": "DRIVE", "2": "BASE"}),
    ("C1", "C", "220 uF 25 V BIPOLAR", "AnalogVCA_THT:Radial_Bipolar_D12.5_P5.0mm", "UES1E221MHM", 125, 72, {"1": "OUTPUT", "2": "BR_A"}),
    ("C2", "C", "220 uF 25 V BIPOLAR", "AnalogVCA_THT:Radial_Bipolar_D12.5_P5.0mm", "UES1E221MHM", 160, 72, {"1": "BR_B", "2": "GND"}),
    ("C3", "C", "100 nF 50 V X7R", "AnalogVCA_THT:C_Disc_P5.00mm", "RCER71H104K0K1H03B", 73, 151, {"1": "CTRL_FILT", "2": "GND"}),
    ("C4", "C", "100 nF 50 V X7R", "AnalogVCA_THT:C_Disc_P5.00mm", "RCER71H104K0K1H03B", 118, 180, {"1": "+12V", "2": "GND"}),
    ("C5", "C", "100 nF 50 V X7R", "AnalogVCA_THT:C_Disc_P5.00mm", "RCER71H104K0K1H03B", 150, 180, {"1": "-12V", "2": "GND"}),
    ("C6", "C", "10 uF 25 V", "AnalogVCA_THT:C_Radial_D5_P2.0mm", "UHE1E100MDD", 118, 195, {"1": "+12V", "2": "GND"}),
    ("C7", "C", "10 uF 25 V", "AnalogVCA_THT:C_Radial_D5_P2.0mm", "UHE1E100MDD", 150, 195, {"1": "GND", "2": "-12V"}),
    ("D1", "D", "1N4148", "AnalogVCA_THT:D_DO35_V_KBottom_P10.16mm", "1N4148", 125, 88, {"1": "BR_A", "2": "BR_P"}),
    ("D2", "D", "1N4148", "AnalogVCA_THT:D_DO35_V_KBottom_P10.16mm", "1N4148", 160, 88, {"1": "BR_B", "2": "BR_P"}),
    ("D3", "D", "1N4148", "AnalogVCA_THT:D_DO35_V_KBottom_P10.16mm", "1N4148", 125, 101, {"1": "BR_N", "2": "BR_A"}),
    ("D4", "D", "1N4148", "AnalogVCA_THT:D_DO35_V_KBottom_P10.16mm", "1N4148", 160, 101, {"1": "BR_N", "2": "BR_B"}),
    ("Q1", "Q_NPN_EBC", "2N3904BU", "AnalogVCA_THT:TO92_Inline_EBC", "2N3904BU", 170, 130, {"1": "SENSE", "2": "BASE", "3": "BR_N"}),
    ("U1", "TL072CP_DIP8", "TL072CP", "AnalogVCA_THT:DIP8_W7.62mm", "TL072CP", 92, 91, {"1": "BUF_OUT", "2": "BUF_OUT", "3": "BUF_IN", "4": "-12V", "5": "CTRL_FILT", "6": "SENSE", "7": "DRIVE", "8": "+12V"}),
]


def sch_property(name: str, value: str, x: float, y: float, hide: bool = False) -> str:
    hidden = "\n\t\t(hide yes)" if hide else ""
    return f'''\t(property "{name}" "{value}"
\t\t(at {x:g} {y:g} 0){hidden}
\t\t(show_name no)
\t\t(do_not_autoplace no)
\t\t{FONT_SMALL}
\t)'''


def sch_symbol_instance(part: tuple, root_uuid: str) -> tuple[str, dict[str, tuple[float, float]]]:
    ref, sym, value, footprint, mpn, x, y, pin_nets = part
    x = round(x / 2.54) * 2.54
    y = round(y / 2.54) * 2.54
    sym_uuid = uid("sch:" + ref)
    pin_positions = {}
    pins = []
    for number, rel in SYMBOL_PIN_REL[sym].items():
        # KiCad schematic instances mirror the symbol-library Y coordinate.
        pin_positions[number] = (x + rel[0], y - rel[1])
        pins.append(f'''\t(pin "{number}" (uuid "{uid(f'sch:{ref}:pin:{number}')}") )''')
    property_offset = 11 if sym == "TL072CP_DIP8" else (6.5 if sym == "Q_NPN_EBC" else 4.5)
    block = f'''(symbol
\t(lib_id "AnalogVCA:{sym}")
\t(at {x:g} {y:g} 0)
\t(unit 1)
\t(body_style 1)
\t(exclude_from_sim no)
\t(in_bom yes)
\t(on_board yes)
\t(in_pos_files yes)
\t(dnp no)
\t(fields_autoplaced no)
\t(uuid "{sym_uuid}")
{sch_property("Reference", ref, x, y - property_offset)}
{sch_property("Value", value, x, y + property_offset)}
{sch_property("Footprint", footprint, x, y, True)}
{sch_property("Datasheet", "", x, y, True)}
{sch_property("Description", "See BOM and engineering review", x, y, True)}
{sch_property("Manufacturer Part Number", mpn, x, y, True)}
{chr(10).join(pins)}
\t(instances
\t\t(project "{NAME}"
\t\t\t(path "/{root_uuid}" (reference "{ref}") (unit 1))
\t\t)
\t)
)'''
    return block, pin_positions


def label_at(net: str, x: float, y: float, key: str, rel: tuple[float, float] = (1, 0)) -> str:
    if rel[0] < 0:
        angle, justify = 0, "right bottom"
    elif rel[0] > 0:
        angle, justify = 0, "left bottom"
    else:
        angle, justify = 90, "left bottom"
    return f'''(label "{net}"
\t(at {x:g} {y:g} {angle})
\t(effects (font (size 1.0 1.0)) (justify {justify}))
\t(uuid "{uid('label:' + key)}")
)'''


def make_schematic() -> None:
    root_uuid = uid("schematic-root")
    defs = make_symbol_definitions(True)
    blocks = []
    ref_uuids = {}
    for part in SCHEMATIC_PARTS:
        block, pin_positions = sch_symbol_instance(part, root_uuid)
        blocks.append(block)
        ref_uuids[part[0]] = uid("sch:" + part[0])
        for pin, net in part[-1].items():
            px, py = pin_positions[pin]
            blocks.append(label_at(net, px, py, f"{part[0]}:{pin}:{net}", SYMBOL_PIN_REL[part[1]][pin]))

    for idx, (net, x, y) in enumerate((("+12V", 48, 177), ("GND", 62, 187), ("-12V", 76, 197))):
        ref = f"#FLG0{idx+1}"
        part = (ref, "PWR_FLAG", "PWR_FLAG", "", "", x, y, {"1": net})
        block, pin_positions = sch_symbol_instance(part, root_uuid)
        block = block.replace("\t(in_bom yes)", "\t(in_bom no)").replace("\t(on_board yes)", "\t(on_board no)")
        blocks.append(block)
        px, py = pin_positions["1"]
        blocks.append(label_at(net, px, py, f"{ref}:1:{net}", (0, -1)))

    notes = [
        (20, 20, "DIODE-BRIDGE VOLTAGE-CONTROLLED ATTENUATOR"),
        (20, 27, "External regulated rails: +12 V / GND / -12 V. Audio: 20 Hz-20 kHz, <=12 Vpp."),
        (20, 34, "Control: 0-2 V. No makeup gain, envelope detector, comparator, or display."),
        (112, 109, "D1-D4 form the symmetric current-biased shunt bridge."),
        (112, 114, "Q1 + U1B force Ibridge ~= VCTRL / 8.06 kOhm."),
        (105, 158, "C1/C2 are BIPOLAR audio electrolytics; do not substitute polarized parts."),
    ]
    note_blocks = []
    for idx, (x, y, text) in enumerate(notes):
        note_blocks.append(f'''(text "{text}" (exclude_from_sim no) (at {x:g} {y:g} 0)
\t(effects (font (size {1.5 if idx == 0 else 1.0:g} {1.5 if idx == 0 else 1.0:g})) (justify left bottom))
\t(uuid "{uid('note:' + str(idx))}"))''')

    schematic = f'''(kicad_sch
\t(version 20260306)
\t(generator "eeschema")
\t(generator_version "10.0")
\t(uuid "{root_uuid}")
\t(paper "A4")
\t(title_block
\t\t(title "Diode-Bridge Voltage-Controlled Attenuator")
\t\t(date "2026-10-07")
\t\t(rev "A - prototype")
\t\t(company "OpenAI engineering deliverable")
\t\t(comment 1 "Regulated external +/-12 V rails; 100 kOhm onboard output load")
\t\t(comment 2 "Provisional PCB rules; not fabricator approval")
\t)
\t(lib_symbols
{chr(10).join(defs)}
\t)
{chr(10).join(note_blocks)}
{chr(10).join(blocks)}
\t(sheet_instances (path "/" (page "1")))
)\n'''
    (ROOT / f"{NAME}.kicad_sch").write_text(schematic, encoding="utf-8")
    (ROOT / "schematics" / f"{NAME}.kicad_sch").write_text(schematic, encoding="utf-8")

    lib = f'''(kicad_symbol_lib
\t(version 20231120)
\t(generator "kicad_symbol_editor")
\t(generator_version "10.0")
{chr(10).join(make_symbol_definitions(False))}
)\n'''
    (ROOT / "libraries" / "AnalogVCA.kicad_sym").write_text(lib, encoding="utf-8")
    (ROOT / "sym-lib-table").write_text('(sym_lib_table\n  (lib (name "AnalogVCA")(type "KiCad")(uri "${KIPRJMOD}/libraries/AnalogVCA.kicad_sym")(options "")(descr "Project-local symbols"))\n)\n', encoding="utf-8")
    (ROOT / "schematic_refs.json").write_text(json.dumps(ref_uuids, indent=2), encoding="utf-8")


def fp_header(name: str, ref_y: float, descr: str) -> str:
    return f'''(footprint "{name}"
\t(version 20260206)
\t(generator "pcbnew")
\t(generator_version "10.0")
\t(layer "F.Cu")
\t(descr "{descr}")
\t(tags "through-hole hand-solder")
\t(property "Reference" "REF**" (at 0 {ref_y:g} 0) (layer "F.Fab")
\t\t(effects (font (size 0.8 0.8) (thickness 0.14))))
\t(property "Value" "{name}" (at 0 {-ref_y:g} 0) (layer "F.Fab")
\t\t(effects (font (size 1 1) (thickness 0.15))))
\t(attr through_hole)
'''


def fp_line(x1, y1, x2, y2, layer="F.Fab", width=0.18) -> str:
    return f'''\t(fp_line (start {x1:g} {y1:g}) (end {x2:g} {y2:g})
\t\t(stroke (width {width:g}) (type solid)) (layer "{layer}"))'''


def fp_circle(x, y, r, layer="F.Fab", width=0.18) -> str:
    return f'''\t(fp_circle (center {x:g} {y:g}) (end {x+r:g} {y:g})
\t\t(stroke (width {width:g}) (type solid)) (fill none) (layer "{layer}"))'''


def pad(number: str, x: float, y: float, *, rect=False, drill=1.0, size=2.2) -> str:
    shape = "rect" if rect else "circle"
    return f'''\t(pad "{number}" thru_hole {shape} (at {x:g} {y:g}) (size {size:g} {size:g})
\t\t(drill {drill:g}) (layers "*.Cu" "*.Mask"))'''


def footprint_sources() -> dict[str, str]:
    fps = {}
    for count in (2, 3):
        name = f"Terminal_1x0{count}_P5.08mm"
        xs = [(-2.54 if count == 2 else -5.08) + i * 5.08 for i in range(count)]
        body_half = (count - 1) * 2.54 + 2.4
        lines = [fp_header(name, -4.4, "5.08 mm pitch PCB screw terminal")]
        lines += [fp_line(-body_half, -3.5, body_half, -3.5), fp_line(body_half, -3.5, body_half, 3.5), fp_line(body_half, 3.5, -body_half, 3.5), fp_line(-body_half, 3.5, -body_half, -3.5)]
        lines += [pad(str(i + 1), x, 0, rect=(i == 0), drill=1.3, size=2.7) for i, x in enumerate(xs)]
        lines.append(")\n")
        fps[name] = "\n".join(lines)

    name = "Axial_DIN0207_P10.16mm"
    fps[name] = "\n".join([
        fp_header(name, -2.6, "DIN0207 axial resistor, 10.16 mm pitch"),
        fp_line(-3.2, -1.15, 3.2, -1.15), fp_line(3.2, -1.15, 3.2, 1.15), fp_line(3.2, 1.15, -3.2, 1.15), fp_line(-3.2, 1.15, -3.2, -1.15),
        pad("1", -5.08, 0, rect=True), pad("2", 5.08, 0), ")\n"
    ])
    for name, p1y, p2y in (("Axial_V_P1Top_P10.16mm", -5.08, 5.08), ("Axial_V_P1Bottom_P10.16mm", 5.08, -5.08)):
        fps[name] = "\n".join([
            fp_header(name, -2.6, "DIN0207 axial resistor, vertical board orientation"),
            fp_line(-1.15, -3.2, 1.15, -3.2), fp_line(1.15, -3.2, 1.15, 3.2),
            fp_line(1.15, 3.2, -1.15, 3.2), fp_line(-1.15, 3.2, -1.15, -3.2),
            pad("1", 0, p1y, rect=True), pad("2", 0, p2y), ")\n"
        ])
    name = "D_DO35_P10.16mm"
    fps[name] = "\n".join([
        fp_header(name, -2.5, "DO-35 axial diode, cathode pad 1"),
        fp_line(-3.4, -1.05, 3.4, -1.05), fp_line(3.4, -1.05, 3.4, 1.05), fp_line(3.4, 1.05, -3.4, 1.05), fp_line(-3.4, 1.05, -3.4, -1.05),
        fp_line(-2.4, -1.05, -2.4, 1.05, width=0.35),
        pad("1", -5.08, 0, rect=True), pad("2", 5.08, 0), ")\n"
    ])
    name = "D_DO35_V_KBottom_P10.16mm"
    fps[name] = "\n".join([
        fp_header(name, -2.5, "DO-35 axial diode, vertical board orientation, cathode pad 1 at bottom"),
        fp_line(-1.05, -3.4, 1.05, -3.4), fp_line(1.05, -3.4, 1.05, 3.4),
        fp_line(1.05, 3.4, -1.05, 3.4), fp_line(-1.05, 3.4, -1.05, -3.4),
        fp_line(-1.05, 2.4, 1.05, 2.4, width=0.35),
        pad("1", 0, 5.08, rect=True), pad("2", 0, -5.08), ")\n"
    ])
    name = "DIP8_W7.62mm"
    dip = [fp_header(name, -6.2, "PDIP-8, 7.62 mm row spacing")]
    dip += [fp_line(-5.1, -5.1, 5.1, -5.1), fp_line(5.1, -5.1, 5.1, 5.1), fp_line(5.1, 5.1, -5.1, 5.1), fp_line(-5.1, 5.1, -5.1, -5.1), fp_circle(-3.81, -4.25, 0.45)]
    coords = {"1": (-3.81, -3.81), "2": (-3.81, -1.27), "3": (-3.81, 1.27), "4": (-3.81, 3.81), "5": (3.81, 3.81), "6": (3.81, 1.27), "7": (3.81, -1.27), "8": (3.81, -3.81)}
    dip += [pad(n, x, y, rect=(n == "1")) for n, (x, y) in coords.items()]
    dip.append(")\n")
    fps[name] = "\n".join(dip)

    name = "TO92_Inline_EBC"
    fps[name] = "\n".join([
        fp_header(name, -3.2, "TO-92 inline; pads 1 E, 2 B, 3 C"),
        fp_line(-3.8, -1.7, 3.8, -1.7), fp_line(3.8, -1.7, 3.8, 1.7), fp_line(3.8, 1.7, -3.8, 1.7), fp_line(-3.8, 1.7, -3.8, -1.7),
        pad("1", -2.54, 0, rect=True), pad("2", 0, 0), pad("3", 2.54, 0), ")\n"
    ])
    name = "Radial_Bipolar_D12.5_P5.0mm"
    fps[name] = "\n".join([
        fp_header(name, -7.4, "12.5 mm bipolar radial electrolytic, 5 mm pitch"), fp_circle(0, 0, 6.25),
        pad("1", -2.5, 0, rect=True, drill=1.0, size=2.4), pad("2", 2.5, 0, drill=1.0, size=2.4), ")\n"
    ])
    name = "C_Disc_P5.00mm"
    fps[name] = "\n".join([
        fp_header(name, -2.6, "Radial ceramic capacitor, 5 mm pitch"),
        fp_line(-3.2, -1.2, 3.2, -1.2), fp_line(3.2, -1.2, 3.2, 1.2), fp_line(3.2, 1.2, -3.2, 1.2), fp_line(-3.2, 1.2, -3.2, -1.2),
        pad("1", -2.5, 0, rect=True), pad("2", 2.5, 0), ")\n"
    ])
    name = "C_Radial_D5_P2.0mm"
    fps[name] = "\n".join([
        fp_header(name, -3.4, "5 mm radial electrolytic, 2 mm pitch"), fp_circle(0, 0, 2.5),
        pad("1", -1.0, 0, rect=True, drill=0.8, size=1.6), pad("2", 1.0, 0, drill=0.8, size=1.6), ")\n"
    ])
    return fps


PAD_OFFSETS = {
    "Terminal_1x02_P5.08mm": {"1": (-2.54, 0), "2": (2.54, 0)},
    "Terminal_1x03_P5.08mm": {"1": (-5.08, 0), "2": (0, 0), "3": (5.08, 0)},
    "Axial_DIN0207_P10.16mm": {"1": (-5.08, 0), "2": (5.08, 0)},
    "Axial_V_P1Top_P10.16mm": {"1": (0, -5.08), "2": (0, 5.08)},
    "Axial_V_P1Bottom_P10.16mm": {"1": (0, 5.08), "2": (0, -5.08)},
    "D_DO35_P10.16mm": {"1": (-5.08, 0), "2": (5.08, 0)},
    "D_DO35_V_KBottom_P10.16mm": {"1": (0, 5.08), "2": (0, -5.08)},
    "DIP8_W7.62mm": {"1": (-3.81, -3.81), "2": (-3.81, -1.27), "3": (-3.81, 1.27), "4": (-3.81, 3.81), "5": (3.81, 3.81), "6": (3.81, 1.27), "7": (3.81, -1.27), "8": (3.81, -3.81)},
    "TO92_Inline_EBC": {"1": (-2.54, 0), "2": (0, 0), "3": (2.54, 0)},
    "Radial_Bipolar_D12.5_P5.0mm": {"1": (-2.5, 0), "2": (2.5, 0)},
    "C_Disc_P5.00mm": {"1": (-2.5, 0), "2": (2.5, 0)},
    "C_Radial_D5_P2.0mm": {"1": (-1.0, 0), "2": (1.0, 0)},
}


BOARD_PARTS = [
    # ref, footprint, x, y, rotation, pad nets
    ("J1", "Terminal_1x03_P5.08mm", 115, 95.5, 0, {"1": "+12V", "2": "GND", "3": "-12V"}),
    ("J2", "Terminal_1x02_P5.08mm", 104.5, 65, 0, {"1": "INPUT", "2": "GND"}),
    ("J3", "Terminal_1x02_P5.08mm", 104.5, 84, 0, {"1": "CTRL_IN", "2": "GND"}),
    ("J4", "Terminal_1x02_P5.08mm", 153.5, 65, 0, {"1": "OUTPUT", "2": "GND"}),
    ("R1", "Axial_DIN0207_P10.16mm", 108, 62, 0, {"1": "INPUT", "2": "BUF_IN"}),
    ("R2", "Axial_DIN0207_P10.16mm", 107, 72, 0, {"1": "GND", "2": "BUF_IN"}),
    ("R3", "Axial_DIN0207_P10.16mm", 135, 62, 0, {"1": "BUF_OUT", "2": "OUTPUT"}),
    ("R4", "Axial_DIN0207_P10.16mm", 145, 69, 0, {"1": "OUTPUT", "2": "GND"}),
    ("R5", "Axial_V_P1Bottom_P10.16mm", 155.5, 88, 0, {"1": "+12V", "2": "BR_P"}),
    ("R6", "Axial_DIN0207_P10.16mm", 119, 80, 0, {"1": "GND", "2": "SENSE"}),
    ("R7", "Axial_DIN0207_P10.16mm", 108, 88, 0, {"1": "CTRL_IN", "2": "CTRL_FILT"}),
    ("R8", "Axial_DIN0207_P10.16mm", 111, 90, 0, {"1": "GND", "2": "CTRL_FILT"}),
    ("R9", "Axial_V_P1Top_P10.16mm", 125, 72, 0, {"1": "DRIVE", "2": "BASE"}),
    ("C1", "Radial_Bipolar_D12.5_P5.0mm", 132, 93, 0, {"1": "OUTPUT", "2": "BR_A"}),
    ("C2", "Radial_Bipolar_D12.5_P5.0mm", 146, 93, 0, {"1": "BR_B", "2": "GND"}),
    ("C3", "C_Disc_P5.00mm", 118.5, 86, 0, {"1": "CTRL_FILT", "2": "GND"}),
    ("C4", "C_Disc_P5.00mm", 128, 70, 0, {"1": "+12V", "2": "GND"}),
    ("C5", "C_Disc_P5.00mm", 112, 77, 0, {"1": "-12V", "2": "GND"}),
    ("C6", "C_Radial_D5_P2.0mm", 105, 75.5, 0, {"1": "+12V", "2": "GND"}),
    ("C7", "C_Radial_D5_P2.0mm", 105, 79.5, 0, {"1": "GND", "2": "-12V"}),
    ("D1", "D_DO35_V_KBottom_P10.16mm", 128, 81, 0, {"1": "BR_A", "2": "BR_P"}),
    ("D2", "D_DO35_V_KBottom_P10.16mm", 134, 81, 0, {"1": "BR_B", "2": "BR_P"}),
    ("D3", "D_DO35_V_KBottom_P10.16mm", 140, 81, 0, {"1": "BR_N", "2": "BR_A"}),
    ("D4", "D_DO35_V_KBottom_P10.16mm", 146, 81, 0, {"1": "BR_N", "2": "BR_B"}),
    ("Q1", "TO92_Inline_EBC", 138, 72, 0, {"1": "SENSE", "2": "BASE", "3": "BR_N"}),
    ("U1", "DIP8_W7.62mm", 119, 68.5, 0, {"1": "BUF_OUT", "2": "BUF_OUT", "3": "BUF_IN", "4": "-12V", "5": "CTRL_FILT", "6": "SENSE", "7": "DRIVE", "8": "+12V"}),
]


NET_ORDER = ["GND", "+12V", "-12V", "INPUT", "BUF_IN", "BUF_OUT", "OUTPUT", "BR_A", "BR_B", "BR_P", "BR_N", "CTRL_IN", "CTRL_FILT", "DRIVE", "BASE", "SENSE"]
NET_NUM = {name: idx + 1 for idx, name in enumerate(NET_ORDER)}


def rotate_point(x: float, y: float, angle: float) -> tuple[float, float]:
    # KiCad footprint rotation is clockwise in the board coordinate system.
    r = math.radians(-angle)
    return x * math.cos(r) - y * math.sin(r), x * math.sin(r) + y * math.cos(r)


def board_pad_positions() -> dict[tuple[str, str], tuple[float, float]]:
    result = {}
    for ref, fp, x, y, rot, _ in BOARD_PARTS:
        for number, (dx, dy) in PAD_OFFSETS[fp].items():
            rx, ry = rotate_point(dx, dy, rot)
            result[(ref, number)] = (round(x + rx, 4), round(y + ry, 4))
    return result


def board_footprint(source: str, ref: str, value: str, mpn: str, x: float, y: float, rotation: float, pad_nets: dict[str, str], sch_uuid: str) -> str:
    # Insert board-only attributes immediately after the layer and then add nets to pads.
    text = source.replace(f'(footprint "{source.split(chr(10),1)[0].split(chr(34))[1]}"', f'(footprint "AnalogVCA_THT:{source.split(chr(10),1)[0].split(chr(34))[1]}"', 1)
    marker = '\t(layer "F.Cu")'
    insert = f'''{marker}
\t(uuid "{uid('fp:' + ref)}")
\t(at {x:g} {y:g} {rotation:g})
\t(path "/{uid('schematic-root')}/{sch_uuid}")'''
    text = text.replace(marker, insert, 1)
    text = text.replace('"REF**"', f'"{ref}"', 1)
    fp_name = source.split(chr(10), 1)[0].split(chr(34))[1]
    text = text.replace(f'(property "Value" "{fp_name}"', f'(property "Value" "{value}"', 1)
    text = text.replace('\t(attr through_hole)', f'''\t(property "Manufacturer Part Number" "{mpn}" (at 0 0 0) (layer "F.Fab") (hide yes)
\t\t(effects (font (size 1 1) (thickness 0.15))))
\t(property "Description" "See BOM and engineering review" (at 0 0 0) (layer "F.Fab") (hide yes)
\t\t(effects (font (size 1 1) (thickness 0.15))))
\t(attr through_hole)''', 1)
    for pad_no, net_name in pad_nets.items():
        token = f'\t(pad "{pad_no}" '
        start = text.find(token)
        if start < 0:
            raise ValueError(f"pad {pad_no} missing in {ref}")
        depth = 0
        in_quote = False
        end = None
        for i in range(start, len(text)):
            ch = text[i]
            if ch == '"':
                in_quote = not in_quote
            elif not in_quote and ch == '(':
                depth += 1
            elif not in_quote and ch == ')':
                depth -= 1
                if depth == 0:
                    end = i
                    break
        if end is None:
            raise ValueError("unbalanced footprint")
        text = text[:end] + f'\n\t\t(net {NET_NUM[net_name]} "/{net_name}")' + text[end:]
    return text


def segment(net: str, p1: tuple[float, float], p2: tuple[float, float], layer="F.Cu", width=0.5, key="") -> str:
    return f'''\t(segment (start {p1[0]:g} {p1[1]:g}) (end {p2[0]:g} {p2[1]:g})
\t\t(width {width:g}) (layer "{layer}") (net {NET_NUM[net]}) (uuid "{uid('seg:' + net + ':' + key + ':' + str(p1) + ':' + str(p2))}"))'''


def route(net: str, points: list[tuple[float, float]], layer="F.Cu", width=0.5, key="") -> list[str]:
    return [segment(net, a, b, layer, width, key + f":{i}") for i, (a, b) in enumerate(zip(points, points[1:]))]


def autoroute_board(P: dict[tuple[str, str], tuple[float, float]]) -> tuple[list[str], list[str]]:
    """Route all non-ground nets on a 0.5 mm, two-layer grid.

    Pads are inflated by 2.0 mm for other nets.  Routed grid cells reserve a
    one-cell halo, giving at least 0.5 mm center spacing beyond the 0.5 mm
    track centerline.  The native DRC remains authoritative.
    """
    grid = 0.5
    xmin, xmax, ymin, ymax = 101.0, 157.0, 61.0, 99.0
    nx = int(round((xmax - xmin) / grid))
    ny = int(round((ymax - ymin) / grid))

    def cell(point):
        return (int(round((point[0] - xmin) / grid)), int(round((point[1] - ymin) / grid)))

    def xy(ix, iy):
        return (round(xmin + ix * grid, 4), round(ymin + iy * grid, 4))

    terminals: dict[str, list[tuple[float, float]]] = {n: [] for n in NET_ORDER}
    pad_records = []
    for ref, fp, _, _, _, nets in BOARD_PARTS:
        radius = 1.35 if fp.startswith("Terminal") else (1.2 if fp == "Radial_Bipolar_D12.5_P5.0mm" else (0.8 if fp == "C_Radial_D5_P2.0mm" else 1.1))
        for number, net in nets.items():
            point = P[(ref, number)]
            terminals[net].append(point)
            pad_records.append((point, net, radius))

    static: dict[tuple[int, int, int], set[str]] = {}
    max_inflate = 2.25
    cells_radius = int(math.ceil(max_inflate / grid))
    for point, net, radius in pad_records:
        # Pad radius + widest-track half width + clearance + half-grid margin.
        inflate = radius + 0.95
        cx, cy = cell(point)
        for layer in (0, 1):
            for dx in range(-cells_radius, cells_radius + 1):
                for dy in range(-cells_radius, cells_radius + 1):
                    ix, iy = cx + dx, cy + dy
                    if not (0 <= ix <= nx and 0 <= iy <= ny):
                        continue
                    px, py = xy(ix, iy)
                    if math.hypot(px - point[0], py - point[1]) <= inflate:
                        static.setdefault((layer, ix, iy), set()).add(net)

    used: dict[tuple[int, int, int], str] = {}
    out_segments: list[str] = []
    out_vias: list[str] = []
    # Manhattan routing avoids a diagonal segment cutting the corner of an
    # inflated pad even when both sampled grid endpoints are legal.
    dirs = ((1, 0), (-1, 0), (0, 1), (0, -1))

    def free(state, net):
        layer, ix, iy = state
        if not (0 <= ix <= nx and 0 <= iy <= ny):
            return False
        pad_nets = static.get(state, set())
        if any(n != net for n in pad_nets):
            return False
        owner = used.get(state)
        return owner is None or owner == net

    def astar(start_cell, targets, net):
        starts = [(0, start_cell[0], start_cell[1]), (1, start_cell[0], start_cell[1])]
        targets_xy = [(s[1], s[2]) for s in targets]
        def heuristic(ix, iy):
            return min(math.hypot(ix - tx, iy - ty) for tx, ty in targets_xy)
        heap = []
        parent = {}
        best = {}
        for s in starts:
            if free(s, net):
                best[s] = 0.0
                heapq.heappush(heap, (heuristic(s[1], s[2]), 0.0, s))
        goal = None
        while heap:
            _, cost, state = heapq.heappop(heap)
            if cost != best.get(state):
                continue
            if state in targets:
                goal = state
                break
            layer, ix, iy = state
            for dx, dy in dirs:
                nxt = (layer, ix + dx, iy + dy)
                if not free(nxt, net):
                    continue
                step = 1.0
                nc = cost + step
                if nc < best.get(nxt, 1e99):
                    best[nxt] = nc
                    parent[nxt] = state
                    heapq.heappush(heap, (nc + heuristic(nxt[1], nxt[2]), nc, nxt))
            other = (1 - layer, ix, iy)
            if free(other, net) and free((layer, ix, iy), net):
                nc = cost + 10.0
                if nc < best.get(other, 1e99):
                    best[other] = nc
                    parent[other] = state
                    heapq.heappush(heap, (nc + heuristic(ix, iy), nc, other))
        if goal is None:
            raise RuntimeError(f"autorouter could not connect {net} at {start_cell}")
        path = [goal]
        while path[-1] not in starts:
            path.append(parent[path[-1]])
        path.reverse()
        return path

    # Route compact/local signal networks before the long supply trunks so
    # nearby through-hole pads cannot be ringed in by an earlier power route.
    route_order = ["BASE", "BUF_IN", "BUF_OUT", "DRIVE", "SENSE", "CTRL_FILT", "BR_A", "BR_B", "BR_P", "BR_N", "OUTPUT", "INPUT", "CTRL_IN", "-12V", "+12V", "GND"]
    for net in route_order:
        pts = terminals[net]
        if len(pts) < 2:
            continue
        root = cell(pts[0])
        tree = {(0, root[0], root[1]), (1, root[0], root[1])}
        for term_index, point in enumerate(pts[1:], 1):
            path = astar(cell(point), tree, net)
            # Collapse collinear runs, emitting a via at each layer transition.
            run = [path[0]]
            for state in path[1:]:
                if state[0] != run[-1][0]:
                    if len(run) >= 2:
                        a, b = run[0], run[-1]
                        out_segments.extend(route(net, [xy(a[1], a[2]), xy(b[1], b[2])], "F.Cu" if a[0] == 0 else "B.Cu", 0.5, f"auto:{term_index}:{len(out_segments)}"))
                    vx, vy = xy(state[1], state[2])
                    # PTH terminals already join the layers. Avoid a redundant
                    # coincident via, which KiCad correctly flags as a hole or
                    # dangling-via defect.
                    if min(math.hypot(vx - px, vy - py) for px, py in terminals[net]) > 1.2:
                        out_vias.append(f'''\t(via (at {vx:g} {vy:g}) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu")
\t\t(net {NET_NUM[net]}) (uuid "{uid('auto-via:' + net + ':' + str(term_index) + ':' + str(vx) + ':' + str(vy))}"))''')
                    run = [state]
                    continue
                if len(run) >= 2:
                    ax, ay = run[-2][1], run[-2][2]
                    bx, by = run[-1][1], run[-1][2]
                    cx, cy = state[1], state[2]
                    if (bx - ax, by - ay) != (cx - bx, cy - by):
                        a, b = run[0], run[-1]
                        out_segments.extend(route(net, [xy(a[1], a[2]), xy(b[1], b[2])], "F.Cu" if a[0] == 0 else "B.Cu", 0.5, f"auto:{term_index}:{len(out_segments)}"))
                        run = [run[-1], state]
                        continue
                run.append(state)
            if len(run) >= 2:
                a, b = run[0], run[-1]
                out_segments.extend(route(net, [xy(a[1], a[2]), xy(b[1], b[2])], "F.Cu" if a[0] == 0 else "B.Cu", 0.5, f"auto:{term_index}:{len(out_segments)}"))
            for state in path:
                tree.add(state)
                layer, ix, iy = state
                for dx in (-1, 0, 1):
                    for dy in (-1, 0, 1):
                        nstate = (layer, ix + dx, iy + dy)
                        if 0 <= nstate[1] <= nx and 0 <= nstate[2] <= ny:
                            owner = used.get(nstate)
                            if owner is None or owner == net:
                                used[nstate] = net
    return out_segments, out_vias


def make_board() -> None:
    fps = footprint_sources()
    libdir = ROOT / "libraries" / "AnalogVCA_THT.pretty"
    for name, text in fps.items():
        (libdir / f"{name}.kicad_mod").write_text(text, encoding="utf-8")
    (ROOT / "fp-lib-table").write_text('(fp_lib_table\n  (lib (name "AnalogVCA_THT")(type "KiCad")(uri "${KIPRJMOD}/libraries/AnalogVCA_THT.pretty")(options "")(descr "Project-local hand-solderable footprints"))\n)\n', encoding="utf-8")

    sch_refs = {ref: uid("sch:" + ref) for ref, *_ in SCHEMATIC_PARTS}
    sch_meta = {part[0]: (part[2], part[4]) for part in SCHEMATIC_PARTS}
    embedded = []
    for ref, fp, x, y, rot, nets in BOARD_PARTS:
        value, mpn = sch_meta[ref]
        embedded.append(board_footprint(fps[fp], ref, value, mpn, x, y, rot, nets, sch_refs[ref]))

    P = board_pad_positions()
    tracks, signal_vias = autoroute_board(P)

    vias = []

    zones = []
    for layer in ("B.Cu",):
        zones.append(f'''\t(zone (net {NET_NUM['GND']}) (net_name "/GND") (layer "{layer}")
\t\t(uuid "{uid('zone:' + layer)}") (hatch edge 0.5)
\t\t(connect_pads (clearance 0.25)) (min_thickness 0.25)
\t\t(fill yes (thermal_gap 0.3) (thermal_bridge_width 0.4) (island_removal_mode 2))
\t\t(polygon (pts (xy 100.6 60.6) (xy 157.4 60.6) (xy 157.4 99.4) (xy 100.6 99.4))))''')

    texts = [
        (136, 98.2, "DIODE VCA REV A", 0.8),
        (104.5, 70.0, "INPUT", 0.8),
        (104.0, 92.5, "CONTROL", 0.8),
        (153.5, 70.0, "OUTPUT", 0.8),
        (115, 98.2, "+12  GND  -12", 0.8),
        (143, 88.5, "DIODE BRIDGE", 0.8),
        (109, 82.0, "C7 + AT GND", 0.8),
    ]
    grtexts = []
    for idx, (x, y, text, size) in enumerate(texts):
        grtexts.append(f'''\t(gr_text "{text}" (at {x:g} {y:g}) (layer "F.SilkS")
\t\t(uuid "{uid('grtext:' + str(idx))}") (effects (font (size {size:g} {size:g}) (thickness 0.14))))''')

    header = '''(kicad_pcb
\t(version 20260206)
\t(generator "pcbnew")
\t(generator_version "10.0")
\t(general (thickness 1.6) (legacy_teardrops no))
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(9 "F.Adhes" user "F.Adhesive")
\t\t(11 "B.Adhes" user "B.Adhesive")
\t\t(13 "F.Paste" user)
\t\t(15 "B.Paste" user)
\t\t(5 "F.SilkS" user "F.Silkscreen")
\t\t(7 "B.SilkS" user "B.Silkscreen")
\t\t(1 "F.Mask" user)
\t\t(3 "B.Mask" user)
\t\t(17 "Dwgs.User" user "User.Drawings")
\t\t(19 "Cmts.User" user "User.Comments")
\t\t(21 "Eco1.User" user "User.Eco1")
\t\t(23 "Eco2.User" user "User.Eco2")
\t\t(25 "Edge.Cuts" user)
\t\t(27 "Margin" user)
\t\t(31 "F.CrtYd" user "F.Courtyard")
\t\t(29 "B.CrtYd" user "B.Courtyard")
\t\t(35 "F.Fab" user)
\t\t(33 "B.Fab" user)
\t)
\t(setup
\t\t(pad_to_mask_clearance 0)
\t\t(allow_soldermask_bridges_in_footprints no)
\t\t(tenting (front yes) (back yes))
\t)
\t(embedded_fonts no)
'''
    nets = "\n".join(f'\t(net {NET_NUM[n]} "/{n}")' for n in NET_ORDER)
    outline = f'''\t(gr_rect (start 100 60) (end 158 100)
\t\t(stroke (width 0.05) (type solid)) (fill none) (layer "Edge.Cuts")
\t\t(uuid "{uid('board-outline')}"))'''
    board = header + nets + "\n" + "\n".join(embedded) + "\n" + "\n".join(tracks + signal_vias + vias + zones + grtexts + [outline]) + "\n)\n"
    (ROOT / f"{NAME}.kicad_pcb").write_text(board, encoding="utf-8")
    (ROOT / "2d-layouts" / f"{NAME}.kicad_pcb").write_text(board, encoding="utf-8")

    rules = '''(version 1)

(rule "Provisional minimum copper clearance"
  (constraint clearance (min 0.20mm)))

(rule "Provisional minimum track width"
  (constraint track_width (min 0.25mm)))

(rule "Provisional via diameter"
  (constraint via_diameter (min 0.60mm)))

(rule "Provisional via drill"
  (constraint hole_size (min 0.30mm)))
'''
    (ROOT / f"{NAME}.kicad_dru").write_text(rules, encoding="utf-8")


def make_project_file() -> None:
    project = {
        "board": {},
        "boards": [],
        "cvpcb": {},
        "erc": {},
        "libraries": {},
        "meta": {"filename": f"{NAME}.kicad_pro", "version": 1},
        "net_settings": {"classes": [], "meta": {"version": 3}},
        "pcbnew": {},
        "schematic": {},
        "sheets": [],
        "text_variables": {},
    }
    (ROOT / f"{NAME}.kicad_pro").write_text(json.dumps(project, indent=2), encoding="utf-8")


def make_simulation_sources() -> None:
    common = '''* Diode-bridge voltage-controlled attenuator
* Actual ngspice model: ideal voltage buffer, high-gain control servo,
* onsemi-like 1N4148 and 2N3904 compact models, exact schematic passives.
.param VC=0
.param VINPP=0.1
.param FREQ=1k
VCC vcc 0 12
VEE vee 0 -12
VCTRL ctrl 0 {VC}
RCTRL ctrl ctrlf 10k
RCTRLPD ctrlf 0 1meg
CCTRL ctrlf 0 100n
VIN in 0 SIN(0 {VINPP/2} {FREQ}) AC 1
RIN in bufin 1k
RINPD bufin 0 1meg
E_BUF buf 0 bufin 0 1
RSER buf out 2.2k
RLOAD out 0 100k
CC1 out bra 220u ic=0
RCC1 bra bra_i 0.20
CC2 brb_i 0 220u ic=0
RCC2 brb brb_i 0.20
D1 brp bra_i D4148A
D2 brp brb D4148A
D3 bra_i brn D4148A
D4 brb brn D4148A
RFEED vcc brp 22k
BOP opout 0 V=max(-11,min(11,1e5*(V(ctrlf)-V(sense))))
RBASE opout base 4.7k
Q1 brn base sense Q2N3904
RE sense 0 8.06k
RLEAKP brp 0 100meg
RLEAKN brn 0 100meg
RLEAKA bra_i 0 100meg
RLEAKB brb 0 100meg
.model D4148A D(Is=4.352n Rs=0.6458 N=1.906 Cjo=7.048p M=0.5 Vj=0.869 Tt=3.48n Bv=100 Ibv=0.1u)
.model D4148B D(Is=4.439n Rs=0.6458 N=1.906 Cjo=7.048p M=0.5 Vj=0.869 Tt=3.48n Bv=100 Ibv=0.1u)
.model Q2N3904 NPN(Is=6.734f Xti=3 Eg=1.11 Vaf=74.03 Bf=416.4 Ne=1.259 Ise=6.734f Ikf=66.78m Xtb=1.5 Br=0.7371 Nc=2 Isc=0 Ikr=0 Rc=1 Cjc=3.638p Mjc=0.3085 Vjc=0.75 Fc=0.5 Cje=4.493p Mje=0.2593 Vje=0.75 Tr=239.5n Tf=301.2p Itf=0.4 Vtf=4 Xtf=2 Rb=10)
'''
    (ROOT / "simulation" / "decks" / "common.cir.inc").write_text(common, encoding="utf-8")

    runner = '''#!/usr/bin/env python3
from pathlib import Path
import csv, re, subprocess

HERE = Path(__file__).resolve().parent
DECKS = HERE / "decks"
RESULTS = HERE / "results"
COMMON = (DECKS / "common.cir.inc").read_text()
RESULTS.mkdir(exist_ok=True)

def run(name, tail, params):
    deck = COMMON
    for key, value in params.items():
        deck = re.sub(rf"\\.param {key}=[^ \\n]+", f".param {key}={value}", deck)
    deck += "\\n" + tail + "\\n.end\\n"
    path = DECKS / f"{name}.cir"
    log = RESULTS / f"{name}.log"
    path.write_text(deck)
    p = subprocess.run(["ngspice", "-b", "-o", str(log), str(path)], text=True, capture_output=True)
    if p.returncode:
        raise SystemExit(f"{name}: ngspice failed: {p.stderr}")
    return log.read_text(errors="replace")

rows = []
for vc in (0, 0.5, 1, 1.5, 2):
    name = f"small_vc_{str(vc).replace('.', 'p')}"
    text = run(name, """.tran 2u 30m 10m
.measure tran vout_pp PP v(out) from=20m to=30m
.measure tran vin_pp PP v(in) from=20m to=30m
.measure tran gain param='vout_pp/vin_pp'
.four 1k v(out)""", {"VC": vc, "VINPP": 0.1, "FREQ": "1k"})
    vals = dict(re.findall(r"^(vout_pp|vin_pp|gain)\\s*=\\s*([-+0-9.eE]+)", text, re.M))
    thd = re.search(r"THD:\\s*([0-9.eE+-]+) %", text)
    rows.append({"control_v": vc, "vin_vpp": 0.1, "vout_vpp": float(vals["vout_pp"]), "gain": float(vals["gain"]), "thd_percent": float(thd.group(1)) if thd else None})

for vc in (0, 0.5, 1, 1.5, 2):
    name = f"large_vc_{str(vc).replace('.', 'p')}"
    text = run(name, """.tran 1u 30m 10m
.measure tran vout_pp PP v(out) from=20m to=30m
.measure tran vin_pp PP v(in) from=20m to=30m
.measure tran gain param='vout_pp/vin_pp'
.four 1k v(out)""", {"VC": vc, "VINPP": 12, "FREQ": "1k"})
    vals = dict(re.findall(r"^(vout_pp|vin_pp|gain)\\s*=\\s*([-+0-9.eE]+)", text, re.M))
    thd = re.search(r"THD:\\s*([0-9.eE+-]+) %", text)
    rows.append({"control_v": vc, "vin_vpp": 12, "vout_vpp": float(vals["vout_pp"]), "gain": float(vals["gain"]), "thd_percent": float(thd.group(1)) if thd else None})

with (RESULTS / "transient_summary.csv").open("w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=rows[0].keys()); w.writeheader(); w.writerows(rows)

freq_rows = []
for vc in (0, 0.5, 1, 1.5, 2):
    name = f"ac_vc_{str(vc).replace('.', 'p')}"
    text = run(name, """.ac dec 200 20 20k
.print ac vm(out)
.measure ac gain20 FIND vm(out) AT=20
.measure ac gain1k FIND vm(out) AT=1k
.measure ac gain20k FIND vm(out) AT=20k""", {"VC": vc, "VINPP": 0.1, "FREQ": "1k"})
    vals = dict(re.findall(r"^(gain20|gain1k|gain20k)\\s*=\\s*([-+0-9.eE]+)", text, re.M))
    import math
    freq_rows.append({"control_v": vc, "gain_db_20hz": 20*math.log10(float(vals["gain20"])), "gain_db_1khz": 20*math.log10(float(vals["gain1k"])), "gain_db_20khz": 20*math.log10(float(vals["gain20k"]))})
with (RESULTS / "frequency_summary.csv").open("w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=freq_rows[0].keys()); w.writeheader(); w.writerows(freq_rows)

# Worst-case-style feedthrough model: D4 Is is 2% high and control rises in 1 ms.
mismatch = COMMON.replace("D4 brb brn D4148A", "D4 brb brn D4148B").replace("VCTRL ctrl 0 {VC}", "VCTRL ctrl 0 PWL(0 0 9m 0 10m 2 40m 2)")
mismatch = mismatch.replace("VIN in 0 SIN(0 {VINPP/2} {FREQ}) AC 1", "VIN in 0 0")
tail = """.tran 2u 40m
.print tran v(out)
.measure tran ftmax MAX v(out) from=9m to=25m
.measure tran ftmin MIN v(out) from=9m to=25m"""
path = DECKS / "control_feedthrough_mismatch.cir"
path.write_text(mismatch + "\\n" + tail + "\\n.end\\n")
log = RESULTS / "control_feedthrough_mismatch.log"
subprocess.run(["ngspice", "-b", "-o", str(log), str(path)], check=True)
feed_text = log.read_text()
ftmax = float(re.search(r"ftmax\\s*=\\s*([-+0-9.eE]+)", feed_text, re.M).group(1))
ftmin = float(re.search(r"ftmin\\s*=\\s*([-+0-9.eE]+)", feed_text, re.M).group(1))
(RESULTS / "control_feedthrough_summary.txt").write_text(f"2% D4 Is mismatch, 0-to-2 V / 1 ms control ramp: peak |Vout| = {max(abs(ftmax), abs(ftmin)):.6g} V\\n")
print("simulation complete")
'''
    run_path = ROOT / "simulation" / "run_simulations.py"
    run_path.write_text(runner, encoding="utf-8")
    run_path.chmod(0o755)


def main() -> None:
    ensure_dirs()
    make_schematic()
    make_board()
    make_project_file()
    make_simulation_sources()
    print(f"Generated {ROOT}")


if __name__ == "__main__":
    main()