main
John Lauer Publish 0.2.19 81ffca7
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121
#!/usr/bin/env python3
"""Real copper on a board GLB, driven by the footprints' own pad, mask and paste layers.

The rule (John, 2026-10-02): pads and test points are always real copper. Footprints carry a
copper pad, a solder-mask aperture and (for assembled pads) a paste aperture; a board render
must follow those layers, never ignore them.

  copper: read a board GLB (KiCad STEP -> step2glb, plain or --molecule) and the .kicad_pcb it
          came from, then
          - give the pad, via and track meshes (KiCad STEP products <board>_pad, _via, _copper)
            a real copper material (or the board finish: enig, hasl), with explicit metallic and
            roughness. A bare baseColor with glTF's default metallic 1 / roughness 1 is what
            made the pads read grey or pale. Meshes are found by their KiCad layer name, never
            by a material index;
          - rebuild the solder mask (top and bottom) from the board outline minus every F.Mask /
            B.Mask aperture: pad shapes grown by their mask margin (pad, footprint or board
            setting), mask graphics, mask zones, untented vias, drill holes, KiCad's
            solder_mask_min_width web rule. Never a blanket sheet over the pads;
          - add solder only where F.Paste / B.Paste apertures exist on fitted parts (paste margin
            and ratio applied). Pads without paste (test points, contacts, pins) stay bare;
          - if the STEP was exported without pads, generate the pad copper from the F.Cu / B.Cu
            pad shapes.
  check:  the gate. Pad materials must be in the finish's colour range with explicit metal and
          roughness; at every exposed pad centre the mask must be open and, on pads without
          paste, no solder may cover the copper. Exit 1 with hints on failure.
          A board file with no pads and no F.Mask / B.Mask apertures has no exposed copper: the GLB
          is still written (substrate and silkscreen opaque, mask whole) and the gate passes with
          the note "no exposed copper on this board".

Usage:
  board-glb-copper.py copper --glb in.glb --board board.kicad_pcb --out out.glb
                       [--finish copper|enig|hasl] [--paste solder|none] [--report r.json]
  board-glb-copper.py check  --glb out.glb --board board.kicad_pcb [--finish ...] [--report r.json]
"""
import argparse, copy, json, math, re, struct, sys
from pathlib import Path

import numpy as np
import shapely
from shapely.geometry import Polygon, Point, LineString, box, MultiPolygon
from shapely.affinity import rotate, translate
from shapely.ops import unary_union, polygonize

VERSION = 1
# GLB pad span vs the board file's pads: a mesh reaching past the board's pads means another board; one falling
# short is allowed a little more, since a coarse tessellation cuts round pads with chords (0.3 mm seen on a 7 mm pad).
PAD_SPAN_OUT_MM = 0.15
PAD_SPAN_IN_MM = 0.5
MASK_T_MM = 0.01          # KiCad's flat soldermask faces sit 0.01 mm off the pad copper
SOLDER_ABOVE_PAD_MM = 0.02

FINISHES = {
    # linear baseColorFactor, metallic, roughness; copper matches the look John approved on the buck board
    'copper': ([0.72, 0.30, 0.10], 0.55, 0.42),
    'enig':   ([0.85, 0.64, 0.27], 0.90, 0.30),
    'hasl':   ([0.70, 0.71, 0.73], 0.85, 0.40),
}
SOLDER = ([0.80, 0.81, 0.83], 1.0, 0.22)   # reflowed tin: bright and shiny
FINISH_ALIASES = {'osp': 'copper', 'bare': 'copper', 'bare-copper': 'copper', 'gold': 'enig',
                  'enepig': 'enig', 'hasl-lf': 'hasl', 'lead-free-hasl': 'hasl', 'immersion-tin': 'hasl'}


def finish_name(s):
    s = (s or 'copper').lower()
    s = FINISH_ALIASES.get(s, s)
    if s not in FINISHES:
        raise SystemExit(f'unknown finish {s!r}: use copper, enig or hasl')
    return s


def in_finish_range(rgb, finish):
    r, g, b = rgb[:3]
    if finish == 'copper':
        return r >= 0.45 and 0.2 <= g / r <= 0.72 and b / r <= 0.45
    if finish == 'enig':
        return r >= 0.6 and 0.6 <= g / r <= 0.88 and b / r <= 0.55
    return min(r, g, b) >= 0.55 and max(r, g, b) - min(r, g, b) <= 0.08  # hasl: bright neutral metal


# ---------------------------------------------------------------- KiCad s-expressions
def parse(text):
    ts = iter(re.findall(r'"(?:\\.|[^"\\])*"|[^\s()]+|[()]', text))
    def rec():
        a = []
        for t in ts:
            if t == ')':
                return a
            a.append(rec() if t == '(' else json.loads(t) if t.startswith('"') else t)
        return a
    return rec()[0]


def sub(n, k):
    return [x for x in n if isinstance(x, list) and x and x[0] == k]


def one(n, k):
    return next(iter(sub(n, k)), [k])[1:]


def num(n, k, default=None):
    v = one(n, k)
    try:
        return float(v[0]) if v else default
    except (TypeError, ValueError):
        return default


def xy(n, k):
    return tuple(map(float, one(n, k)[:2]))


def layer_of(n):
    v = one(n, 'layer')
    return v[0] if v else None


def filled(n):
    f = one(n, 'fill')
    return bool(f) and f[0] in ('yes', 'solid', 'true')


def arc_points(s, m, e, step_deg=5.0):
    (ax, ay), (bx, by), (cx, cy) = s, m, e
    d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by))
    if abs(d) < 1e-12:
        return [s, e]
    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)
    a0, am, a1 = (math.atan2(p[1] - uy, p[0] - ux) for p in (s, m, e))
    def norm(a):
        return a % (2 * math.pi)
    sweep = norm(a1 - a0)
    if norm(am - a0) > sweep:          # the mid point is on the other way round
        sweep -= 2 * math.pi
    n = max(4, int(math.ceil(abs(math.degrees(sweep)) / step_deg)))
    pts = [s] + [(ux + r * math.cos(a0 + sweep * i / n), uy + r * math.sin(a0 + sweep * i / n)) for i in range(1, n)] + [e]
    return pts


def graphic(n, closed_only=False):
    """A gr_*/fp_* item as (geometry, is_area). Lines and arcs are strokes (buffered by width)."""
    kind = n[0].split('_', 1)[1]
    st = sub(n, 'stroke')
    width = (num(st[0], 'width', 0.0) if st else num(n, 'width', 0.0)) or 0.0
    if kind == 'line':
        g = LineString([xy(n, 'start'), xy(n, 'end')])
        return (g.buffer(width / 2) if width else g), False
    if kind == 'arc':
        if sub(n, 'mid'):
            g = LineString(arc_points(xy(n, 'start'), xy(n, 'mid'), xy(n, 'end')))
        else:   # legacy (start centre) (end start) (angle)
            c, s0 = xy(n, 'start'), xy(n, 'end'); ang = math.radians(num(n, 'angle', 0)); r = math.dist(c, s0)
            a0 = math.atan2(s0[1] - c[1], s0[0] - c[0]); k = max(4, int(abs(math.degrees(ang)) / 5))
            g = LineString([(c[0] + r * math.cos(a0 + ang * i / k), c[1] + r * math.sin(a0 + ang * i / k)) for i in range(k + 1)])
        return (g.buffer(width / 2) if width else g), False
    if kind == 'rect':
        a, b = xy(n, 'start'), xy(n, 'end')
        p = box(min(a[0], b[0]), min(a[1], b[1]), max(a[0], b[0]), max(a[1], b[1]))
    elif kind == 'circle':
        c = xy(n, 'center'); p = Point(c).buffer(math.dist(c, xy(n, 'end')), quad_segs=24)
    elif kind == 'poly':
        pts = [tuple(map(float, v[1:3])) for v in one(n, 'pts') if isinstance(v, list) and v[0] == 'xy']
        p = Polygon(pts).buffer(0)
    elif kind in ('text', 'text_box', 'curve', 'bbox'):
        return None, False
    else:
        raise ValueError(f'unsupported graphic {n[0]}')
    if filled(n):
        return (p.buffer(width / 2) if width else p), True
    return (p.exterior.buffer(width / 2) if width else p.exterior), p   # outline; p is the closed area


