main
ae007 Project page: AFS_Adom rev A (Rice Eclipse AFS clean-room AI redesign): highlight reel and narrated process video with… cbe460e 7d ago
"""AFS_Adom board generator: embeds footprints, assigns nets, places parts, draws outline/zones/silk.

Usage: python3 -I pcb.py  -> writes AFS_Adom/AFS_Adom.kicad_pcb (unrouted) and placement.json
"""
import sys, json, math, copy, os
sys.path.insert(0, '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad/gen')
from kicad_gen import *

SCR = '/tmp/claude-1001/-home-adom-project/8819fb5f-c2c6-4524-9528-99428cd6b88e/scratchpad'
OUT = SCR + '/AFS_Adom'
FPD = SCR + '/fps'
nl = json.load(open(OUT + '/netlist.json'))
COMPS = nl['comps']
ROOT = nl['root_uuid']

# ------------------------------------------------------------------ board frame
BX0, BY0, BW, BH = 100.0, 100.0, 92.0, 68.0
MP1 = (102.0, 166.0)   # molecule origin: front-left machine pin (KiCad y down); y up in the design frame
SPAN = (88.0, 64.0)
RCORNER = 2.0
BX1, BY1 = BX0 + BW, BY0 + BH


def fp_tree(name):
    t = parse(open(FPD + '/' + name + '.kicad_mod', encoding='utf-8').read())[0]
    return t


def fp_pads(name):
    """[(number, x, y, w, h, kind)] relative, unrotated."""
    t = fp_tree(name)
    out = []
    for p in find(t, 'pad'):
        at = find1(p, 'at'); sz = find1(p, 'size')
        out.append((uq(p[1]), float(at[1]), float(at[2]), float(sz[1]), float(sz[2]), p[2]))
    return out


def rotpt(x, y, r):
    """Rotate a footprint-local point by r degrees (KiCad: CCW on screen, y down)."""
    a = math.radians(r)
    return (x * math.cos(a) + y * math.sin(a), -x * math.sin(a) + y * math.cos(a))


def pad_abs(ref, pad):
    c = COMPS[ref]
    x, y, r = PL[ref]
    for n, px, py, w, h, k in fp_pads(c['footprint'].split(':')[1]):
        if n == pad:
            dx, dy = rotpt(px, py, r)
            return (x + dx, y + dy)
    raise KeyError(ref + '.' + pad)


# ------------------------------------------------------------------ placement
PL = {}   # ref -> (x, y, rot)
def P(ref, x, y, r=0):
    PL[ref] = (round(x, 3), round(y, 3), r)

# mounting holes
P('H1', 107, 105); P('H2', 185, 105); P('H3', 107, 163); P('H4', 185, 163)
P('MP1', MP1[0], MP1[1]); P('MP2', MP1[0] + SPAN[0], MP1[1]); P('MP3', MP1[0], MP1[1] - SPAN[1]); P('MP4', MP1[0] + SPAN[0], MP1[1] - SPAN[1])
MOLECULE_FIXED = {'MC1': (0, 38), 'MC2': (0, 42), 'MC3': (0, 46), 'MC4': (0, 50),
                  'MC5': (88, 50), 'MC6': (88, 46), 'MC7': (88, 42), 'MC8': (32, 8), 'MC9': (32, 4), 'MC10': (34, 4), 'MC11': (36, 4),
                  'MC12': (32, 6), 'MC13': (88, 56), 'MC14': (88, 4)}   # mm from MP1, y up (aiflow molecule frame)
for ref, (mx, my) in MOLECULE_FIXED.items():
    P(ref, MP1[0] + mx, MP1[1] - my)
# servo headers along the top edge, pin 1 (signal) left, pins run +x
for i in range(8):
    P('J%d' % (i + 2), 113 + 8.8 * i, 104.6, 90)
