main
John Lauer Add adom-hero 1.4 hero GLB and Altium libraries; refresh records 0e4f5a0 2d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
#!/usr/bin/env python3
"""Build a component page's annotated hero GLB: the physical chip plus Adom's nonphysical reference artwork.

The convention (unified 2026-09-30 from the 45 hand-built "style A" heroes on the wiki, the wiki-component
skill's table and John's request) is fixed here so nobody's AI has to reconstruct it from prose:

  root 1   the physical model, untouched (our etched GLB: manufacturer/KiCad body + Adom.LaserEtch.MPN)
  root 2   "Adom reference artwork (nonphysical)", three flat meshes at z = -0.02 mm (below the seating
           plane, so they never z-fight the chip underside), all KHR_materials_unlit, BLEND, double-sided:
    "Footprint pads - teal dashed outlines - 50 percent opacity"  copper perimeter of every pad, dashed
                                                                  rgb(0, .902, .863)  alpha 0.50
    "Silkscreen reference lines - 30 percent opacity"             F.SilkS strokes/fills + a pin-1 dot
                                                                  rgb(.92, .96, 1)    alpha 0.30
    "Signal names - 50 percent opacity"                           pad signal names outside the pads
                                                                  rgb(.92, .96, 1)    alpha 0.50
  asset.extras.adomHero = {spec, mpn, board_use: false, layers, sources...}

The hero is presentation only (board_use false). Board-use downloads stay clean: the page declares the hero as
component.parts.model_3d and the clean model as model_3d_plain.

Coordinates: glTF x = KiCad x, y = -KiCad y, Z up, metres (KiCad footprint origin = model origin; checked
per part against the chip's lowest geometry, see align_check).

Usage: hero_glb.py <etched.glb> <footprint.kicad_mod> <page.json> <out-hero.glb> [--mpn MPN] [--png preview.png]
"""
import argparse, hashlib, json, math, os, re, struct, sys
import numpy as np
from pygltflib import GLTF2, Accessor, BufferView, Material, Mesh, Node, Primitive, Attributes, PbrMetallicRoughness

SPEC = 'adom-hero 1.4'
Z = -0.02                                   # mm, artwork plane
PAD_MAT = ('Footprint pads - teal dashed outlines - 50 percent opacity', [0.0, 0.902, 0.863, 0.5])
SILK_MAT = ('Silkscreen reference lines - 30 percent opacity', [0.92, 0.96, 1.0, 0.3])
NAME_MAT = ('Signal names - 50 percent opacity', [0.92, 0.96, 1.0, 0.5])
ROOT = 'Adom reference artwork (nonphysical)'
POLAR_PAD = re.compile(r'^(K|KA|CATHODE|\+|POS|PLUS)$', re.I)


# ---------------------------------------------------------------- KiCad footprint parsing (s-expressions)
def sexp(text):
    toks = re.findall(r'\(|\)|"(?:\\.|[^"\\])*"|[^\s()]+', text)
    stack = [[]]
    for t in toks:
        if t == '(': stack.append([])
        elif t == ')':
            x = stack.pop(); stack[-1].append(x)
        else: stack[-1].append(t[1:-1].replace('\\"', '"') if t.startswith('"') else t)
    return stack[0][0]


def child(node, key):
    return next((c for c in node if isinstance(c, list) and c and c[0] == key), None)


def children(node, key):
    return [c for c in node if isinstance(c, list) and c and c[0] == key]


def num(v): return float(v)


def xy(node): return (num(node[1]), num(node[2]))


def stroke_w(node, default=0.12):
    s = child(node, 'stroke')
    if s and child(s, 'width'): return num(child(s, 'width')[1])
    if child(node, 'width'): return num(child(node, 'width')[1])
    return default


def on_layer(node, layer):
    ly = child(node, 'layer') or child(node, 'layers')
    return bool(ly) and any(l == layer or (l.startswith('*.') and layer.endswith(l[1:])) for l in ly[1:])