def place(g, at):
    """Footprint-local geometry to board coordinates (KiCad y down; angle counter-clockwise on screen)."""
    x, y = at[0], at[1]; a = at[2] if len(at) > 2 else 0.0
    return translate(rotate(g, -a, origin=(0, 0)), x, y)


# ---------------------------------------------------------------- the board's layers
def pad_shape(p):
    kind = p[3]
    w, h = map(float, one(p, 'size')[:2])
    rect = box(-w / 2, -h / 2, w / 2, h / 2)
    if kind == 'rect':
        g = rect
    elif kind == 'circle':
        g = Point(0, 0).buffer(w / 2, quad_segs=16)
    elif kind == 'oval':
        g = (LineString([(-(w - h) / 2, 0), ((w - h) / 2, 0)]).buffer(h / 2, quad_segs=16) if w >= h
             else LineString([(0, -(h - w) / 2), (0, (h - w) / 2)]).buffer(w / 2, quad_segs=16))
    elif kind == 'roundrect':
        r = min(w, h) * (num(p, 'roundrect_rratio', 0) or 0)
        g = box(-w / 2 + r, -h / 2 + r, w / 2 - r, h / 2 - r).buffer(r, quad_segs=8) if r > 0 else rect
    elif kind == 'trapezoid':
        d = one(p, 'rect_delta'); dx, dy = (float(d[0]), float(d[1])) if d else (0.0, 0.0)
        g = Polygon([(-w / 2 - dy / 2, h / 2 + dx / 2), (w / 2 + dy / 2, h / 2 - dx / 2),
                     (w / 2 - dy / 2, -h / 2 + dx / 2), (-w / 2 + dy / 2, -h / 2 - dx / 2)]).buffer(0)
    elif kind == 'custom':
        prims = one(p, 'primitives')
        anchor = one(one(p, 'options'), 'anchor') if sub(p, 'options') else []
        base = Point(0, 0).buffer(min(w, h) / 2, quad_segs=16) if anchor == ['circle'] else rect
        parts = [base]
        for n in prims:
            if not isinstance(n, list):
                continue
            g, area = graphic(n)
            if g is None:
                continue
            parts.append(g)
            if isinstance(area, Polygon):   # an outline-only custom primitive: KiCad fills it
                parts.append(area)
        g = unary_union(parts)
    else:
        raise ValueError(f'unsupported pad shape {kind!r}')
    ch = one(p, 'chamfer')
    cr = num(p, 'chamfer_ratio', 0) or 0
    if ch and cr > 0 and kind in ('rect', 'roundrect'):
        c = min(w, h) * cr
        corners = {'top_left': (-w / 2, -h / 2), 'top_right': (w / 2, -h / 2), 'bottom_left': (-w / 2, h / 2), 'bottom_right': (w / 2, h / 2)}
        for name in ch:
            if name in corners:
                cx, cy = corners[name]; sx, sy = (1 if cx < 0 else -1), (1 if cy < 0 else -1)
                g = g.difference(Polygon([(cx, cy), (cx + sx * c, cy), (cx, cy + sy * c)]))
    off = one(one(p, 'drill'), 'offset') if sub(p, 'drill') else []
    if off:
        g = translate(g, float(off[0]), float(off[1]))
    return g


def drill_shape(p):
    d = one(p, 'drill')
    if not d:
        return None
    vals = [x for x in d if not isinstance(x, list)]
    if vals and vals[0] == 'oval':
        a, b = float(vals[1]), float(vals[2] if len(vals) > 2 else vals[1])
        return (LineString([(-(a - b) / 2, 0), ((a - b) / 2, 0)]).buffer(b / 2, quad_segs=12) if a >= b
                else LineString([(0, -(b - a) / 2), (0, (b - a) / 2)]).buffer(a / 2, quad_segs=12))
    if vals and float(vals[0]) > 0:
        return Point(0, 0).buffer(float(vals[0]) / 2, quad_segs=16)
    return None


def sides_of(layers, kind):
    out = []
    for s in ('F', 'B'):
        if f'{s}.{kind}' in layers or f'*.{kind}' in layers or f'F&B.{kind}' in layers:
            out.append(s)
    return out


def tenting(node, default):
    t = sub(node, 'tenting')
    if not t:
        return default
    t = t[0][1:]
    if t and all(not isinstance(x, list) for x in t):          # KiCad 8: (tenting front back)
        return {'F': 'front' in t, 'B': 'back' in t}
    res = dict(default)
    for x in t:
        if isinstance(x, list) and len(x) > 1:
            res['F' if x[0] == 'front' else 'B'] = x[1] in ('yes', 'true')
    return res


def outline(root, footprints):
    lines, areas = [], []
    items = [(n, (0, 0, 0)) for n in root if isinstance(n, list) and n[0].startswith('gr_') and layer_of(n) == 'Edge.Cuts']
    for f in footprints:
        at = list(map(float, one(f, 'at'))); items += [(n, at) for n in f if isinstance(n, list) and n[0].startswith('fp_') and layer_of(n) == 'Edge.Cuts']
    for n, at in items:
        kind = n[0].split('_', 1)[1]
        if kind in ('line', 'arc'):
            if kind == 'line':
                g = LineString([xy(n, 'start'), xy(n, 'end')])
            elif sub(n, 'mid'):
                g = LineString(arc_points(xy(n, 'start'), xy(n, 'mid'), xy(n, 'end'), 3.0))
            else:   # legacy arc: the zero-width stroke is the centre line
                g = graphic([n[0]] + [x for x in n[1:] if not (isinstance(x, list) and x[0] in ('stroke', 'width'))])[0]
            lines.append(place(g, at) if at != (0, 0, 0) else g)
        else:
            g, area = graphic([n[0]] + [x for x in n[1:] if not (isinstance(x, list) and x[0] in ('stroke', 'width', 'fill'))])
            poly = area if isinstance(area, Polygon) else g
            if poly is not None and poly.area > 0:
                areas.append(place(poly, at) if at != (0, 0, 0) else poly)
    snapped = [LineString([(round(x, 4), round(y, 4)) for x, y in l.coords]) for l in lines]
    polys = list(polygonize(unary_union(snapped))) + areas
    if not polys:
        raise SystemExit('no closed board outline on Edge.Cuts')
    polys.sort(key=lambda p: -p.area)
    shape = polys[0]
    for p in polys[1:]:
        shape = shape.symmetric_difference(p)
    return shape.buffer(0)