# level shifter below the header row; bulk caps under the row
P('U13', 143, 112.2, 90)
P('C3', 136.5, 121.6, 0); P('C4', 152.5, 117.4, 0)
P('C%d' % (nl_c('74AHCT541 VCC')), 149, 110.5, 90) if False else None
# MCU
P('U1', 147, 136, 180)
# crystal group above the chip (XIN/XOUT on the top row)
P('Y1', 148.2, 124.8, 90)
P('R9', 144.6, 124.4, 0)
# flash below-right of the QSPI pins
P('U7', 155.5, 149.4, 90)
P('R10', 161.5, 146, 90); P('R11', 161.5, 150, 90)
# USB
P('J10', 145, 164.33, 0)
P('U14', 145, 156.6, 0)
P('R13', 147.2, 152.4, 90); P('R14', 148.8, 152.4, 90)
P('R19', 139.0, 158.6, 0); P('R20', 152.5, 160.4, 0)
# buttons
P('SW2', 127.0, 157.6, 0); P('SW1', 161, 157.6, 0); P('R12', 138.4, 152.0, 90)
# JST headers bottom edge
P('J12', 113, 164.2); P('J13', 124, 164.2); P('J14', 153.0, 164.2); P('J15', 164.0, 164.2)
# right edge: spare SPI, GPIO, microSD
P('J16', 180.0, 111.2, 0); P('J17', 180.0, 117.0, 0)
P('J11', 180.2, 151.5, 90)
# left edge: XT30 (mouth to the left), power-out headers
P('J1', 113.7, 140.2, 90)          # XT30 mouth flush with the left edge
P('J18', 162.5, 112.0, 0); P('J19', 172.0, 112.0, 0); P('J20', 126.6, 118.5, 0); P('J21', 126.6, 126.5, 0)
# battery input stage
P('R1', 120.6, 148.2, 0)          # shunt: pad1 VBAT_IN (left), pad2 VBAT (right)
P('D1', 119.6, 143.6, 0)          # TVS, K (pad1) left on VBAT_IN
P('R2', 116.5, 152.4, 0); P('R3', 120.6, 152.4, 0); P('C1', 125.8, 150.8, 90)
P('U2', 131.4, 151.6, 0); P('C2', 131.4, 147.9, 0)
# ideal diodes
P('Q1', 126.4, 139.0, 0); P('U3', 126.4, 143.6, 0)
P('Q2', 133.4, 139.0, 0); P('U4', 133.4, 143.6, 0)
# 5 V buck block
# SW pin 5 (right-middle of the TSOT-23-6) faces the inductor pad 1 at 1.1 mm; BST cap between pin 6 and the SW pad;
# input cap at the VIN pins on the left; output caps at the inductor pad 2 (datasheet AP63205 layout guide)
P('U5', 112.0, 111.6, 270); P('C6', 109.2, 111.6, 90); P('C7', 114.6, 112.1, 90); P('TP6', 116.0, 115.6, 0)   # BST cap pad 2 0.2 mm above the SW pad; SW test point beside the SW pad
P('L1', 112.0, 117.3, 270); P('C8', 112.0, 122.0, 0); P('C9', 117.8, 118.8, 0)
P('R6', 118.6, 116.4, 0); P('D4', 118.6, 114.6, 0)
# 3.3 V buck block
P('U6', 112.0, 125.1, 270); P('C10', 109.2, 125.1, 90); P('C11', 114.6, 125.6, 90); P('TP8', 116.0, 129.1, 0)
P('L2', 112.0, 130.8, 270); P('C12', 117.8, 134.3, 0); P('C13', 117.8, 132.3, 0)
P('C5', 106.0, 120.0, 90)
P('R7', 118.6, 129.9, 0); P('D5', 118.6, 128.1, 0)
# rail LEDs near the top-left
P('R4', 109.5, 110.4, 0) if False else None
P('R4', 123.3, 120.5, 90); P('D2', 123.3, 123.9, 90)
P('R5', 135.8, 149.2, 90); P('D3', 135.8, 152.6, 90)
# user LED
P('R15', 156.6, 128.4, 0); P('D6', 156.6, 131.0, 0)
# sensors on the right half, away from the inductors (x >= 160)
P('U8', 167.5, 125.5, 0)      # ICM-45686, axes parallel to the board edges
P('U9', 167.5, 135.0, 0)      # KX134-1211
P('U10', 183.5, 135.5, 0)     # MMC5983MA (right edge, clear of the SD socket)
P('U11', 176.5, 128.0, 0)     # MS5611
P('U12', 175.5, 134.0, 0)     # TMP117
P('R16', 171.0, 138.8, 0)
P('R17', 160.0, 152.5, 90); P('R18', 163.0, 152.5, 90)
# SD pull-ups column left of the SD socket
for i, r in enumerate(['R21', 'R22', 'R23', 'R24', 'R25', 'R26']):
    P(r, 169.0, 143.0 + 2.4 * i, 0)
P('R27', 174.5, 160.8, 0); P('R28', 177.8, 160.8, 0)
# remaining caps: placed next to the pins they serve (below)