def parse_footprint(path):
    fp = sexp(open(path).read())
    pads, silk = [], []
    for p in children(fp, 'pad'):
        number, kind, shape = p[1], p[2], p[3]
        if kind == 'np_thru_hole': continue
        ly = child(p, 'layers')
        if not ly or not any(l in ('F.Cu', '*.Cu') for l in ly[1:]): continue
        at = child(p, 'at'); size = child(p, 'size')
        rr = child(p, 'roundrect_rratio')
        prims = []
        if shape == 'custom' and child(p, 'primitives'):
            for gp in children(child(p, 'primitives'), 'gr_poly'):
                pts = child(gp, 'pts')
                if pts: prims.append([xy(q) for q in children(pts, 'xy')])
        pads.append(dict(number=number, kind=kind, shape=shape, x=num(at[1]), y=num(at[2]),
                         rot=num(at[3]) if len(at) > 3 else 0.0, w=num(size[1]), h=num(size[2]),
                         rratio=num(rr[1]) if rr else 0.25, prims=prims))
    for g in fp:
        if not (isinstance(g, list) and g and g[0] in ('fp_line', 'fp_arc', 'fp_circle', 'fp_rect', 'fp_poly')): continue
        if not on_layer(g, 'F.SilkS'): continue
        w = stroke_w(g); fill = child(g, 'fill'); filled = bool(fill) and fill[1] in ('solid', 'yes')
        if g[0] == 'fp_line': silk.append(('path', [xy(child(g, 'start')), xy(child(g, 'end'))], w))
        elif g[0] == 'fp_rect':
            (x1, y1), (x2, y2) = xy(child(g, 'start')), xy(child(g, 'end'))
            poly = [(x1, y1), (x2, y1), (x2, y2), (x1, y2), (x1, y1)]
            silk.append(('fill' if filled else 'path', poly, w))
        elif g[0] == 'fp_circle':
            (cx, cy), (ex, ey) = xy(child(g, 'center')), xy(child(g, 'end'))
            r = math.hypot(ex - cx, ey - cy)
            poly = [(cx + r * math.cos(t), cy + r * math.sin(t)) for t in np.linspace(0, 2 * math.pi, arc_n(r, 360) + 1)]
            silk.append(('fill' if filled else 'path', poly, w))
        elif g[0] == 'fp_arc':
            if child(g, 'mid'):
                a, m, b = xy(child(g, 'start')), xy(child(g, 'mid')), xy(child(g, 'end'))
                silk.append(('path', arc3(a, m, b), w))
            else:   # KiCad 5: (start centre) (end point) (angle deg)
                c, s = xy(child(g, 'start')), xy(child(g, 'end')); ang = num(child(g, 'angle')[1])
                r = math.hypot(s[0] - c[0], s[1] - c[1]); t0 = math.atan2(s[1] - c[1], s[0] - c[0])
                silk.append(('path', [(c[0] + r * math.cos(t0 + math.radians(ang) * k / 24), c[1] + r * math.sin(t0 + math.radians(ang) * k / 24)) for k in range(25)], w))
        elif g[0] == 'fp_poly':
            pts = [xy(q) for q in children(child(g, 'pts'), 'xy')]
            silk.append(('fill' if (filled or not fill) else 'path', pts + [pts[0]], w))
    model = child(fp, 'model')
    mdl = None
    if model:
        mdl = dict(offset=[num(v) for v in child(child(model, 'offset'), 'xyz')[1:]] if child(model, 'offset') else [0, 0, 0],
                   rotate=[num(v) for v in child(child(model, 'rotate'), 'xyz')[1:]] if child(model, 'rotate') else [0, 0, 0])
    return pads, silk, mdl


def arc3(a, m, b, n=None):
    ax, ay = a; bx, by = m; cx, cy = b
    d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
    if abs(d) < 1e-12: return [a, b]
    ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d
    uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d
    r = math.hypot(ax - ux, ay - uy)
    t0, tm, t1 = (math.atan2(p[1] - uy, p[0] - ux) for p in (a, m, b))
    def norm(t): return (t - t0) % (2 * math.pi)
    sweep = norm(t1) if norm(tm) < norm(t1) else norm(t1) - 2 * math.pi
    n = n or arc_n(r, math.degrees(sweep))
    return [(ux + r * math.cos(t0 + sweep * k / n), uy + r * math.sin(t0 + sweep * k / n)) for k in range(n + 1)]


# ---------------------------------------------------------------- geometry
def arc_n(r, sweep_deg, chord=0.005):
    """Segments for an arc so no straight piece is longer than `chord` mm (smooth at any zoom)."""
    return int(min(max(math.ceil(abs(math.radians(sweep_deg)) * r / chord), 8), 240))


def pad_outline(p):
    """Closed polyline of the copper perimeter in footprint coordinates."""
    w, h = p['w'], p['h']
    if p['shape'] == 'circle':
        n = arc_n(w / 2, 360); loc = [(w / 2 * math.cos(t), w / 2 * math.sin(t)) for t in np.linspace(0, 2 * math.pi, n + 1)]
    elif p['shape'] == 'oval':
        r = min(w, h) / 2; L = (max(w, h) - 2 * r) / 2; loc = []
        for cx, a0 in ((L, -90), (-L, 90)):
            n = arc_n(r, 180); loc += [(cx + r * math.cos(math.radians(a0 + k * 180 / n)), r * math.sin(math.radians(a0 + k * 180 / n))) for k in range(n + 1)]
        if h > w: loc = [(y, x) for x, y in loc]
        loc.append(loc[0])
    elif p['shape'] in ('roundrect', 'rect', 'trapezoid', 'custom'):
        r = p['rratio'] * min(w, h) if p['shape'] == 'roundrect' else 0.0
        if p['shape'] == 'custom' and p['prims']:
            loc = p['prims'][0] + [p['prims'][0][0]]
        elif r <= 1e-6:
            loc = [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2), (-w / 2, -h / 2)]
        else:
            loc = []
            for cx, cy, a0 in ((w / 2 - r, -h / 2 + r, -90), (w / 2 - r, h / 2 - r, 0), (-w / 2 + r, h / 2 - r, 90), (-w / 2 + r, -h / 2 + r, 180)):
                n = arc_n(r, 90); loc += [(cx + r * math.cos(math.radians(a0 + k * 90 / n)), cy + r * math.sin(math.radians(a0 + k * 90 / n))) for k in range(n + 1)]
            loc.append(loc[0])
    else:
        loc = [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2), (-w / 2, -h / 2)]
    # KiCad pad rotation is counter-clockwise on screen, where y points down: rotate by -angle in math axes
    t = math.radians(-p['rot']); c, s = math.cos(t), math.sin(t)
    return [(p['x'] + x * c - y * s, p['y'] + x * s + y * c) for x, y in loc]