def board_layers(board_path):
    root = parse(Path(board_path).read_text(encoding='utf-8'))
    setup = sub(root, 'setup')[0] if sub(root, 'setup') else ['setup']
    g_mask = num(setup, 'pad_to_mask_clearance', 0.0) or 0.0
    g_paste = num(setup, 'pad_to_paste_clearance', 0.0) or 0.0
    g_ratio = num(setup, 'pad_to_paste_clearance_ratio', 0.0) or 0.0
    min_web = num(setup, 'solder_mask_min_width', 0.0) or 0.0
    tent = tenting(setup, {'F': True, 'B': True})
    footprints = sub(root, 'footprint')
    edge = outline(root, footprints)
    pads, openings, holes = [], {'F': [], 'B': []}, []
    for f in footprints:
        ref = next((x[2] for x in sub(f, 'property') if len(x) > 2 and x[1] == 'Reference'), None) or (one(f, 'fp_text')[1] if one(f, 'fp_text') else '?')
        at = list(map(float, one(f, 'at'))); fa = at[2] if len(at) > 2 else 0.0
        attrs = one(f, 'attr')
        dnp = 'dnp' in attrs
        fm, fp_, fr = num(f, 'solder_mask_margin'), num(f, 'solder_paste_margin'), num(f, 'solder_paste_ratio', num(f, 'solder_paste_margin_ratio'))
        for idx, p in enumerate(sub(f, 'pad')):
            layers = one(p, 'layers')
            pa = list(map(float, one(p, 'at'))); angle = pa[2] if len(pa) > 2 else fa
            centre = rotate(Point(pa[:2]), -fa, origin=(0, 0)); cx, cy = at[0] + centre.x, at[1] + centre.y
            local = pad_shape(p)
            def to_board(g):
                return translate(rotate(g, -angle, origin=(0, 0)), cx, cy)
            copper = to_board(local)
            w, h = map(float, one(p, 'size')[:2])
            mm = num(p, 'solder_mask_margin', fm if fm is not None else g_mask)
            pm = num(p, 'solder_paste_margin', fp_ if fp_ is not None else g_paste)
            pr = num(p, 'solder_paste_margin_ratio', num(p, 'solder_paste_ratio', fr if fr is not None else g_ratio))
            cu = sides_of(layers, 'Cu')
            mask_sides = sides_of(layers, 'Mask')
            paste_sides = [] if dnp or p[2] == 'np_thru_hole' else sides_of(layers, 'Paste')
            mask_g = copper.buffer(mm, quad_segs=8) if mm else copper
            for s in mask_sides:
                openings[s].append(mask_g)
            paste_d = pm + pr * min(w, h)
            paste_g = copper.buffer(paste_d, quad_segs=8) if paste_d else copper
            hole = drill_shape(p)
            if hole is not None:
                holes.append(to_board(hole))
            if p[2] == 'np_thru_hole' and not cu:
                continue
            # issue #91: an NPTH listing "*.Cu" "*.Mask" (KiCad's usual mounting hole) whose pad is no larger than its
            # drill has no copper left after drilling: a board hole with a mask opening, not a bare-copper pad
            if p[2] == 'np_thru_hole' and hole is not None and copper.difference(to_board(hole).buffer(0.01, quad_segs=8)).area < 1e-4:
                continue
            pads.append(dict(id=f'{ref}:{p[1]}:{idx}', ref=ref, pad=p[1], type=p[2], shape=p[3], copper=copper,
                             sides=cu if p[2] != 'smd' else [s for s in cu if s in ('F', 'B')],
                             mask=mask_sides, paste=paste_sides, pasteShape=paste_g if paste_sides and not paste_g.is_empty else None,
                             centre=(cx, cy), maskMargin=mm, pasteMargin=paste_d))
        for n in f:
            if isinstance(n, list) and n[0].startswith('fp_') and layer_of(n) in ('F.Mask', 'B.Mask'):
                g, area = graphic(n)
                if g is not None:
                    openings[layer_of(n)[0]].append(place(area if isinstance(area, Polygon) else g, at))
    for n in root:
        if isinstance(n, list) and n[0].startswith('gr_') and layer_of(n) in ('F.Mask', 'B.Mask'):
            g, area = graphic(n)
            if g is not None:
                openings[layer_of(n)[0]].append(area if isinstance(area, Polygon) else g)
    for z in sub(root, 'zone'):
        zl = one(z, 'layers') or one(z, 'layer')
        for s in ('F', 'B'):
            if f'{s}.Mask' in zl:
                fills = [fp for fp in sub(z, 'filled_polygon') if layer_of(fp) in (None, f'{s}.Mask')]
                src = fills or sub(z, 'polygon')
                for fp in src:
                    pts = [tuple(map(float, v[1:3])) for v in one(fp, 'pts') if isinstance(v, list) and v[0] == 'xy']
                    if len(pts) >= 3:
                        openings[s].append(Polygon(pts).buffer(0))
    # copper that is not a pad (zones, tracks, drawn copper, vias): exposed where a mask opening lies over it
    other_cu = {'F': [], 'B': []}
    def cu_sides(n):
        ls = (one(n, 'layers') or []) + ([layer_of(n)] if layer_of(n) else [])
        return [s for s in ('F', 'B') if f'{s}.Cu' in ls or '*.Cu' in ls or 'F&B.Cu' in ls]
    for z in sub(root, 'zone'):
        for s in cu_sides(z):
            fills = [fp for fp in sub(z, 'filled_polygon') if layer_of(fp) in (None, f'{s}.Cu')]
            for fp in fills or sub(z, 'polygon'):      # an unfilled zone counts as its whole outline (conservative)
                pts = [tuple(map(float, v[1:3])) for v in one(fp, 'pts') if isinstance(v, list) and v[0] == 'xy']
                if len(pts) >= 3:
                    other_cu[s].append(Polygon(pts).buffer(0))
    for t in sub(root, 'segment') + sub(root, 'arc'):
        w = num(t, 'width', 0.0) or 0.0
        line = LineString(arc_points(xy(t, 'start'), xy(t, 'mid'), xy(t, 'end')) if t[0] == 'arc' else [xy(t, 'start'), xy(t, 'end')])
        for s in cu_sides(t):
            other_cu[s].append(line.buffer(w / 2) if w else line)
    drawn = [(n, None) for n in root if isinstance(n, list) and n[0].startswith('gr_')]
    for f in footprints:
        at = list(map(float, one(f, 'at')))
        drawn += [(n, at) for n in f if isinstance(n, list) and n[0].startswith('fp_')]
    for n, at in drawn:
        sides = cu_sides(n)
        if not sides:
            continue
        try:
            g, area = graphic(n)
        except ValueError:
            continue
        if g is None:
            continue
        g = area if isinstance(area, Polygon) and filled(n) else g
        for s in sides:
            other_cu[s].append(place(g, at) if at else g)
    for v in sub(root, 'via'):
        c = xy(v, 'at'); size = num(v, 'size', 0.6); drill = num(v, 'drill', 0.3)
        for s in ('F', 'B'):
            other_cu[s].append(Point(c).buffer(size / 2, quad_segs=12))
        t = tenting(v, tent)
        for s in ('F', 'B'):
            if not t[s]:
                openings[s].append(Point(c).buffer(size / 2 + g_mask, quad_segs=12))
        if not (t['F'] and t['B']):
            holes.append(Point(c).buffer(drill / 2, quad_segs=12))
    open_u = {}
    for s in ('F', 'B'):
        u = unary_union(openings[s]) if openings[s] else Polygon()
        if min_web > 0 and not u.is_empty:      # KiCad drops mask webs thinner than solder_mask_min_width
            u = u.buffer(min_web / 2, quad_segs=6).buffer(-min_web / 2, quad_segs=6)
        open_u[s] = u
    hole_u = unary_union(holes) if holes else Polygon()
    mask = {s: edge.difference(open_u[s]).difference(hole_u).buffer(0) for s in ('F', 'B')}
    paste = {s: unary_union([p['pasteShape'] for p in pads if s in p['paste'] and p['pasteShape'] is not None]).intersection(edge)
             for s in ('F', 'B')}
    copper = {s: unary_union([p['copper'] for p in pads if s in p['sides']] or [Polygon()]).difference(hole_u).intersection(edge)
              for s in ('F', 'B')}
    # issues #81 and #91: exposed copper is where a mask opening lies over copper (pads, zones, tracks, drawn copper,
    # vias). A board whose pads are all under mask, or whose only openings are NPTH holes, has none, so there is no
    # pad copper to show and no silkscreen to clip; "0 exposed" is the right answer there, not a missing export option
    all_cu = {s: unary_union([copper[s]] + other_cu[s]).difference(hole_u).intersection(edge) for s in ('F', 'B')}
    exposed_cu = {s: open_u[s].intersection(all_cu[s]) for s in ('F', 'B')}
    no_exposed = all(exposed_cu[s].is_empty or exposed_cu[s].area < 1e-6 for s in ('F', 'B'))
    return dict(edge=edge, pads=pads, mask=mask, openings=open_u, paste=paste, copper=copper, holes=hole_u,
                exposedCopper=exposed_cu, stem=Path(board_path).stem, noExposedCopper=no_exposed)