def place_near_pin(ref, ic, pin, dist=2.3, along=0.0, flip=False):
    """Put a 2-pad passive radially outside IC pad `pin`, pad 1 toward the IC."""
    x, y, r = PL[ic]
    pads = {n: (px, py) for n, px, py, w, h, k in fp_pads(COMPS[ic]['footprint'].split(':')[1])}
    px, py = pads[pin]
    dx, dy = rotpt(px, py, r)
    # side by the dominant axis of the rotated pad position
    if abs(dx) > abs(dy):
        sx, sy = (1 if dx > 0 else -1), 0
    else:
        sx, sy = 0, (1 if dy > 0 else -1)
    cx, cy = x + dx + sx * dist + (-sy) * along, y + dy + sy * dist + sx * along
    rot = {(-1, 0): 180, (1, 0): 0, (0, -1): 90, (0, 1): 270}[(sx, sy)]
    if flip:
        rot = (rot + 180) % 360
    P(ref, cx, cy, rot)


# RP2350 decoupling caps by the pin they serve (C14..C21 IOVDD pins 5,15,24,29,41,50,60,76)
iovdd = ['5', '15', '24', '29', '41', '50', '60', '76']
for c, pin in zip(['C14', 'C15', 'C16', 'C17', 'C18', 'C19', 'C20', 'C21'], iovdd):
    place_near_pin(c, 'U1', pin, dist=4.0)
P('C21', 153.2, 143.9, 0)                 # IOVDD pin 76: served through a plane via, cap beside the via
# crystal sits on the top row: shift the pin-29 cap sideways, DVDD pin-32 cap the other way
P('C17', 144.6, 119.5, 0)
P('C16', 153.6, 128.4, 90)
P('C22', 151.2, 146.4, 270)               # QSPI_IOVDD (pin 69), east of the USB pair, pad 1 up
P('C23', 149.6, 146.4, 270)               # USB_OTP_VDD (pin 68), east of the USB pair, pad 1 up
P('C24', 138.2, 141.6, 180)              # ADC_AVDD (pin 59), second tier under the pin-60 cap
place_near_pin('C25', 'U1', '10', dist=4.0)          # DVDD right
P('C18', 138.2, 132.2, 180); P('C19', 138.2, 134.6, 180); P('C27', 138.2, 137.0, 180); P('C20', 138.2, 139.4, 180)
place_near_pin('C26', 'U1', '32')          # DVDD top
P('C26', 141.2, 128.1, 270)
# (C27 placed explicitly above) place_near_pin('C27', 'U1', '51')          # DVDD left
P('C28', 135.0, 131.8, 90)                 # 3V3 bulk 10 uF left of the chip
# core regulator (bottom-left pins 61..65): CIN C29 at VREG_VIN, L3, COUT C30, second 4.7u C31, R8/C32 filter
P('C29', 144.4, 145.6, 270)     # CIN: pad1 (3V3) up, straight under VREG_VIN; GND pad down     # CIN: pad1 3V3 (top) near VREG_VIN pin 64, pad2 GND
P('L3', 141.2, 145.6, 270)      # LX pad up, at the end of the LX diagonal; DVDD pad down       # LX pad1 left (toward VREG_LX), DVDD pad2 right
P('C30', 147.6, 146.6, 270)     # COUT: pad1 (DVDD) up on the FB diagonal; GND pad down     # COUT between VREG_VIN and VREG_PGND side, pad1 DVDD
P('C31', 135.4, 135.0, 90)     # second 4.7u on DVDD near pin 51 (left column)
P('R8', 138.6, 144.6, 270)      # 33 R: 3V3 up, VREG_AVDD down      # 33 R to VREG_AVDD
P('C32', 138.6, 147.9, 270)     # CFILT 4.7u: VREG_AVDD up, GND down     # 4.7u on VREG_AVDD
# crystal caps
P('C33', 151.2, 124.8, 90); P('C34', 144.0, 121.4, 0)
# flash cap
P('C35', 153.8, 156.6, 0)
# sensors caps
place_near_pin('C36', 'U8', '8', dist=3.2)
place_near_pin('C37', 'U8', '5', dist=3.2)
place_near_pin('C38', 'U9', '7', dist=3.2)
place_near_pin('C39', 'U9', '3', dist=3.2)
place_near_pin('C40', 'U10', '10', dist=3.2)   # 10 uF CAP
P('C41', 182.0, 139.0, 0)   # U10 VDD cap, below the part (left side faces C44)
place_near_pin('C42', 'U10', '13', dist=3.2)
place_near_pin('C43', 'U11', '1', dist=3.2)
place_near_pin('C44', 'U12', '5', dist=3.2)
# servos VCC cap, connectors caps
place_near_pin('C45', 'U13', '20', dist=2.2)
P('C46', 150.6, 155.2, 90)     # VBUS 10 uF near the connector/ideal diode path
P('C47', 169.0, 158.0, 0); P('C48', 169.0, 160.4, 0)   # SD VDD caps