def stroke_quads(poly, width):
    """One continuous ribbon along a polyline: offset both sides with mitred joins, so bends have no gaps
    and no overlapping triangles (overlaps double the alpha and show as bright seams)."""
    pts = [poly[0]] + [q for a, q in zip(poly, poly[1:]) if math.hypot(q[0] - a[0], q[1] - a[1]) > 1e-9]
    if len(pts) < 2: return []
    closed = len(pts) > 2 and math.hypot(pts[0][0] - pts[-1][0], pts[0][1] - pts[-1][1]) < 1e-9
    if closed: pts = pts[:-1]
    n = len(pts); hw = width / 2
    def seg_normal(i, j):
        dx, dy = pts[j][0] - pts[i][0], pts[j][1] - pts[i][1]; L = math.hypot(dx, dy)
        return (-dy / L, dx / L)
    left, right = [], []
    for i in range(n):
        prv = (i - 1) % n if (closed or i > 0) else None
        nxt = (i + 1) % n if (closed or i < n - 1) else None
        if prv is None: nx, ny = seg_normal(i, nxt)
        elif nxt is None: nx, ny = seg_normal(prv, i)
        else:
            a, b = seg_normal(prv, i), seg_normal(i, nxt)
            mx, my = a[0] + b[0], a[1] + b[1]; ml = math.hypot(mx, my)
            if ml < 1e-6: nx, ny = b
            else:
                mx, my = mx / ml, my / ml; cosh = max(mx * b[0] + my * b[1], 0.25)   # mitre limit 4x
                nx, ny = mx / cosh, my / cosh
        left.append((pts[i][0] + nx * hw, pts[i][1] + ny * hw)); right.append((pts[i][0] - nx * hw, pts[i][1] - ny * hw))
    idx = list(range(n)) + ([0] if closed else [])
    tris = []
    for i, j in zip(idx, idx[1:]):
        tris += [(left[i], left[j], right[j]), (left[i], right[j], right[i])]
    return tris


def dashes(poly, dash, gap):
    """Split a closed polyline into dash pieces (each a short polyline, corners kept)."""
    segs = list(zip(poly, poly[1:])); out = []; cur = []; on = True; left = dash
    for (x1, y1), (x2, y2) in segs:
        L = math.hypot(x2 - x1, y2 - y1); pos = 0.0
        if L < 1e-12: continue
        while pos < L - 1e-12:
            step = min(left, L - pos); t0, t1 = pos / L, (pos + step) / L
            p0 = (x1 + (x2 - x1) * t0, y1 + (y2 - y1) * t0); p1 = (x1 + (x2 - x1) * t1, y1 + (y2 - y1) * t1)
            if on:
                if not cur: cur = [p0]
                cur.append(p1)
            pos += step; left -= step
            if left <= 1e-12:
                if on and len(cur) > 1: out.append(cur)
                cur = []; on = not on; left = dash if on else gap
    if on and len(cur) > 1: out.append(cur)
    return out


def fill_tris(rings):
    """Triangulate a polygon (outer ring + holes) with earcut."""
    import mapbox_earcut as earcut
    verts = np.array([p for r in rings for p in r], dtype=np.float64).reshape(-1, 2)
    ends = np.cumsum([len(r) for r in rings]).astype(np.uint32)
    idx = earcut.triangulate_float64(verts, ends)
    return [tuple(map(tuple, verts[idx[k:k + 3]])) for k in range(0, len(idx), 3)]


# ---------------------------------------------------------------- text (DejaVu Sans Mono, pre-triangulated glyph table)
_GLYPHS = None


def glyphs():
    """DejaVu Sans Mono, pre-triangulated at font size 1 (hero_glyphs.json, shared with the Rust port), so text
    needs no font library at run time and both implementations draw identical glyphs."""
    global _GLYPHS
    if _GLYPHS is None:
        _GLYPHS = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'hero_glyphs.json')))
    return _GLYPHS


def text_tris(txt, height, x, y, align='left', vertical=False):
    """Filled text triangles, baseline-centred on (x, y) in footprint coordinates (y down). Overbars for ~{..}."""
    G = glyphs(); table = G['glyphs']
    plain, bars = '', []
    for m in re.finditer(r'~\{([^}]*)\}|([^~]+|~)', txt):
        if m.group(1) is not None:
            bars.append((len(plain), len(plain) + len(m.group(1)))); plain += m.group(1)
        else: plain += m.group(2)
    if not plain.strip(): return []
    adv = G['advance'] * height; W = adv * len(plain)
    tris = []
    for i, ch in enumerate(plain):
        gl = table.get(ch) or table['?']
        v = gl['v']; t = gl['t']
        for k in range(0, len(t), 3):
            tris.append(tuple((i * adv + v[2 * j] * height, v[2 * j + 1] * height) for j in t[k:k + 3]))
    for a, b in bars:   # overbar just above the cap height
        y0, y1 = 0.80 * height, 0.87 * height
        tris += [((a * adv, y0), (b * adv, y0), (b * adv, y1)), ((a * adv, y0), (b * adv, y1), (a * adv, y1))]
    # local text coords: x right, y UP, baseline at 0; centre vertically on cap height, align horizontally
    ox = {'left': 0.0, 'right': -W, 'center': -W / 2}[align]; oy = -0.36 * height
    out = []
    for t in tris:
        q = []
        for tx, ty in t:
            lx, ly = tx + ox, ty + oy
            if vertical: lx, ly = -ly, lx          # rotate 90 degrees CCW (reads bottom-to-top)
            q.append((x + lx, y - ly))             # footprint y is down
        out.append(tuple(q))
    return out