NO_EXPOSED_NOTE = 'no exposed copper on this board: no F.Mask / B.Mask aperture lies over copper, so there is no pad copper to colour and no silkscreen to clip'


# ---------------------------------------------------------------- GLB plumbing
def read_glb(path):
    b = Path(path).read_bytes()
    if b[:4] != b'glTF':
        raise SystemExit(f'{path}: not a GLB')
    jl = struct.unpack_from('<I', b, 12)[0]
    g = json.loads(b[20:20 + jl])
    bin_ = b''
    if len(b) > 20 + jl:
        bl = struct.unpack_from('<I', b, 20 + jl)[0]
        bin_ = b[28 + jl:28 + jl + bl]
    return g, bytearray(bin_)


def write_glb(path, g, bin_):
    while len(bin_) % 4:
        bin_.append(0)
    if g.get('buffers'):
        g['buffers'][0]['byteLength'] = len(bin_)
    j = json.dumps(g, separators=(',', ':')).encode()
    j += b' ' * ((-len(j)) % 4)
    total = 12 + 8 + len(j) + 8 + len(bin_)
    out = struct.pack('<III', 0x46546C67, 2, total) + struct.pack('<II', len(j), 0x4E4F534A) + j + struct.pack('<II', len(bin_), 0x004E4942) + bytes(bin_)
    Path(path).write_bytes(out)


def add_view(g, bin_, data, target=None):
    while len(bin_) % 4:
        bin_.append(0)
    off = len(bin_); bin_ += data
    bv = {'buffer': 0, 'byteOffset': off, 'byteLength': len(data)}
    if target:
        bv['target'] = target
    g.setdefault('bufferViews', []).append(bv)
    return len(g['bufferViews']) - 1


def add_mesh_data(g, bin_, pos, nrm, idx):
    pos = np.asarray(pos, dtype='<f4'); nrm = np.asarray(nrm, dtype='<f4'); idx = np.asarray(idx, dtype='<u4')
    both = np.c_[pos, nrm]                       # share vertices: CDT output repeats them per triangle
    both, inv = np.unique(both, axis=0, return_inverse=True)
    pos, nrm = np.ascontiguousarray(both[:, :3], dtype='<f4'), np.ascontiguousarray(both[:, 3:], dtype='<f4')
    idx = inv.reshape(-1)[idx].astype('<u4')
    acc = g.setdefault('accessors', [])
    pv = add_view(g, bin_, pos.tobytes(), 34962)
    acc.append({'bufferView': pv, 'componentType': 5126, 'count': len(pos), 'type': 'VEC3',
                'min': pos.min(axis=0).tolist(), 'max': pos.max(axis=0).tolist()})
    pa = len(acc) - 1
    nv = add_view(g, bin_, nrm.tobytes(), 34962)
    acc.append({'bufferView': nv, 'componentType': 5126, 'count': len(nrm), 'type': 'VEC3'}); na = len(acc) - 1
    iv = add_view(g, bin_, idx.tobytes(), 34963)
    acc.append({'bufferView': iv, 'componentType': 5125, 'count': len(idx), 'type': 'SCALAR'}); ia = len(acc) - 1
    return {'attributes': {'POSITION': pa, 'NORMAL': na}, 'indices': ia, 'mode': 4}


def node_local(nd):
    if 'matrix' in nd:
        return np.array(nd['matrix'], dtype=float).reshape(4, 4).T
    t = nd.get('translation', [0, 0, 0]); r = nd.get('rotation', [0, 0, 0, 1]); s = nd.get('scale', [1, 1, 1])
    x, y, z, w = r
    R = np.array([[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)]])
    M = np.eye(4); M[:3, :3] = R * np.array(s); M[:3, 3] = t
    return M


def world_matrices(g):
    parent = {}
    for i, nd in enumerate(g.get('nodes', [])):
        for c in nd.get('children', []):
            parent[c] = i
    cache = {}
    def wm(i):
        if i not in cache:
            M = node_local(g['nodes'][i])
            cache[i] = (wm(parent[i]) @ M) if i in parent else M
        return cache[i]
    return wm, parent


# glTF component types; KHR_mesh_quantization (what step2glb writes) stores positions as normalized int8/int16
# and dequantizes them through the node transform, so every measurement goes through accessor_array + the world matrix.
COMPONENT = {5120: '<i1', 5121: '<u1', 5122: '<i2', 5123: '<u2', 5125: '<u4', 5126: '<f4'}
COMPONENTS = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT2': 4, 'MAT3': 9, 'MAT4': 16}


def dequantize(values, acc):
    """Accessor values (array or min/max list) as floats, applying the normalized flag."""
    v = np.asarray(values, dtype=float)
    if not acc.get('normalized'):
        return v
    ct = acc['componentType']
    if ct == 5120: return np.maximum(v / 127.0, -1.0)
    if ct == 5122: return np.maximum(v / 32767.0, -1.0)
    if ct == 5121: return v / 255.0
    if ct == 5123: return v / 65535.0
    return v


def accessor_array(g, bin_, ai):
    """An accessor's elements as a float array (count x components), byteStride honoured, normalized
    integers dequantized; None when it has no buffer data (Draco and other compressed primitives)."""
    acc = g['accessors'][ai]
    if 'sparse' in acc:
        raise SystemExit('sparse accessors are not supported in board-layer meshes')
    if 'bufferView' not in acc:
        return None
    if acc['componentType'] not in COMPONENT:
        raise SystemExit(f'accessor {ai}: unsupported componentType {acc["componentType"]}')
    dt = np.dtype(COMPONENT[acc['componentType']]); comps = COMPONENTS[acc['type']]
    bv = g['bufferViews'][acc['bufferView']]
    if bv.get('buffer', 0) != 0:
        raise SystemExit('only single-buffer GLBs are supported')
    off = bv.get('byteOffset', 0) + acc.get('byteOffset', 0)
    n = acc['count']
    stride = bv.get('byteStride') or dt.itemsize * comps
    if n == 0:
        return np.zeros((0, comps))
    end = off + stride * (n - 1) + dt.itemsize * comps
    if end > len(bin_):
        raise SystemExit(f'accessor {ai} runs past the end of the binary chunk')
    raw = bytes(bin_[off:end])   # a copy: bin_ grows later and must not stay exported to numpy
    arr = np.ndarray(shape=(n, comps), dtype=dt, buffer=raw, strides=(stride, dt.itemsize))
    return dequantize(arr, acc)


def rigid_part(M):
    """M without its scale: the columns of the 3x3 normalised. Quantized GLBs carry the dequantization scale
    in the node, so this is the frame whose units are the scene's metres."""
    R = np.array(M, dtype=float)
    for c in range(3):
        n = np.linalg.norm(R[:3, c])
        if n > 0:
            R[:3, c] /= n
    return R


def transformed_bounds(lo, hi, M):
    corners = np.array([[x, y, z, 1.0] for x in (lo[0], hi[0]) for y in (lo[1], hi[1]) for z in (lo[2], hi[2])])
    t = (corners @ np.asarray(M).T)[:, :3]
    return t.min(axis=0), t.max(axis=0)


LAYER_RE = re.compile(r'_(PCB|pad|pads|via|vias|copper|soldermask|silkscreen|silkclip)(?:[-_.]\d+)?$', re.I)