def place_testpoints():
    import itertools
    boxes = []
    for r, (x, y, rr) in PL.items():
        name = COMPS[r]['footprint'].split(':')[1]
        t = fp_tree(name)
        xs = []; ys = []
        for g in find(t, 'fp_line') + find(t, 'fp_rect') + find(t, 'fp_poly') + find(t, 'fp_circle') + find(t, 'fp_arc'):
            if uq(find1(g, 'layer')[1]) != 'F.CrtYd':
                continue
            if g[0] == 'fp_circle':
                c0 = find1(g, 'center'); e0 = find1(g, 'end')
                rad = math.hypot(float(e0[1]) - float(c0[1]), float(e0[2]) - float(c0[2]))
                xs += [float(c0[1]) - rad, float(c0[1]) + rad]; ys += [float(c0[2]) - rad, float(c0[2]) + rad]
                continue
            for key in ('start', 'end', 'center', 'mid'):
                e = find1(g, key)
                if e:
                    xs.append(float(e[1])); ys.append(float(e[2]))
            pts = find1(g, 'pts')
            if pts:
                for xy in find(pts, 'xy'):
                    xs.append(float(xy[1])); ys.append(float(xy[2]))
        if not xs:
            pads = fp_pads(name)
            xs = [px for n, px, py, w, h, k in pads] or [0]; ys = [py for n, px, py, w, h, k in pads] or [0]
        corners = [rotpt(px, py, rr) for px in (min(xs), max(xs)) for py in (min(ys), max(ys))]
        mg = 4.0 if r == 'U1' else (2.5 if r in ('U7', 'U8', 'U9', 'U10', 'U11', 'U12', 'U13', 'U3', 'U4', 'U2') else 1.0)   # keep the fan-out bands free
        boxes.append((x + min(c[0] for c in corners) - mg, y + min(c[1] for c in corners) - mg, x + max(c[0] for c in corners) + mg, y + max(c[1] for c in corners) + mg))
    tps = [r for r in COMPS if r.startswith('TP') and r not in PL]
    for ref in tps:
        net = COMPS[ref]['pads']['1']
        anchors = []
        for r, c in COMPS.items():
            if r in PL and r != ref:
                for pin, n in c['pads'].items():
                    if n == net:
                        anchors.append((0 if r[0] in 'CRLJ' else 1, pad_abs(r, pin)))
        anchors = [a for _, a in sorted(anchors, key=lambda t: t[0])]       # anchor on a passive or connector, not an IC pin
        if not anchors:
            anchors = [(BX0 + 10, BY0 + 10)]
        ax, ay = anchors[0]
        best = None
        for rad in [2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0]:
            for ang in range(0, 360, 20):
                x = ax + rad * math.cos(math.radians(ang)); y = ay + rad * math.sin(math.radians(ang))
                x, y = round(x * 2) / 2, round(y * 2) / 2
                if not (BX0 + 3 < x < BX1 - 3 and BY0 + 3 < y < BY1 - 3):
                    continue
                if any(bx0 <= x <= bx1 and by0 <= y <= by1 for (bx0, by0, bx1, by1) in boxes):
                    continue
                best = (x, y); break
            if best:
                break
        if best is None:
            best = (BX0 + 46, BY0 + 34)
        P(ref, best[0], best[1])
        boxes.append((best[0] - 1.6, best[1] - 1.6, best[0] + 1.6, best[1] + 1.6))
place_testpoints()
missing = [r for r in COMPS if r not in PL]
assert not missing, missing

# ------------------------------------------------------------------ nets
netnames = sorted(set(n for c in COMPS.values() for n in c['pads'].values() if n != 'NC'))
NET = {n: i + 1 for i, n in enumerate(netnames)}
NET[''] = 0


def net_of(ref, pad):
    n = COMPS[ref]['pads'].get(pad)
    if n is None or n == 'NC':
        return None
    return n


# ------------------------------------------------------------------ footprint emit
PROJECT_MODELS = {'QFN-80-1EP_10x10mm_P0.4mm_EP3.4x3.4mm', 'Kionix_LGA-12_2x2mm_P0.5mm_LayoutBorder2x4y', 'AMASS_XT30PW-M_1x02_P2.50mm_Horizontal'}