def mirror_label(tris):
    """Seen from below (orbiting the model over toward you, as the wiki viewer does), a label reads upside
    down: the view flips top-to-bottom. Mirror the underside copy across the label's own horizontal centre
    line, so it reads correctly from below and stays in place (Colby, 2026-09-30: "WODE", "ΛIN")."""
    if not tris: return []
    ys = [v[1] for t in tris for v in t]; cy = (min(ys) + max(ys)) / 2
    return [tuple((vx, 2 * cy - vy) for vx, vy in t) for t in tris]


# ---------------------------------------------------------------- layout decisions
def scale_params(pads):
    xs = [p['x'] for p in pads]; ys = [p['y'] for p in pads]
    span = max(max(xs) - min(xs) + max(p['w'] for p in pads), max(ys) - min(ys) + max(p['h'] for p in pads), 1.0)
    # 1.1 (2026-09-30): bolder. At 0.015 mm the outlines vanished at the wiki viewer's default zoom and the
    # 30% silkscreen read as "the footprint" (Colby). The footprint must read first.
    # 1.2: between the two -- 1.1 read as heavy to Colby ("I like that it's subtle"), 1.0 vanished (Ray chose the middle)
    dash = min(max(0.025 * span, 0.12), 0.45)
    return dict(span_mm=round(span, 3), dash_mm=round(dash, 4), gap_mm=round(0.6 * dash, 4),
                pad_stroke_mm=round(min(max(0.006 * span, 0.03), 0.09), 4))


def is_grid(pads):
    """BGA/LGA-style ball grid: many pads with many distinct rows AND columns."""
    xs = {round(p['x'], 2) for p in pads}; ys = {round(p['y'], 2) for p in pads}
    return len(pads) >= 16 and len(xs) >= 4 and len(ys) >= 4 and len(pads) > 0.6 * len(xs) * len(ys)


def label_box(lb):
    W = 0.602 * lb['h'] * len(re.sub(r'~\{([^}]*)\}', r'\1', lb['text'])); H = lb['h']
    x, y = lb['x'], lb['y']
    if not lb['vertical']:
        x0 = x - W if lb['align'] == 'right' else x
        return (x0, x0 + W, y - H / 2, y + H / 2)
    y0 = y if lb['align'] == 'right' else y - W        # vertical text runs up the screen (footprint -y)
    return (x - H / 2, x + H / 2, y0, y0 + W)


def overlaps(a, b): return a[0] < b[1] and b[0] < a[1] and a[2] < b[3] and b[2] < a[3]


def label_plan(pads, names, obstacles=()):
    """Per labelled pad: text, anchor, alignment, orientation, height. Interior pads get none."""
    sig = [p for p in pads if not (p['number'] in ('', '~') or thermal_like(p, pads))]
    if not sig or is_grid(sig): return [], ('ball grid: labels omitted (they would sit under the body)' if sig else 'no signal pads')
    xs = [p['x'] for p in sig]; ys = [p['y'] for p in sig]
    cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
    hx, hy = (max(xs) - min(xs)) / 2 or 1e-6, (max(ys) - min(ys)) / 2 or 1e-6
    plan, seen = [], set()
    for p in sig:
        if p['number'] in seen: continue        # multi-pad numbers (split EP, shield legs) labelled once
        seen.add(p['number'])
        # side = the extreme it sits closest to (normalised), so a 2-row part splits left/right or top/bottom
        dxn, dyn = (p['x'] - cx) / hx, (p['y'] - cy) / hy
        if len(sig) == 2 or abs(dxn) >= abs(dyn): side = 'R' if p['x'] >= cx else 'L'
        else: side = 'B' if p['y'] >= cy else 'T'
        same = [q for q in sig if q is not p and ((side in 'LR' and abs(q['x'] - p['x']) < 0.3) or (side in 'TB' and abs(q['y'] - p['y']) < 0.3))]
        pitch = min([abs((q['y'] - p['y']) if side in 'LR' else (q['x'] - p['x'])) for q in same] or [99])
        o = pad_outline(p); ex = [v[0] for v in o]; ey = [v[1] for v in o]
        short = min(p['w'], p['h'])
        h = min(max(min(0.5 * pitch, 0.7 * short if len(sig) == 2 else 0.5 * pitch), 0.12), 0.6)
        gap = max(0.25 * h, 0.1)
        text = names.get(p['number']) or p['number']
        if text in ('~', ''): text = p['number']
        if side == 'L': plan.append(dict(pad=p['number'], text=text, x=min(ex) - gap, y=p['y'], align='right', vertical=False, h=h))
        if side == 'R': plan.append(dict(pad=p['number'], text=text, x=max(ex) + gap, y=p['y'], align='left', vertical=False, h=h))
        if side == 'T': plan.append(dict(pad=p['number'], text=text, x=p['x'], y=min(ey) - gap, align='left', vertical=True, h=h))
        if side == 'B': plan.append(dict(pad=p['number'], text=text, x=p['x'], y=max(ey) + gap, align='right', vertical=True, h=h))
        lb = plan[-1]
        for _ in range(4):     # step outward past a silkscreen shape (the footprint's pin-1 triangle, our dot)
            hit = next((ob for ob in obstacles if overlaps(label_box(lb), ob)), None)
            if not hit: break
            if side == 'L': lb['x'] = hit[0] - gap
            if side == 'R': lb['x'] = hit[1] + gap
            if side == 'T': lb['y'] = hit[2] - gap
            if side == 'B': lb['y'] = hit[3] + gap
    return plan, None