def board_meshes(g, stem):
    """{layer: [(mesh index, node index)]} for the KiCad board-layer meshes, found by name."""
    found = {}
    nodes_of = {}
    for ni, nd in enumerate(g.get('nodes', [])):
        if 'mesh' in nd:
            nodes_of.setdefault(nd['mesh'], []).append(ni)
    for mi, me in enumerate(g.get('meshes', [])):
        name = me.get('name', '')
        m = LAYER_RE.search(name)
        if not m:
            continue
        layer = m.group(1).lower()
        layer = {'pads': 'pad', 'vias': 'via'}.get(layer, layer)
        for ni in nodes_of.get(mi, [None]):
            found.setdefault(layer, []).append((mi, ni))
    for mi, me in enumerate(g.get('meshes', [])):
        tag = (me.get('extras') or {}).get('adomBoardLayer')
        if tag:
            for ni in nodes_of.get(mi, [None]):
                if (mi, ni) not in found.get(tag, []):
                    found.setdefault(tag, []).append((mi, ni))
    return found


def acc_bounds(g, mi):
    """A mesh's local bounds from its POSITION accessors' min/max (dequantized when normalized)."""
    lo, hi = np.full(3, np.inf), np.full(3, -np.inf)
    for p in g['meshes'][mi]['primitives']:
        a = g['accessors'][p['attributes']['POSITION']]
        if 'min' in a and 'max' in a:
            lo = np.minimum(lo, dequantize(a['min'], a)); hi = np.maximum(hi, dequantize(a['max'], a))
    return lo, hi


class Frame:
    """KiCad board mm <-> the board frame, aligned on the board body's bounding box.

    The board frame is the board body node's world frame with its scale removed (rigid_part), so it is in
    metres even when the GLB is quantized (KHR_mesh_quantization puts the dequantization scale and offset in
    the node). Every mesh is measured through its full world matrix into this frame; generated layers are
    written in it, under a node whose matrix is exactly this frame."""
    def __init__(self, g, layers, edge):
        if 'pcb' not in layers:
            raise SystemExit('no board body mesh (<board>_PCB) in the GLB: export the STEP with the board body')
        self.g = g; self.wm, _ = world_matrices(g)
        self.pcb_mesh, self.pcb_node = layers['pcb'][0]
        self.board = rigid_part(self.wm(self.pcb_node)) if self.pcb_node is not None else np.eye(4)
        self.board_inv = np.linalg.inv(self.board)
        lo, hi = self.mesh_bounds(self.pcb_mesh, self.pcb_node)
        x0, y0, x1, y1 = edge.bounds
        sx, sy = (hi[0] - lo[0]) * 1000, (hi[1] - lo[1]) * 1000
        if abs(sx - (x1 - x0)) > 0.1 or abs(sy - (y1 - y0)) > 0.1:
            raise SystemExit(f'board body {sx:.2f} x {sy:.2f} mm does not match the Edge.Cuts outline {x1-x0:.2f} x {y1-y0:.2f} mm: wrong board file for this GLB?')
        self.lo, self.hi, self.kb = lo, hi, (x0, y0, x1, y1)

    def board_from_node(self, ni):
        """Node-local coordinates -> board frame (full world transform: matrix or TRS, parents included)."""
        if ni is None:
            return np.eye(4)
        return self.board_inv @ self.wm(ni)

    def mesh_bounds(self, mi, ni):
        """A mesh's bounds in the board frame."""
        lo, hi = acc_bounds(self.g, mi)
        return transformed_bounds(lo, hi, self.board_from_node(ni))

    def pads_match(self, mi, ni, copper_all):
        """Does the pad mesh's footprint on the board match the board file's pad copper? A stale or
        wrong .kicad_pcb (moved parts, older revision) fails here rather than painting the wrong mask."""
        lo, hi = self.mesh_bounds(mi, ni)
        xs = sorted(float(self.kb[0] + (v - self.lo[0]) * 1000.0) for v in (lo[0], hi[0]))
        ys = sorted(float(self.kb[1] + (self.hi[1] - v) * 1000.0) for v in (lo[1], hi[1]))
        x0, y0, x1, y1 = (float(v) for v in copper_all.bounds)
        d = max(abs(xs[0] - x0), abs(xs[1] - x1), abs(ys[0] - y0), abs(ys[1] - y1))
        outward = max(x0 - xs[0], xs[1] - x1, y0 - ys[0], ys[1] - y1)
        inward = max(xs[0] - x0, x1 - xs[1], ys[0] - y0, y1 - ys[1])
        ok = outward <= PAD_SPAN_OUT_MM and inward <= PAD_SPAN_IN_MM
        return d, (xs[0], ys[0], xs[1], ys[1]), (x0, y0, x1, y1), ok

    def to_pcb(self, x, y):
        return self.lo[0] + (x - self.kb[0]) / 1000.0, self.hi[1] - (y - self.kb[1]) / 1000.0

    def pcb_to_node(self, ni):
        """Board frame -> a node's local coordinates (where that node's mesh data lives)."""
        return np.linalg.inv(self.board_from_node(ni))

    def z_in_pcb(self, mi, ni, which):
        """A mesh's top (max) or bottom (min) z expressed in the board frame."""
        lo, hi = self.mesh_bounds(mi, ni)
        return float(hi[2] if which == 'max' else lo[2])


def triangulate(geom, frame, z, up, M):
    """Flat faces for a (multi)polygon in KiCad mm at height z (board body frame), into a node frame M."""
    pos, nrm, idx = [], [], []
    if geom.is_empty:
        return pos, nrm, idx
    polys = [geom] if isinstance(geom, Polygon) else [p for p in getattr(geom, 'geoms', []) if isinstance(p, Polygon)]
    n_world = (np.linalg.inv(M[:3, :3]).T @ np.array([0, 0, 1.0 if up else -1.0])); n_world /= (np.linalg.norm(n_world) or 1)
    for poly in polys:
        if poly.area < 1e-6:
            continue
        tris = shapely.constrained_delaunay_triangles(poly)
        for t in tris.geoms:
            c = list(t.exterior.coords)[:3]
            p3 = []
            for x, y in c:
                px, py = frame.to_pcb(x, y)
                v = M @ np.array([px, py, z, 1.0])
                p3.append(v[:3])
            a, b, cc = p3
            cross = np.cross(b - a, cc - a)
            if np.dot(cross, n_world) < 0:
                b, cc = cc, b
            base = len(pos)
            pos += [a, b, cc]; nrm += [n_world] * 3; idx += [base, base + 1, base + 2]
    return pos, nrm, idx


def make_material(g, name, rgb, metal, rough, extra=None):
    mats = g.setdefault('materials', [])
    for i, m in enumerate(mats):
        if m.get('name') == name:
            mats[i] = m = dict(m)
            break
    else:
        m = {'name': name}; mats.append(m); i = len(mats) - 1
    m['pbrMetallicRoughness'] = {'baseColorFactor': list(rgb[:3]) + [1.0], 'metallicFactor': metal, 'roughnessFactor': rough}
    m['doubleSided'] = True
    m.pop('alphaMode', None)
    if extra:
        m.update(extra)
    return i