def model_clause(path):
    return ['model', q(path), ['offset', ['xyz', '0', '0', '0']], ['scale', ['xyz', '1', '1', '1']], ['rotate', ['xyz', '0', '0', '0']]]


def emit_fp(ref):
    c = COMPS[ref]
    lib, name = c['footprint'].split(':')
    t = copy.deepcopy(fp_tree(name))
    x, y, r = PL[ref]
    out = ['footprint', q(c['footprint']), ['layer', q('F.Cu')], ['uuid', q(nuid())], ['at', fmt(x), fmt(y), fmt(r)]]
    body = []
    for item in t[2:]:
        if not isinstance(item, list):
            continue
        k = item[0]
        if k in ('version', 'generator', 'generator_version', 'layer', 'uuid', 'at', 'embedded_fonts', 'property'):
            continue
        if k == 'model':
            path = uq(item[1])
            if 'adom-kicad-footprint-library.3dshapes' in path:
                item[1] = q('${KIPRJMOD}/3d/' + os.path.basename(path))
            if name in PROJECT_MODELS:
                item[1] = q('${KIPRJMOD}/3d/' + name + '.step')
        if k == 'pad':
            at = find1(item, 'at')
            ang = (float(at[3]) if len(at) > 3 else 0.0) + r
            if len(at) > 3:
                at[3] = fmt(ang % 360)
            elif r:
                at.append(fmt(r % 360))
            n = net_of(ref, uq(item[1]))
            # insert net after layers
            idx = max(i for i, e in enumerate(item) if isinstance(e, list) and e[0] in ('layers', 'size', 'drill', 'at'))
            if n is not None:
                item.insert(idx + 1, ['net', str(NET[n]), q(n)])
                item.insert(idx + 2, ['pinfunction', q(n)])
            if not find1(item, 'uuid'):
                item.append(['uuid', q(nuid())])
        if k in ('fp_text',):
            at = find1(item, 'at')
            if len(at) > 3:
                at[3] = fmt((float(at[3]) + r) % 360)
            elif r:
                at.append(fmt(r % 360))
        body.append(item)
    # properties
    def prop(k, v, layer, hide, px, py, size=1.0, th=0.15):
        return ['property', q(k), q(v), ['at', fmt(px), fmt(py), fmt(r % 360)], ['layer', q(layer)]] + ([['hide', 'yes']] if hide else []) + \
               [['uuid', q(nuid())], ['effects', ['font', ['size', fmt(size), fmt(size)], ['thickness', fmt(th)]]]]
    # reference above the part on silk; value on fab
    ys = [py for n, px, py, w, h, k in fp_pads(name)] or [0]
    top = min(ys) - 1.6
    props = [prop('Reference', ref, 'F.Fab', False, 0, top, 0.8, 0.12), prop('Value', c['value'], 'F.Fab', False, 0, 0, 0.3, 0.06),
             prop('Datasheet', c['fields'].get('Datasheet', ''), 'F.Fab', True, 0, 0), prop('Description', c['fields'].get('Description', ''), 'F.Fab', True, 0, 0)]
    for k in ('MPN', 'Manufacturer', 'Mouser', 'LCSC', 'DigiKey'):
        if k in c['fields']:
            props.append(prop(k, c['fields'][k], 'F.Fab', True, 0, 0))
    out += [['descr', q(uq(find1(t, 'descr')[1]) if find1(t, 'descr') else '')]]
    if find1(t, 'tags'):
        out.append(['tags', q(uq(find1(t, 'tags')[1]))])
    out += props
    out += [['path', q(c['path'] + '/' + c['uuid'])], ['sheetname', q(c['sheet'].replace('.kicad_sch', ''))], ['sheetfile', q(c['sheet'])]]
    attr = find1(t, 'attr')
    if ref.startswith('H') or ref.startswith('TP'):
        out.append(['attr', 'smd' if ref.startswith('TP') else 'through_hole', 'exclude_from_pos_files', 'exclude_from_bom'])
    elif ref.startswith('MP') or ref.startswith('MC'):
        out.append(['attr', 'through_hole', 'exclude_from_pos_files'])
    elif attr:
        out.append(attr)
    out += [b for b in body if b[0] != 'attr']
    if not any(b[0] == 'model' for b in body) and name.startswith('L_0806'):
        out.append(model_clause('${KIPRJMOD}/3d/L_0806_2016Metric_AOTA-B201610S.step'))
    out.append(['embedded_fonts', 'no'])
    return out