def thermal_like(p, pads):
    """Exposed/thermal pad: named EP/PAD, or a pad much larger than the median sitting inside the others."""
    if re.match(r'^(EP|PAD|TAB|0)$', p['number'], re.I): return True
    areas = sorted(q['w'] * q['h'] for q in pads)
    med = areas[len(areas) // 2]
    if len(pads) >= 5 and p['w'] * p['h'] > 4 * med:
        xs = [q['x'] for q in pads]; ys = [q['y'] for q in pads]
        return min(xs) < p['x'] < max(xs) and min(ys) < p['y'] < max(ys)
    return False


def silk_marks_pin1(silk, p1, pads):
    """The footprint already carries a pin-1 mark: a filled silk shape (KiCad's triangle / dot) whose centroid
    is nearer pad 1 than any other pad and within 2.5 mm of it."""
    for kind, pts, _ in silk:
        if kind != 'fill' or len(pts) < 3: continue
        cx = np.mean([q[0] for q in pts]); cy = np.mean([q[1] for q in pts])
        dist = lambda p: math.hypot(p['x'] - cx, p['y'] - cy)
        if dist(p1) <= 2.5 and dist(p1) <= min(dist(q) for q in pads) + 1e-6: return True
    return False


def pin1_dot(pads, names, silk=()):
    """(x, y, r) of the pin-1 dot, outside pad 1, or None when the part has no orientation to show or the
    footprint's own silkscreen already marks pin 1."""
    sig = [p for p in pads if not thermal_like(p, pads)]
    p1 = None
    if len(sig) >= 3:
        p1 = next((p for p in sig if p['number'] in ('1', 'A1')), None)
    elif len(sig) == 2:   # a 2-pin part shows polarity only when a pin says so (cathode / +)
        p1 = next((p for p in sig if POLAR_PAD.match((names.get(p['number']) or '').strip())), None)
    if not p1: return None
    if silk_marks_pin1(silk, p1, sig): return ('existing', p1['number'])
    xs = [p['x'] for p in sig]; ys = [p['y'] for p in sig]
    cx, cy = (min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2
    dx, dy = p1['x'] - cx, p1['y'] - cy
    if len(sig) >= 3 and not is_grid(sig):
        # outward along the pad's own row axis: beyond pad 1's END of the row (the classic dot position)
        same_col = [q for q in sig if abs(q['x'] - p1['x']) < 0.3]
        if len(same_col) > 1: dx, dy = 0.0, (p1['y'] - np.mean([q['y'] for q in same_col])) or -1.0
        else: dx, dy = (p1['x'] - np.mean([q['x'] for q in sig if abs(q['y'] - p1['y']) < 0.3])) or -1.0, 0.0
    L = math.hypot(dx, dy) or 1.0; ux, uy = dx / L, dy / L
    r = min(max(0.22 * min(p1['w'], p1['h']), 0.1), 0.35)
    o = pad_outline(p1); reach = max((v[0] - p1['x']) * ux + (v[1] - p1['y']) * uy for v in o)
    d = reach + 0.2 + r
    return (p1['x'] + ux * d, p1['y'] + uy * d, r, p1['number'])


# ---------------------------------------------------------------- GLB assembly
def tris_to_arrays(tris):
    """Indexed mesh: shared vertices stored once (the ribbons and glyphs share most corners), ~3x smaller."""
    P = np.array([[x / 1000.0, -y / 1000.0, Z / 1000.0] for t in tris for (x, y) in t], dtype=np.float32)
    key = np.round(P.astype(np.float64) * 1e7).astype(np.int64)
    _, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
    return P[first], inv.reshape(-1).astype(np.uint32)


def facing(tris, up):
    """Force each triangle's winding in glTF space (x, -y): counter-clockwise from above for up=True, from below
    for up=False. A single-sided material then shows the top copy only from above and the bottom copy only
    from below."""
    out = []
    for t in tris:
        (x0, y0), (x1, y1), (x2, y2) = t
        area = (x1 - x0) * (-(y2 - y0)) - (x2 - x0) * (-(y1 - y0))     # signed area with glTF y = -footprint y
        out.append(t if (area > 0) == up else (t[0], t[2], t[1]))
    return out


def add_mesh(g, blob, name, prims):
    """One node + mesh named `name`; prims = [(material_index, tris), ...], one primitive each."""
    gl_prims = []
    for mat_idx, tris in prims:
        if not tris: continue
        P, I = tris_to_arrays(tris)
        pb, ib = P.tobytes(), I.tobytes()
        off = len(blob); blob += pb + b'\x00' * ((4 - len(pb) % 4) % 4)
        g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(pb), target=34962))
        g.accessors.append(Accessor(bufferView=len(g.bufferViews) - 1, componentType=5126, count=len(P), type='VEC3',
                                    min=P.min(0).tolist(), max=P.max(0).tolist()))
        pa = len(g.accessors) - 1
        off = len(blob); blob += ib
        g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(ib), target=34963))
        g.accessors.append(Accessor(bufferView=len(g.bufferViews) - 1, componentType=5125, count=len(I), type='SCALAR'))
        gl_prims.append(Primitive(attributes=Attributes(POSITION=pa), indices=len(g.accessors) - 1, material=mat_idx))
    if not gl_prims: return None, blob
    g.meshes.append(Mesh(name=name, primitives=gl_prims))
    g.nodes.append(Node(name=name, mesh=len(g.meshes) - 1))
    return len(g.nodes) - 1, blob