def add_node_like(g, ref_node, mesh_index, name, board=None):
    """A node next to ref_node (same parent) for a generated mesh. With board (a world matrix), the node's
    world transform is exactly that frame, so mesh data written in board-frame metres lands in place even when
    ref_node carries a quantization scale; without it the node copies ref_node's own transform."""
    nd = {'name': name, 'mesh': mesh_index}
    wm, parent = world_matrices(g)
    if board is not None:
        local = np.asarray(board, dtype=float)
        if ref_node is not None and ref_node in parent:
            local = np.linalg.inv(wm(parent[ref_node])) @ local
        if not np.allclose(local, np.eye(4), atol=1e-12):
            nd['matrix'] = [float(v) for v in local.T.reshape(-1)]
    elif ref_node is not None:
        src = g['nodes'][ref_node]
        for k in ('matrix', 'translation', 'rotation', 'scale'):
            if k in src:
                nd[k] = src[k]
    g['nodes'].append(nd); ni = len(g['nodes']) - 1
    if ref_node is not None and ref_node in parent:
        g['nodes'][parent[ref_node]].setdefault('children', []).append(ni)
    else:
        scene = g['scenes'][g.get('scene', 0)]
        if ref_node is not None and ref_node not in scene['nodes']:
            raise SystemExit('cannot place the new board layer next to the board body node')
        scene['nodes'].append(ni)
    return ni


def opaque_board_materials(g, layers, changes=None):
    """Clone transparent substrate/ink materials; never alter shared component or mask materials."""
    replacements = {}
    for role in ('pcb', 'silkscreen'):
        for mi in sorted({mi for mi, _ in layers.get(role, [])}):
            for primitive in g['meshes'][mi]['primitives']:
                old = primitive.get('material')
                material = g.get('materials', [])[old] if old is not None else {}
                rgba = material.get('pbrMetallicRoughness', {}).get('baseColorFactor', [1, 1, 1, 1])
                if material.get('alphaMode', 'OPAQUE') == 'OPAQUE' and rgba[3] == 1:
                    continue
                key = (role, old)
                if key not in replacements:
                    cloned = copy.deepcopy(material)
                    cloned['name'] = material.get('name', 'default') + '_opaque_' + role
                    cloned['alphaMode'] = 'OPAQUE'
                    cloned.setdefault('pbrMetallicRoughness', {})['baseColorFactor'] = list(rgba[:3]) + [1]
                    replacements[key] = len(g.setdefault('materials', []))
                    g['materials'].append(cloned)
                primitive['material'] = replacements[key]
    if changes is not None and replacements:
        changes.append(f'opaque substrate/ink: {len(replacements)} cloned material(s); shared components and mask preserved')