# ------------------------------------------------------------------ board
def gr_line(a, b, layer, w=0.1):
    return ['gr_line', ['start', fmt(a[0]), fmt(a[1])], ['end', fmt(b[0]), fmt(b[1])], ['stroke', ['width', fmt(w)], ['type', 'default']], ['layer', q(layer)], ['uuid', q(nuid())]]


def gr_text(s, x, y, layer, size=1.0, th=0.15, rot=0, justify=None):
    e = ['effects', ['font', ['size', fmt(size), fmt(size)], ['thickness', fmt(th)]]]
    if justify:
        e.append(['justify'] + justify)
    return ['gr_text', q(s), ['at', fmt(x), fmt(y), str(rot)], ['layer', q(layer)], ['uuid', q(nuid())], e]


def zone(net, layer, pts, priority=0, clearance=0.3, minw=0.25, thermal=True, name=''):
    z = ['zone', ['net', str(NET[net]) if net else '0'], ['net_name', q(net)], ['layer', q(layer)], ['uuid', q(nuid())]]
    if name:
        z.append(['name', q(name)])
    z += [['hatch', 'edge', '0.5'], ['priority', str(priority)],
          ['connect_pads', 'yes' if not thermal else '', ['clearance', fmt(clearance)]] if not thermal else ['connect_pads', ['clearance', fmt(clearance)]],
          ['min_thickness', fmt(minw)], ['filled_areas_thickness', 'no'],
          ['fill', 'yes', ['thermal_gap', '0.4'], ['thermal_bridge_width', '0.5'], ['island_removal_mode', '1'], ['island_area_min', '2']],
          ['polygon', ['pts'] + [['xy', fmt(x), fmt(y)] for x, y in pts]]]
    # clean the connect_pads token
    for i, e in enumerate(z):
        if isinstance(e, list) and e[0] == 'connect_pads':
            z[i] = [x for x in e if x != '']
    return z


def keepout(layers, pts, name):
    return ['zone', ['net', '0'], ['net_name', q('')], ['layers'] + [q(l) for l in layers], ['uuid', q(nuid())], ['name', q(name)],
            ['hatch', 'edge', '0.5'], ['priority', '10'], ['connect_pads', ['clearance', '0']], ['min_thickness', '0.25'],
            ['keepout', ['tracks', 'allowed'], ['vias', 'allowed'], ['pads', 'allowed'], ['copperpour', 'not_allowed'], ['footprints', 'allowed']],
            ['fill', ['thermal_gap', '0.5'], ['thermal_bridge_width', '0.5']],
            ['polygon', ['pts'] + [['xy', fmt(x), fmt(y)] for x, y in pts]]]