def world_points(g):
    """All physical vertices in world metres (for the alignment check)."""
    blob = g.binary_blob()
    CT = {5126: ('f', 4), 5123: ('H', 2), 5125: ('I', 4), 5121: ('B', 1), 5122: ('h', 2), 5120: ('b', 1)}
    NC = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}
    def acc(i):
        a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; t, sz = CT[a.componentType]; nc = NC[a.type]
        st = bv.byteStride or sz * nc; o = (bv.byteOffset or 0) + (a.byteOffset or 0)
        v = np.array([struct.unpack_from('<' + t * nc, blob, o + k * st) for k in range(a.count)], dtype=float)
        if a.normalized: v = v / {5122: 32767, 5120: 127, 5123: 65535, 5121: 255}[a.componentType]
        return v
    def mat(nd):
        if nd.matrix: return np.array(nd.matrix).reshape(4, 4).T
        T = np.eye(4); R = np.eye(4); S = np.eye(4)
        if nd.translation: T[:3, 3] = nd.translation
        if nd.rotation:
            x, y, z, w = nd.rotation
            R[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
        if nd.scale: S[:3, :3] = np.diag(nd.scale)
        return T @ R @ S
    out = []
    def walk(i, M):
        nd = g.nodes[i]; M2 = M @ mat(nd)
        if nd.mesh is not None:
            for p in g.meshes[nd.mesh].primitives:
                P = acc(p.attributes.POSITION); out.append((M2 @ np.c_[P, np.ones(len(P))].T).T[:, :3])
        for c in nd.children or []: walk(c, M2)
    for r in g.scenes[g.scene or 0].nodes: walk(r, np.eye(4))
    return np.vstack(out) if out else np.zeros((0, 3))


def align_check(g, pads):
    """The chip's lowest geometry (its contacts) must sit over the pads: compare extents in both axes."""
    W = world_points(g) * 1000.0
    if not len(W): return dict(ok=False, why='no physical geometry')
    zmin = W[:, 2].min(); low = W[W[:, 2] < zmin + max(0.08, 0.05 * (W[:, 2].max() - zmin))]
    px = [v[0] for p in pads for v in pad_outline(p)]; py = [-v[1] for p in pads for v in pad_outline(p)]
    pad_box = (min(px), max(px), min(py), max(py)); c_box = (low[:, 0].min(), low[:, 0].max(), low[:, 1].min(), low[:, 1].max())
    # contacts centred on the pad field and not wider than it (+ tolerance); a rotated model fails both
    cx_p, cy_p = (pad_box[0] + pad_box[1]) / 2, (pad_box[2] + pad_box[3]) / 2
    cx_c, cy_c = (c_box[0] + c_box[1]) / 2, (c_box[2] + c_box[3]) / 2
    tol = max(0.25, 0.08 * max(pad_box[1] - pad_box[0], pad_box[3] - pad_box[2]))
    # Orientation: a model turned 90 degrees swaps the long axis of its contacts against the pad field. A
    # leadless body (QFN/WSON) rests its whole underside on the seating plane, so width alone proves nothing.
    pw, ph = pad_box[1] - pad_box[0], pad_box[3] - pad_box[2]; cw, ch = c_box[1] - c_box[0], c_box[3] - c_box[2]
    flipped = (pw > 1.25 * ph and ch > 1.25 * cw) or (ph > 1.25 * pw and cw > 1.25 * ch)
    off = math.hypot(cx_c - cx_p, cy_c - cy_p)
    return dict(ok=bool(off <= tol and not flipped), centre_offset_mm=round(off, 3), tolerance_mm=round(tol, 3), orientation_flipped=bool(flipped),
                contacts_bbox_mm=[round(v, 3) for v in c_box], pads_bbox_mm=[round(v, 3) for v in pad_box], seating_z_mm=round(float(zmin), 3))


def sha(path): return hashlib.sha256(open(path, 'rb').read()).hexdigest()


def build(src_glb, kmod, page_json, out, mpn=None, png=None):
    pads, silk, mdl = parse_footprint(kmod)
    if not pads: raise SystemExit(f'{kmod}: no copper pads')
    pj = json.load(open(page_json)); comp = pj.get('component') or {}
    names = {str(p.get('number')): (p.get('name') or '').strip() for p in comp.get('pins') or []}
    mpn = mpn or comp.get('mpn') or os.path.basename(out).replace('-hero.glb', '').upper()
    prm = scale_params(pads)
    g = GLTF2().load(src_glb)
    chk = align_check(g, pads)
    if mdl and (any(abs(v) > 1e-6 for v in mdl['rotate']) or any(abs(v) > 1e-6 for v in mdl['offset'])):
        chk['model_transform_in_footprint'] = mdl
    # --- artwork
    pad_tris = []
    for p in pads:
        o = pad_outline(p); per = sum(math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(o, o[1:]))
        dsh = min(prm['dash_mm'], max(per / 6, 0.05)); w = min(prm['pad_stroke_mm'], max(per / 25, 0.02))
        for d in dashes(o, dsh, 0.6 * dsh): pad_tris += stroke_quads(d, w)
    silk_tris = []
    for kind, pts, w in silk:
        if 'REF' in str(pts): continue
        silk_tris += fill_tris([pts[:-1] if pts[0] == pts[-1] else pts]) if kind == 'fill' and len(pts) >= 4 else stroke_quads(pts, w)
    dot = pin1_dot(pads, names, silk)
    existing_mark = bool(dot) and dot[0] == 'existing'
    if dot and not existing_mark:
        x, y, r, _ = dot
        ring = [(x + r * math.cos(t), y + r * math.sin(t)) for t in np.linspace(0, 2 * math.pi, 33)[:-1]]
        silk_tris += fill_tris([ring])
    obstacles = [(min(q[0] for q in pts), max(q[0] for q in pts), min(q[1] for q in pts), max(q[1] for q in pts)) for kind, pts, _ in silk if kind == 'fill']
    if dot and not existing_mark: obstacles.append((dot[0] - dot[2], dot[0] + dot[2], dot[1] - dot[2], dot[1] + dot[2]))
    plan, why_no_labels = label_plan(pads, names, obstacles)
    name_up, name_down = [], []
    for lb in plan:
        t = text_tris(lb['text'], lb['h'], lb['x'], lb['y'], lb['align'], lb['vertical'])
        name_up += t; name_down += mirror_label(t)
    name_up, name_down = facing(name_up, True), facing(name_down, False)
    # --- materials, meshes, root node
    blob = bytearray(g.binary_blob() or b'')
    blob += b'\x00' * ((4 - len(blob) % 4) % 4)
    mats = []
    for nm, col in (PAD_MAT, SILK_MAT, NAME_MAT):
        # Names are single-sided: an upward copy and a mirrored downward copy, so they read from either side.
        g.materials.append(Material(name=nm, pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=col, metallicFactor=0.0, roughnessFactor=1.0),
                                    alphaMode='BLEND', doubleSided=(nm != NAME_MAT[0]), extensions={'KHR_materials_unlit': {}}))
        mats.append(len(g.materials) - 1)
    kids = []
    for (nm, _), prims in zip((PAD_MAT, SILK_MAT, NAME_MAT),
                              ([(mats[0], pad_tris)], [(mats[1], silk_tris)], [(mats[2], name_up), (mats[2], name_down)])):
        ni, blob = add_mesh(g, blob, nm, prims)
        if ni is not None: kids.append(ni)
    g.nodes.append(Node(name=ROOT, children=kids)); g.scenes[g.scene or 0].nodes.append(len(g.nodes) - 1)
    g.buffers[0].byteLength = len(blob); g.set_binary_blob(bytes(blob))
    if 'KHR_materials_unlit' not in (g.extensionsUsed or []): g.extensionsUsed = (g.extensionsUsed or []) + ['KHR_materials_unlit']
    layers = dict(pad_outlines=dict(material=PAD_MAT[0], rgba=PAD_MAT[1], pads=len(pads), **prm),
                  silkscreen=dict(material=SILK_MAT[0], rgba=SILK_MAT[1], footprint_items=len(silk),
                                  pin1=(dict(source='footprint silkscreen', pad=dot[1]) if existing_mark else
                                        dict(source='added dot (AI)', pad=dot[3], center_mm=[round(dot[0], 3), round(dot[1], 3)], r_mm=round(dot[2], 3)) if dot else None)),
                  signal_names=dict(material=NAME_MAT[0], rgba=NAME_MAT[1], font='DejaVu Sans Mono', labels=len(plan), sides='top + mirrored underside (single-sided)',
                                    omitted=why_no_labels, fallback_to_pad_number=sum(1 for lb in plan if lb['text'] == lb['pad'])))
    hero = dict(spec=SPEC, mpn=mpn, kind='component + footprint reference art', board_use=False, plane_z_mm=Z, units='metre', upAxis='Z',
                layers=layers, align=chk, sources=dict(glb=os.path.basename(src_glb), glb_sha256=sha(src_glb),
                footprint=os.path.basename(kmod), footprint_sha256=sha(kmod), pins='page.json component.pins'), generator='factory/hero_glb.py')
    ex = g.asset.extras if isinstance(g.asset.extras, dict) else {}
    ex['adomHero'] = hero; g.asset.extras = ex
    g.save_binary(out)
    side = dict(hero, labels=[{k: (round(v, 3) if isinstance(v, float) else v) for k, v in lb.items()} for lb in plan],
                pads=[dict(number=p['number'], shape=p['shape'], center_mm=[p['x'], p['y']], size_mm=[p['w'], p['h']], rot=p['rot']) for p in pads])
    json.dump(side, open(out.replace('.glb', '.overlay.json'), 'w'), indent=1)
    if png: preview(out, png, pads, plan, dot)
    return hero