# ---------------------------------------------------------------- copper
def copper(args):
    fin = finish_name(args.finish)
    L = board_layers(args.board)
    g, bin_ = read_glb(args.glb)
    layers = board_meshes(g, L['stem'])
    fr = Frame(g, layers, L['edge'])
    rep = {'tool': 'adom-aiflow board-glb copper', 'version': VERSION, 'finish': fin, 'paste': args.paste,
           'board': str(args.board), 'glb': str(args.glb), 'changes': []}
    opaque_board_materials(g, layers, rep['changes'])
    pcb_top = fr.z_in_pcb(fr.pcb_mesh, fr.pcb_node, 'max'); pcb_bot = fr.z_in_pcb(fr.pcb_mesh, fr.pcb_node, 'min')

    rgb, metal, rough = FINISHES[fin]
    cu_mat = make_material(g, f'AdomCopperPads' if fin == 'copper' else f'AdomPads_{fin.upper()}', rgb, metal, rough,
                           {'extras': {'adomFinish': fin, 'adomRule': 'pads and test points are real copper (or the board finish)'}})
    trace_mat = make_material(g, 'AdomCopperUnderMask', FINISHES['copper'][0], FINISHES['copper'][1], FINISHES['copper'][2],
                              {'extras': {'adomFinish': 'copper'}})

    # 1. pads: KiCad's own pad copper, recoloured by layer name; else built from F.Cu / B.Cu pad shapes
    if layers.get('pad'):
        for mi, ni in layers['pad']:
            for p in g['meshes'][mi]['primitives']:
                p['material'] = cu_mat
            g['meshes'][mi].setdefault('extras', {})['adomBoardLayer'] = 'pad'
        for mi, ni in layers['pad']:
            d, got, want, ok = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))
            if not ok:
                raise SystemExit(f'the GLB pads span {[round(float(v), 2) for v in got]} mm but the board file\'s pads span {[round(float(v), 2) for v in want]} mm '
                                 f'(off by {d:.2f} mm): use the .kicad_pcb this STEP was exported from')
        pad_top = max(fr.z_in_pcb(mi, ni, 'max') for mi, ni in layers['pad'])
        pad_bot = min(fr.z_in_pcb(mi, ni, 'min') for mi, ni in layers['pad'])
        rep['changes'].append(f'pad copper: {len(layers["pad"])} KiCad pad mesh(es) -> {g["materials"][cu_mat]["name"]}')
    else:
        pad_top, pad_bot = pcb_top + 0.040e-3, pcb_bot - 0.040e-3
        prims = []
        for s, z, up in (('F', pad_top, True), ('B', pad_bot, False)):
            pos, nrm, idx = triangulate(L['copper'][s], fr, z, up, np.eye(4))
            if idx:
                pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = cu_mat; prims.append(pr)
        if prims:
            g['meshes'].append({'name': f'{L["stem"]}_pad', 'primitives': prims, 'extras': {'adomBoardLayer': 'pad', 'adomGenerated': 'from F.Cu/B.Cu pad shapes'}})
            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow pads', fr.board)
        if L['pads']:
            rep['changes'].append('pad copper: the STEP had no pads (export with --include-pads); generated from the F.Cu / B.Cu pad shapes')
        else:
            rep['changes'].append('pad copper: none, the board file has no pads')
    for layer in ('via', 'copper'):
        for mi, ni in layers.get(layer, []):
            for p in g['meshes'][mi]['primitives']:
                p['material'] = cu_mat if layer == 'via' else trace_mat
            rep['changes'].append(f'{layer}: real copper material')

    # 2. solder mask from the footprints' mask layers
    if layers.get('soldermask'):
        mi, ni = layers['soldermask'][0]
        old = g['meshes'][mi]['primitives']
        mat = old[0].get('material') if old else None
        base = g['materials'][mat] if mat is not None else {'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}
        top = fr.z_in_pcb(mi, ni, 'max'); bot = fr.z_in_pcb(mi, ni, 'min')
        M = fr.pcb_to_node(ni)
        rep['changes'].append('soldermask: KiCad mask faces replaced by outline minus the F.Mask / B.Mask apertures')
    else:
        base = {'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}
        top, bot = pad_top + MASK_T_MM / 1000, pad_bot - MASK_T_MM / 1000
        mi = ni = None; M = np.eye(4)
        rep['changes'].append('soldermask: none in the STEP (export with --include-soldermask); built from the mask layers')
    bpbr = dict(base.get('pbrMetallicRoughness', {}))
    mask_mat_d = {'name': 'AdomSolderMask', 'pbrMetallicRoughness': bpbr, 'doubleSided': True,
                  'extras': {'adomRule': 'mask opens where the footprint mask layer says'}}
    if base.get('alphaMode'):
        mask_mat_d['alphaMode'] = base['alphaMode']
    elif len(bpbr.get('baseColorFactor', [1, 1, 1, 1])) > 3 and bpbr['baseColorFactor'][3] < 1:
        mask_mat_d['alphaMode'] = 'BLEND'
    g['materials'].append(mask_mat_d); mask_mat = len(g['materials']) - 1
    # KiCad may export front/back masks as separate meshes. Rebuild every one,
    # in its own local frame, and keep each mesh on its original board side.
    targets = layers.get('soldermask', []) or [(None, None)]
    mid = (pcb_top + pcb_bot) / 2
    for mask_mi, mask_ni in targets:
        if mask_mi is None:
            face_specs = [('F', top, True), ('B', bot, False)]
            mask_M = np.eye(4)
        else:
            zhi = fr.z_in_pcb(mask_mi, mask_ni, 'max')
            zlo = fr.z_in_pcb(mask_mi, mask_ni, 'min')
            mask_M = fr.pcb_to_node(mask_ni)
            face_specs = []
            if zhi > mid: face_specs.append(('F', zhi, True))
            if zlo < mid: face_specs.append(('B', zlo, False))
        prims = []
        for side, z, up in face_specs:
            pos, nrm, idx = triangulate(L['mask'][side], fr, z, up, mask_M)
            if idx:
                pr = add_mesh_data(g, bin_, pos, nrm, idx)
                pr['material'] = mask_mat
                prims.append(pr)
        if mask_mi is not None:
            g['meshes'][mask_mi]['primitives'] = prims
            g['meshes'][mask_mi].setdefault('extras', {}).update({'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'})
        else:
            g['meshes'].append({'name': f'{L["stem"]}_soldermask', 'primitives': prims, 'extras': {'adomBoardLayer': 'soldermask', 'adomGenerated': 'outline minus F.Mask/B.Mask apertures'}})
            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow soldermask', fr.board)

    # 2b. silkscreen is clipped at every mask opening, as the fab clips it (KiCad's STEP export keeps the whole ink, so a
    # pad under a text renders with ink on bare copper: a board no fab makes). The STEP's silk is compressed and cannot be
    # cut here, so the exposed copper is laid again just above the ink inside each opening: what the eye sees is the
    # fab's result, bare copper with the ink stopping at the mask edge. KiCad DRC's silk_over_copper is reported by
    # adom-aiflow gate and finish as an advisory (issue #92); moving the label off the pad keeps the board clean.
    rep['silkClip'] = {}
    for mi_s, ni_s in layers.get('silkscreen', []):
        s_top = fr.z_in_pcb(mi_s, ni_s, 'max'); s_bot = fr.z_in_pcb(mi_s, ni_s, 'min')
        mid = (pcb_top + pcb_bot) / 2
        prims = []
        for s, up in (('F', True), ('B', False)):
            if up and s_top < mid: continue
            if not up and s_bot > mid: continue
            area = L['exposedCopper'][s]
            if area.is_empty or area.area < 1e-6: continue
            z = (s_top + 0.003e-3) if up else (s_bot - 0.003e-3)
            pos, nrm, idx = triangulate(area, fr, z, up, np.eye(4))
            if idx:
                pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = cu_mat; prims.append(pr)
                rep['silkClip'][s] = round(area.area, 3)
        if prims:
            g['meshes'].append({'name': f'{L["stem"]}_silkclip', 'primitives': prims,
                                'extras': {'adomBoardLayer': 'silkclip', 'adomGenerated': 'exposed copper above the silkscreen inside every mask opening (the fab clips silk there)'}})
            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow silk clip', fr.board)
            rep['changes'].append('silkscreen: clipped at the mask openings (exposed copper laid over the ink, as the fab prints it)')

    # 3. solder where paste exists
    n_paste = {'F': 0, 'B': 0}
    if args.paste == 'solder':
        s_mat = make_material(g, 'AdomSolder', *SOLDER, {'extras': {'adomRule': 'solder only where the footprint paste layer has an aperture'}})
        prims = []
        for s, z, up in (('F', pad_top + SOLDER_ABOVE_PAD_MM / 1000, True), ('B', pad_bot - SOLDER_ABOVE_PAD_MM / 1000, False)):
            z = max(z, top + 0.005e-3) if up else min(z, bot - 0.005e-3)
            pos, nrm, idx = triangulate(L['paste'][s], fr, z, up, np.eye(4))
            n_paste[s] = sum(1 for p in L['pads'] if s in p['paste'])
            if idx:
                pr = add_mesh_data(g, bin_, pos, nrm, idx); pr['material'] = s_mat; prims.append(pr)
        if prims:
            g['meshes'].append({'name': f'{L["stem"]}_solder', 'primitives': prims, 'extras': {'adomBoardLayer': 'solder', 'adomGenerated': 'F.Paste/B.Paste apertures of fitted parts'}})
            add_node_like(g, fr.pcb_node, len(g['meshes']) - 1, 'adom-aiflow solder', fr.board)
            rep['changes'].append(f'solder: {n_paste["F"]} top / {n_paste["B"]} bottom paste apertures')

    exposed = [p for p in L['pads'] if p['mask']]
    bare = [p['id'] for p in exposed if not p['paste']]
    rep.update(pads=len(L['pads']), exposedPads=len(exposed), barePads=len(bare), pastePads=n_paste, noExposedCopper=L['noExposedCopper'],
               maskOpenings={s: (len(L['openings'][s].geoms) if hasattr(L['openings'][s], 'geoms') else int(not L['openings'][s].is_empty)) for s in 'FB'})
    g.setdefault('asset', {}).setdefault('extras', {})['adomBoardFinish'] = {
        'tool': 'adom-aiflow board-glb copper', 'version': VERSION, 'finish': fin, 'paste': args.paste,
        'rule': 'pads and test points are real copper; mask and paste follow the footprint layers'}
    write_glb(args.out, g, bin_)
    rep['out'] = str(args.out)
    if args.report:
        Path(args.report).write_text(json.dumps(rep, indent=1))
    print(f'{args.out}: finish {fin}, {len(L["pads"])} pads ({len(exposed)} exposed, {len(bare)} bare copper, '
          f'{n_paste["F"] + n_paste["B"]} with solder), mask rebuilt from the F.Mask / B.Mask apertures'
          + ('; no exposed copper on this board' if L['noExposedCopper'] else ''))
    for c in rep['changes']:
        print('  ' + c)
    # the gate runs on what was just written
    args.glb = args.out
    return check(args)


# ---------------------------------------------------------------- the gate
def prim_triangles(g, bin_, prim):
    """Triangles of an uncompressed primitive (float or KHR_mesh_quantization), in its mesh's local frame
    (dequantized, before the node transform); None if compressed."""
    if 'extensions' in prim and 'KHR_draco_mesh_compression' in prim['extensions']:
        return None
    a = g['accessors'][prim['attributes']['POSITION']]
    if 'bufferView' not in a:
        return None
    if prim.get('mode', 4) != 4:
        return np.zeros((0, 3, 3))          # points/lines/strips carry no faces the gate measures
    pos = accessor_array(g, bin_, prim['attributes']['POSITION'])[:, :3]
    if 'indices' in prim:
        idx = g['accessors'][prim['indices']]
        if idx.get('normalized'):
            raise SystemExit('normalized index accessors are not valid glTF')
        idx = accessor_array(g, bin_, prim['indices']).reshape(-1).astype(np.int64)
    else:
        idx = np.arange(len(pos))
    return pos[idx[:len(idx) - len(idx) % 3].reshape(-1, 3)]


def coverage(g, bin_, fr, mi, ni, side_top):
    """2D union (KiCad mm) of a mesh's top- or bottom-side faces; None when it cannot be read."""
    M = fr.board_from_node(ni)
    polys = []
    zs = []
    for prim in g['meshes'][mi]['primitives']:
        tris = prim_triangles(g, bin_, prim)
        if tris is None:
            return None
        flat = tris.reshape(-1, 3)
        hom = np.c_[flat, np.ones(len(flat))] @ M.T
        t = hom[:, :3].reshape(-1, 3, 3)
        zs.append(t[:, :, 2].mean(axis=1))
        polys.append(t)
    if not polys:
        return Polygon()
    t = np.concatenate(polys); z = np.concatenate(zs)
    mid = (z.max() + z.min()) / 2
    sel = t[z >= mid] if side_top else t[z < mid]
    if z.max() - z.min() < 1e-6:            # one face only: it is the top unless it sits under the board
        sel = t if (side_top == (z.max() > fr.lo[2] + (fr.hi[2] - fr.lo[2]) / 2)) else t[:0]
    kx = lambda X: fr.kb[0] + (X - fr.lo[0]) * 1000.0
    ky = lambda Y: fr.kb[1] + (fr.hi[1] - Y) * 1000.0
    tri_polys = [Polygon([(kx(p[0]), ky(p[1])) for p in tri]) for tri in sel]
    tri_polys = [p for p in tri_polys if p.is_valid and p.area > 0]
    return unary_union(tri_polys) if tri_polys else Polygon()


def check(args):
    fin = finish_name(args.finish)
    L = board_layers(args.board)
    g, bin_ = read_glb(args.glb)
    layers = board_meshes(g, L['stem'])
    errors, notes, hints = [], [], []
    try:
        fr = Frame(g, layers, L['edge'])
    except SystemExit as e:
        errors.append(str(e)); fr = None
    # Opaque ink and substrate avoid transparent depth sorting beneath the mask.
    for role in ('pcb', 'silkscreen'):
        for mi in sorted({mi for mi, _ in layers.get(role, [])}):
            for primitive in g['meshes'][mi]['primitives']:
                idx = primitive.get('material')
                material = g.get('materials', [])[idx] if idx is not None else {}
                rgba = material.get('pbrMetallicRoughness', {}).get('baseColorFactor', [1, 1, 1, 1])
                if material.get('alphaMode', 'OPAQUE') != 'OPAQUE' or rgba[3] != 1:
                    errors.append(f'{role} material is transparent: substrate and silkscreen must be opaque')
    # pad materials
    pad_meshes = layers.get('pad', [])
    if not pad_meshes and L['noExposedCopper']:
        notes.append(NO_EXPOSED_NOTE)
    elif not pad_meshes:
        errors.append(f'no pad copper in the GLB (no <board>_pad mesh) although the board file has {len(L["pads"])} pad(s)' if L['pads']
                      else 'no pad copper in the GLB (no <board>_pad mesh) and the board file has no pads, but its F.Mask / B.Mask apertures expose copper')
        if L['pads']:
            hints.append('Export the STEP with --include-pads (or let board-glb copper generate the pads from F.Cu / B.Cu).')
    for mi, ni in pad_meshes:
        if fr is not None:
            d, got, want, ok = fr.pads_match(mi, ni, L['copper']['F'].union(L['copper']['B']))
            if not ok:
                errors.append(f'the GLB pads do not line up with the board file pads (off by {d:.2f} mm): wrong or stale .kicad_pcb')
        for p in g['meshes'][mi]['primitives']:
            m = g['materials'][p['material']] if p.get('material') is not None else {}
            pbr = m.get('pbrMetallicRoughness', {})
            rgb = pbr.get('baseColorFactor', [1, 1, 1, 1])
            name = m.get('name', f'material {p.get("material")}')
            if not in_finish_range(rgb, fin):
                errors.append(f'pad material {name} colour {[round(float(c), 3) for c in rgb[:3]]} is not {fin}')
            if 'metallicFactor' not in pbr or 'roughnessFactor' not in pbr:
                errors.append(f'pad material {name} leaves metallic/roughness at the glTF default (1/1): it renders grey and pale')
            elif pbr['roughnessFactor'] > 0.75:
                errors.append(f'pad material {name} roughness {pbr["roughnessFactor"]} is too rough for metal')
    # mask openings at every exposed pad
    exposed = [p for p in L['pads'] if p['mask']]
    if fr is not None:
        mask_cov = {}
        for mi, ni in layers.get('soldermask', []):
            for s in 'FB':
                c = coverage(g, bin_, fr, mi, ni, s == 'F')
                if c is None:
                    errors.append('the solder mask is still the compressed KiCad export, so its openings cannot be proven')
                    hints.append('Run adom-aiflow board-glb copper: it rebuilds the mask from the F.Mask / B.Mask apertures.')
                    mask_cov = None; break
                mask_cov[s] = c if s not in mask_cov else mask_cov[s].union(c)
            if mask_cov is None:
                break
        solder_cov = {}
        for mi, ni in layers.get('solder', []):
            for s in 'FB':
                c = coverage(g, bin_, fr, mi, ni, s == 'F')
                if c is not None:
                    solder_cov[s] = c if s not in solder_cov else solder_cov[s].union(c)
        covered, soldered_bare = [], []
        for p in exposed:
            probe = Point(p['centre'])
            if not p['copper'].buffer(1e-6).contains(probe):
                probe = p['copper'].representative_point()
            for s in p['mask']:
                if s not in p['sides'] and p['type'] == 'smd':
                    continue
                if mask_cov and s in mask_cov and mask_cov[s].buffer(-1e-4).contains(probe):
                    covered.append(f'{p["id"]} ({s})')
                if not p['paste'] and s in solder_cov and solder_cov[s].buffer(-1e-4).contains(probe):
                    soldered_bare.append(f'{p["id"]} ({s})')
        if covered:
            errors.append(f'solder mask covers {len(covered)} exposed pad centre(s): ' + ', '.join(covered[:12]) + (' ...' if len(covered) > 12 else ''))
            hints.append('The mask must open where the footprint mask layer says (pad shape plus mask margin); never a blanket sheet.')
        if soldered_bare:
            errors.append(f'solder over {len(soldered_bare)} pad(s) without paste (test points stay bare copper): ' + ', '.join(soldered_bare[:12]))
        if mask_cov is not None and not layers.get('soldermask'):
            notes.append('no solder mask in the GLB')
        if mask_cov:
            notes.append(f'mask open at all {len(exposed)} exposed pad centres' if not covered else '')
    # silkscreen must stop at the mask openings, as the fab prints it
    if layers.get('silkscreen') and not layers.get('silkclip') and not L['noExposedCopper']:
        errors.append('the silkscreen is not clipped at the mask openings: ink over a test point or pad renders on bare copper, a board no fab makes')
        hints.append('Run adom-aiflow board-glb copper: it lays the exposed copper over the ink inside every mask opening. For a clean board, move labels off pads too: KiCad DRC silk_over_copper (adom-aiflow gate and finish report it as an advisory).')
    bare = [p['id'] for p in exposed if not p['paste']]
    rep = {'gate': 'adom-aiflow board-glb check', 'glb': str(args.glb), 'board': str(args.board), 'finish': fin,
           'ok': not errors, 'errors': errors, 'notes': [n for n in notes if n], 'exposedPads': len(exposed), 'barePads': bare,
           'noExposedCopper': L['noExposedCopper']}
    if args.report:
        p = Path(args.report)
        old = json.loads(p.read_text()) if p.is_file() else {}
        old['gate'] = rep; p.write_text(json.dumps(old, indent=1))
    if errors:
        print('BOARD GLB GATE FAILED: pads and test points must be real copper, with the mask open where the footprint says')
        for e in errors:
            print('  - ' + e)
        hints.append(f'Fix it by construction: adom-aiflow board-glb copper --glb {args.glb} --board {args.board} --out <board>.copper.glb; never hand-recolour a material index.')
        for h in dict.fromkeys(hints):
            print('Hint: ' + h)
        return 1
    if L['noExposedCopper']:
        print(f'gate passed: {NO_EXPOSED_NOTE}; substrate and silkscreen opaque, mask whole')
        return 0
    print(f'gate passed: pad copper is {fin}; mask open at {len(exposed)} exposed pads; {len(bare)} bare copper pads (test points, contacts, pins) carry no solder')
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('action', choices=['copper', 'check'])
    ap.add_argument('--glb', required=True)
    ap.add_argument('--board', required=True)
    ap.add_argument('--out')
    ap.add_argument('--finish', default='copper')
    ap.add_argument('--paste', choices=['solder', 'none'], default='none')
    ap.add_argument('--report')
    a = ap.parse_args()
    if a.action == 'copper':
        if not a.out:
            ap.error('copper needs --out (never overwrite the input GLB)')
        if Path(a.out).resolve() == Path(a.glb).resolve():
            ap.error('--out must differ from --glb')
        sys.exit(copper(a))
    sys.exit(check(a))


if __name__ == '__main__':
    main()