def build(tracks=None, vias=None, zones_extra=None, out=OUT + '/AFS_Adom.kicad_pcb', plane_zones=True):
    pcb = ['kicad_pcb', ['version', '20241229'], ['generator', q('pcbnew')], ['generator_version', q('9.0')],
           ['general', ['thickness', '1.6'], ['legacy_teardrops', 'no']], ['paper', q('A3')],
           ['title_block', ['title', q('AFS_Adom flight computer')], ['date', q('2026-10-15')], ['rev', q('A')], ['company', q('AFS_Adom clean-room design')]],
           ['layers', ['0', q('F.Cu'), 'signal'], ['1', q('In1.Cu'), 'power'], ['2', q('In2.Cu'), 'power'], ['31', q('B.Cu'), 'signal'],
            ['32', q('B.Adhes'), 'user', q('B.Adhesive')], ['33', q('F.Adhes'), 'user', q('F.Adhesive')], ['34', q('B.Paste'), 'user'], ['35', q('F.Paste'), 'user'],
            ['36', q('B.SilkS'), 'user', q('B.Silkscreen')], ['37', q('F.SilkS'), 'user', q('F.Silkscreen')], ['38', q('B.Mask'), 'user'], ['39', q('F.Mask'), 'user'],
            ['40', q('Dwgs.User'), 'user', q('User.Drawings')], ['41', q('Cmts.User'), 'user', q('User.Comments')], ['42', q('Eco1.User'), 'user', q('User.Eco1')],
            ['43', q('Eco2.User'), 'user', q('User.Eco2')], ['44', q('Edge.Cuts'), 'user'], ['45', q('Margin'), 'user'], ['46', q('B.CrtYd'), 'user', q('B.Courtyard')],
            ['47', q('F.CrtYd'), 'user', q('F.Courtyard')], ['48', q('B.Fab'), 'user'], ['49', q('F.Fab'), 'user']],
           ['setup',
            ['stackup', ['layer', q('F.SilkS'), ['type', q('Top Silk Screen')]], ['layer', q('F.Paste'), ['type', q('Top Solder Paste')]],
             ['layer', q('F.Mask'), ['type', q('Top Solder Mask')], ['thickness', '0.01']],
             ['layer', q('F.Cu'), ['type', q('copper')], ['thickness', '0.035']],
             ['layer', q('dielectric 1'), ['type', q('prepreg')], ['thickness', '0.2'], ['material', q('FR4')], ['epsilon_r', '4.5'], ['loss_tangent', '0.02']],
             ['layer', q('In1.Cu'), ['type', q('copper')], ['thickness', '0.035']],
             ['layer', q('dielectric 2'), ['type', q('core')], ['thickness', '1.0'], ['material', q('FR4')], ['epsilon_r', '4.5'], ['loss_tangent', '0.02']],
             ['layer', q('In2.Cu'), ['type', q('copper')], ['thickness', '0.035']],
             ['layer', q('dielectric 3'), ['type', q('prepreg')], ['thickness', '0.2'], ['material', q('FR4')], ['epsilon_r', '4.5'], ['loss_tangent', '0.02']],
             ['layer', q('B.Cu'), ['type', q('copper')], ['thickness', '0.035']],
             ['layer', q('B.Mask'), ['type', q('Bottom Solder Mask')], ['thickness', '0.01']], ['layer', q('B.Paste'), ['type', q('Bottom Solder Paste')]],
             ['layer', q('B.SilkS'), ['type', q('Bottom Silk Screen')]], ['copper_finish', q('ENIG')], ['dielectric_constraints', 'no']],
            ['pad_to_mask_clearance', '0.0508'], ['allow_soldermask_bridges_in_footprints', 'no'],
            ['pcbplotparams', ['layerselection', '0x00000000_00000000_55555555_5755f5ff'], ['plot_on_all_layers_selection', '0x00000000_00000000_00000000_00000000'],
             ['disableapertmacros', 'no'], ['usegerberextensions', 'no'], ['usegerberattributes', 'yes'], ['usegerberadvancedattributes', 'yes'], ['creategerberjobfile', 'yes'],
             ['dashed_line_dash_ratio', '12'], ['dashed_line_gap_ratio', '3'], ['svgprecision', '4'], ['plotframeref', 'no'], ['mode', '1'], ['useauxorigin', 'no'],
             ['hpglpennumber', '1'], ['hpglpenspeed', '20'], ['hpglpendiameter', '15'], ['pdf_front_fp_property_popups', 'yes'], ['pdf_back_fp_property_popups', 'yes'],
             ['pdf_metadata', 'yes'], ['pdf_single_document', 'no'], ['dxfpolygonmode', 'yes'], ['dxfimperialunits', 'yes'], ['dxfusepcbnewfont', 'yes'],
             ['psnegative', 'no'], ['psa4output', 'no'], ['plot_black_and_white', 'yes'], ['sketchpadsonfab', 'no'], ['plotpadnumbers', 'no'], ['hidednponfab', 'no'],
             ['sketchdnponfab', 'yes'], ['crossoutdnponfab', 'yes'], ['subtractmaskfromsilk', 'no'], ['outputformat', '1'], ['mirror', 'no'], ['drillshape', '0'],
             ['scaleselection', '1'], ['outputdirectory', q('outputs/gerbers/')]]]]
    pcb.append(['net', '0', q('')])
    for n in netnames:
        pcb.append(['net', str(NET[n]), q(n)])
    for ref in COMPS:
        pcb.append(emit_fp(ref))
    # outline: pin span + 2 mm margin all round, corner arcs of radius 2 mm centred on the corner pins
    rcorner = RCORNER
    pcb.append(gr_line((BX0 + rcorner, BY0), (BX1 - rcorner, BY0), 'Edge.Cuts'))
    pcb.append(gr_line((BX1, BY0 + rcorner), (BX1, BY1 - rcorner), 'Edge.Cuts'))
    pcb.append(gr_line((BX1 - rcorner, BY1), (BX0 + rcorner, BY1), 'Edge.Cuts'))
    pcb.append(gr_line((BX0, BY1 - rcorner), (BX0, BY0 + rcorner), 'Edge.Cuts'))
    for (cx, cy, sx, sy) in [(BX0 + rcorner, BY0 + rcorner, BX0, BY0 + rcorner), (BX1 - rcorner, BY0 + rcorner, BX1 - rcorner, BY0),
                             (BX1 - rcorner, BY1 - rcorner, BX1, BY1 - rcorner), (BX0 + rcorner, BY1 - rcorner, BX0 + rcorner, BY1)]:
        ex, ey = (cx + (sx - cx) * math.cos(math.radians(90)) - (sy - cy) * math.sin(math.radians(90)),
                  cy + (sx - cx) * math.sin(math.radians(90)) + (sy - cy) * math.cos(math.radians(90)))
        # 90-degree arc from start (sx,sy) about (cx,cy); compute mid point
        a0 = math.atan2(sy - cy, sx - cx); a1 = a0 + math.radians(90)
        mx, my = cx + rcorner * math.cos((a0 + a1) / 2), cy + rcorner * math.sin((a0 + a1) / 2)
        pcb.append(['gr_arc', ['start', fmt(sx), fmt(sy)], ['mid', fmt(mx), fmt(my)], ['end', fmt(ex), fmt(ey)],
                    ['stroke', ['width', '0.1'], ['type', 'default']], ['layer', q('Edge.Cuts')], ['uuid', q(nuid())]])
    # silkscreen: board name, IMU axes, servo labels, polarity
    # board identity on the back face (mirrored), out of the way of every top-side label
    t = gr_text('AFS_Adom rev A 2026-10-15  wiki.adom.inc/ae007/afs-adom', 146.0, 150.0, 'B.SilkS', 1.0, 0.15)
    t[-1].append(['justify', 'mirror'])
    pcb.append(t)
    # IMU axis marks (ICM-45686 U8: +X toward the board's right edge, +Y toward the top edge, +Z out of the board)
    ox, oy = 160.8, 122.0
    pcb.append(gr_line((ox, oy), (ox + 3.5, oy), 'F.SilkS', 0.15)); pcb.append(gr_line((ox + 3.5, oy), (ox + 2.9, oy - 0.4), 'F.SilkS', 0.15))
    pcb.append(gr_text('X', ox + 4.3, oy, 'F.SilkS', 0.8, 0.15))
    pcb.append(gr_line((ox, oy), (ox, oy - 3.0), 'F.SilkS', 0.15)); pcb.append(gr_line((ox, oy - 3.0), (ox - 0.4, oy - 2.4), 'F.SilkS', 0.15))
    pcb.append(gr_text('Y', ox, oy - 3.8, 'F.SilkS', 0.8, 0.15))
    pcb.append(gr_text('IMU Z out', ox + 4.5, oy - 3.8, 'F.SilkS', 0.8, 0.15))
    # connector, contact, button and hole labels are placed in the silkscreen step (adom-aiflow silkscreen-layout)
    # planes
    board_pts = [(BX0, BY0), (BX1, BY0), (BX1, BY1), (BX0, BY1)]
    if plane_zones:
        pcb.append(zone('GND', 'In1.Cu', board_pts, 0, 0.3, 0.25, name='GND plane'))
        pcb.append(zone('+3V3', 'In2.Cu', board_pts, 0, 0.3, 0.25, name='3V3 plane'))
        vbat_pts = [(104.0, 103.5), (188.0, 103.5), (188.0, 110.0), (126.2, 110.0), (126.2, 150.6), (122.0, 150.6), (122.0, 109.3), (104.0, 109.3)]
        pcb.append(zone('VBAT', 'In2.Cu', vbat_pts, 2, 0.4, 0.5, thermal=False, name='VBAT island'))
        # keep copper off In1/In2 under the RP2350 VREG_LX node and the 3.3 uH inductor (RP2350 DS 6.3.8.1)
        lx, ly, _ = PL['L3']
        pcb.append(keepout(['In1.Cu', 'In2.Cu'], [(lx - 2.6, ly - 1.6), (lx + 0.2, ly - 1.6), (lx + 0.2, ly + 1.6), (lx - 2.6, ly + 1.6)], 'LX cutout'))
    for z in (zones_extra or []):
        pcb.append(z)
    for t in (tracks or []):
        pcb.append(t)
    for v in (vias or []):
        pcb.append(v)
    pcb.append(['embedded_fonts', 'no'])
    open(out, 'w', encoding='utf-8').write(dump(pcb) + '\n')
    json.dump({'placement': PL, 'nets': NET}, open(OUT + '/placement.json', 'w'), indent=1)
    return out


def nl_c(desc):
    return 0


if __name__ == '__main__':
    p = build()
    print('wrote', p, len(COMPS), 'footprints', len(netnames), 'nets')