# ---------------------------------------------------------------- quick preview (top + oblique) for review
def preview(glb, png, pads, plan, dot):
    import matplotlib; matplotlib.use('Agg')
    import matplotlib.pyplot as plt
    from matplotlib.collections import PolyCollection
    from mpl_toolkits.mplot3d.art3d import Poly3DCollection
    g = GLTF2().load(glb)
    blob = g.binary_blob()
    CT = {5126: ('f', 4), 5123: ('H', 2), 5125: ('I', 4), 5121: ('B', 1), 5122: ('h', 2), 5120: ('b', 1)}
    NC = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4}
    def acc(i):
        a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; t, sz = CT[a.componentType]; nc = NC[a.type]
        st = bv.byteStride or sz * nc; o = (bv.byteOffset or 0) + (a.byteOffset or 0)
        v = np.array([struct.unpack_from('<' + t * nc, blob, o + k * st) for k in range(a.count)], dtype=float)
        if a.normalized: v = v / {5122: 32767, 5120: 127, 5123: 65535, 5121: 255}[a.componentType]
        return v
    def mat(nd):
        if nd.matrix: return np.array(nd.matrix).reshape(4, 4).T
        T = np.eye(4); R = np.eye(4); S = np.eye(4)
        if nd.translation: T[:3, 3] = nd.translation
        if nd.rotation:
            x, y, z, w = nd.rotation
            R[:3, :3] = [[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)]]
        if nd.scale: S[:3, :3] = np.diag(nd.scale)
        return T @ R @ S
    tris = []   # (3x3 mm, rgba)
    def walk(i, M):
        nd = g.nodes[i]; M2 = M @ mat(nd)
        if nd.mesh is not None:
            for p in g.meshes[nd.mesh].primitives:
                P = acc(p.attributes.POSITION) ; P = (M2 @ np.c_[P, np.ones(len(P))].T).T[:, :3] * 1000
                I = acc(p.indices).astype(int).reshape(-1, 3) if p.indices is not None else np.arange(len(P)).reshape(-1, 3)
                m = g.materials[p.material] if p.material is not None else None
                col = list(m.pbrMetallicRoughness.baseColorFactor) if m and m.pbrMetallicRoughness and m.pbrMetallicRoughness.baseColorFactor else [.6, .6, .6, 1]
                for t in I: tris.append((P[t], col))
        for c in nd.children or []: walk(c, M2)
    for r in g.scenes[g.scene or 0].nodes: walk(r, np.eye(4))
    fig = plt.figure(figsize=(14, 7), facecolor='#1b1e24')
    ax = fig.add_subplot(1, 2, 1); ax.set_facecolor('#2a2e36'); ax.set_aspect('equal')
    phys = [(t, c) for t, c in tris if c[3] >= 0.99]; art = [(t, c) for t, c in tris if c[3] < 0.99]
    # top view: physical first (light shading by height), artwork drawn on top so the review sees both
    zs = np.array([t[:, 2].mean() for t, _ in phys]) if phys else np.array([0])
    order = np.argsort(zs)
    ax.add_collection(PolyCollection([phys[i][0][:, :2] for i in order], facecolors=[tuple(np.array(phys[i][1][:3]) * 0.7) + (0.55,) for i in order], edgecolors='none'))
    ax.add_collection(PolyCollection([t[:, :2] for t, _ in art], facecolors=[tuple(c[:3]) + (min(1, c[3] + 0.35),) for _, c in art], edgecolors='none'))
    allp = np.vstack([t for t, _ in tris]); pad = 0.6
    ax.set_xlim(allp[:, 0].min() - pad, allp[:, 0].max() + pad); ax.set_ylim(allp[:, 1].min() - pad, allp[:, 1].max() + pad)
    ax.set_title('top (artwork over a translucent body)', color='w', fontsize=10); ax.tick_params(colors='#888')
    ax3 = fig.add_subplot(1, 2, 2, projection='3d'); ax3.set_facecolor('#2a2e36')
    ax3.add_collection3d(Poly3DCollection([t for t, _ in tris], facecolors=[tuple(c[:3]) + (c[3],) for _, c in tris], edgecolors='none'))
    rng = allp.max(0) - allp.min(0); mid = (allp.max(0) + allp.min(0)) / 2; R = rng.max() / 2
    ax3.set_xlim(mid[0] - R, mid[0] + R); ax3.set_ylim(mid[1] - R, mid[1] + R); ax3.set_zlim(mid[2] - R * 0.6, mid[2] + R * 0.6)
    ax3.view_init(elev=38, azim=-60); ax3.set_axis_off(); ax3.set_title('oblique', color='w', fontsize=10)
    fig.suptitle(os.path.basename(glb), color='w'); fig.tight_layout(); fig.savefig(png, dpi=110); plt.close(fig)


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('glb'); ap.add_argument('kicad_mod'); ap.add_argument('page_json'); ap.add_argument('out')
    ap.add_argument('--mpn'); ap.add_argument('--png')
    a = ap.parse_args()
    h = build(a.glb, a.kicad_mod, a.page_json, a.out, a.mpn, a.png)
    L = h['layers']
    print(f"OK {a.out}: pads {L['pad_outlines']['pads']}, silk items {L['silkscreen']['footprint_items']}, "
          f"pin1 {(L['silkscreen']['pin1'] or {}).get('source', 'none')}, labels {L['signal_names']['labels']}"
          f"{' (' + L['signal_names']['omitted'] + ')' if L['signal_names']['omitted'] else ''}; "
          f"align {'OK' if h['align']['ok'] else 'FAIL'} (offset {h['align'].get('centre_offset_mm')} mm)")