main
John Lauer Publish 0.2.19 81ffca7
"""board-glb-copper: real copper pads by construction, and the gate that refuses grey or masked pads.
A synthetic board (test point, two paste pads, a through-hole contact) and a hand-built GLB laid out like
a KiCad STEP export (<board>_PCB, _pad with a bare grey material, a blanket _soldermask)."""
import importlib.util, json, struct, subprocess, sys, tempfile
from pathlib import Path
import numpy as np

HERE = Path(__file__).parent
TOOL = HERE / 'board-glb-copper.py'
sp = importlib.util.spec_from_file_location('bgc', TOOL); m = importlib.util.module_from_spec(sp); sp.loader.exec_module(m)

BOARD = '''(kicad_pcb (version 20260206)
 (setup (pad_to_mask_clearance 0.05) (tenting (front yes) (back yes)))
 (gr_line (start 100 100) (end 120 100) (layer "Edge.Cuts") (stroke (width 0.05)))
 (gr_line (start 120 100) (end 120 110) (layer "Edge.Cuts") (stroke (width 0.05)))
 (gr_line (start 120 110) (end 100 110) (layer "Edge.Cuts") (stroke (width 0.05)))
 (gr_line (start 100 110) (end 100 100) (layer "Edge.Cuts") (stroke (width 0.05)))
 (footprint "TestPoint:TestPoint_Pad_D1.0mm" (layer "F.Cu") (at 104 105) (property "Reference" "TP1")
  (pad "1" smd circle (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask")))
 (footprint "R_0603" (layer "F.Cu") (at 112 105 90) (property "Reference" "R1")
  (pad "1" smd roundrect (at -0.8 0 90) (size 0.9 1) (roundrect_rratio 0.25) (layers "F.Cu" "F.Mask" "F.Paste") (solder_paste_margin -0.05))
  (pad "2" smd roundrect (at 0.8 0 90) (size 0.9 1) (roundrect_rratio 0.25) (layers "F.Cu" "F.Mask" "F.Paste")))
 (footprint "Contact" (layer "F.Cu") (at 117 102) (property "Reference" "J1")
  (pad "1" thru_hole circle (at 0 0) (size 1.3 1.3) (drill 0.78) (layers "*.Cu" "*.Mask")))
 (via (at 108 108) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu"))
)'''


def quad(x0, y0, x1, y1, z):
    """Two triangles, KiCad mm -> STEP metres (y flipped)."""
    p = [(x0, -y0), (x1, -y0), (x1, -y1), (x0, -y1)]
    v = [(x / 1000, y / 1000, z / 1000) for x, y in p]
    return [v[0], v[1], v[2], v[0], v[2], v[3]]


def glb_with(meshes, path):
    g = {'asset': {'version': '2.0'}, 'scene': 0, 'scenes': [{'nodes': []}], 'nodes': [], 'meshes': [], 'accessors': [],
         'bufferViews': [], 'buffers': [{'byteLength': 0}],
         'materials': [{'name': 'mat_pcb', 'pbrMetallicRoughness': {'baseColorFactor': [0.42, 0.45, 0.29, 1]}},
                       {'name': 'mat_26', 'pbrMetallicRoughness': {'baseColorFactor': [0.5, 0.5, 0.5, 1]}},
                       {'name': 'mat_28', 'alphaMode': 'BLEND', 'pbrMetallicRoughness': {'baseColorFactor': [0.08, 0.2, 0.14, 0.83], 'metallicFactor': 0}}]}
    bin_ = bytearray()
    for name, tris, mat in meshes:
        pos = np.array(tris, dtype=float); nrm = np.tile([0, 0, 1.0], (len(pos), 1))
        pr = m.add_mesh_data(g, bin_, pos, nrm, np.arange(len(pos)))
        pr['material'] = mat
        g['meshes'].append({'name': name, 'primitives': [pr]})
        g['nodes'].append({'name': name, 'mesh': len(g['meshes']) - 1, 'translation': [-0.1, 0.1, 0.0012]})
        g['scenes'][0]['nodes'].append(len(g['nodes']) - 1)
    m.write_glb(path, g, bin_)


def quantize_glb(src, dst, parent_rotation=None):
    """The same scene written the way step2glb writes it: KHR_mesh_quantization, POSITION and NORMAL as
    normalized int16 interleaved in one strided view, the dequantization scale and offset in each node.
    With parent_rotation, every mesh node hangs under a rotated root (a Y-up wrapper)."""
    g, b = m.read_glb(src)
    out = bytearray(); views = []; accs = []
    def view(data, stride=None, target=None):
        while len(out) % 4: out.append(0)
        v = {'buffer': 0, 'byteOffset': len(out), 'byteLength': len(data)}
        if stride: v['byteStride'] = stride
        if target: v['target'] = target
        out.extend(data); views.append(v); return len(views) - 1
    for mi, me in enumerate(g['meshes']):
        prims = []
        for pr in me['primitives']:
            pos = m.accessor_array(g, b, pr['attributes']['POSITION'])
            nrm = m.accessor_array(g, b, pr['attributes']['NORMAL'])
            idx = m.accessor_array(g, b, pr['indices']).reshape(-1).astype('<u2')
            centre = (pos.max(0) + pos.min(0)) / 2; scale = float(np.abs(pos - centre).max()) or 1.0
            q = np.round((pos - centre) / scale * 32767).astype('<i2'); n = np.round(nrm * 32767).astype('<i2')
            inter = np.zeros((len(pos), 8), dtype='<i2'); inter[:, 0:3] = q; inter[:, 4:7] = n
            v = view(inter.tobytes(), 16, 34962)
            accs.append({'bufferView': v, 'byteOffset': 0, 'componentType': 5122, 'normalized': True, 'count': len(pos),
                         'type': 'VEC3', 'min': q.min(0).tolist(), 'max': q.max(0).tolist()})
            accs.append({'bufferView': v, 'byteOffset': 8, 'componentType': 5122, 'normalized': True, 'count': len(pos), 'type': 'VEC3'})
            iv = view(idx.tobytes(), None, 34963)
            accs.append({'bufferView': iv, 'componentType': 5123, 'count': len(idx), 'type': 'SCALAR'})
            prims.append({'attributes': {'POSITION': len(accs) - 3, 'NORMAL': len(accs) - 2}, 'indices': len(accs) - 1,
                          'material': pr.get('material'), 'mode': 4})
        me['primitives'] = prims
        for nd in g['nodes']:
            if nd.get('mesh') == mi:
                t = np.array(nd.get('translation', [0, 0, 0]), dtype=float)
                nd['translation'] = (t + centre).tolist(); nd['scale'] = [scale] * 3
    g['accessors'] = accs; g['bufferViews'] = views
    g['extensionsUsed'] = ['KHR_mesh_quantization']; g['extensionsRequired'] = ['KHR_mesh_quantization']
    if parent_rotation is not None:
        kids = list(g['scenes'][0]['nodes'])
        g['nodes'].append({'name': 'yup', 'rotation': parent_rotation, 'children': kids})
        g['scenes'][0]['nodes'] = [len(g['nodes']) - 1]
    m.write_glb(dst, g, out)


def run(*a):
    r = subprocess.run([sys.executable, str(TOOL), *map(str, a)], capture_output=True, text=True)
    return r.returncode, r.stdout + r.stderr


with tempfile.TemporaryDirectory() as d:
    d = Path(d)
    board = d / 'test.kicad_pcb'; board.write_text(BOARD)
    pads = []
    for x0, y0, x1, y1 in [(103.5, 104.5, 104.5, 105.5), (111.5, 105.3, 112.5, 106.2), (111.5, 103.8, 112.5, 104.7), (116.35, 101.35, 117.65, 102.65)]:
        pads += quad(x0, y0, x1, y1, 1.55)
    raw = d / 'raw.glb'
    glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('test_pad', pads, 1),
              ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], raw)

    # Native KiCad exports can split the mask into one top and one bottom mesh.
    split_raw = d / 'split-mask.glb'
    glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('test_pad', pads, 1),
              ('test_soldermask', quad(100, 100, 120, 110, 1.56), 2),
              ('test_soldermask', quad(100, 100, 120, 110, -0.05), 2)], split_raw)
    split_fixed = d / 'split-mask-fixed.glb'
    code, out = run('copper', '--glb', split_raw, '--board', board, '--out', split_fixed)
    assert code == 0, out
    sg, sb = m.read_glb(split_fixed)
    sl = m.board_meshes(sg, 'test'); sf = m.Frame(sg, sl, m.board_layers(board)['edge'])
    masks = sl['soldermask']
    assert len(masks) == 2
    assert m.coverage(sg, sb, sf, *masks[0], False).is_empty
    assert m.coverage(sg, sb, sf, *masks[1], True).is_empty

    # 1. the raw export fails: grey pads with default metal/roughness, a blanket mask over every pad
    code, out = run('check', '--glb', raw, '--board', board)
    assert code == 1, out
    assert 'is not copper' in out and 'glTF default' in out and 'solder mask covers 5 exposed pad' in out, out

    # 2. copper by construction passes its own gate; test point and contact stay bare copper
    fixed = d / 'fixed.glb'
    code, out = run('copper', '--glb', raw, '--board', board, '--out', fixed, '--report', d / 'r.json')
    assert code == 0, out
    rep = json.loads((d / 'r.json').read_text())
    assert rep['exposedPads'] == 4 and rep['barePads'] == 2 and rep['gate']['ok'], rep
    g, b = m.read_glb(fixed)
    mats = {mt['name']: mt for mt in g['materials']}
    cu = mats['AdomCopperPads']['pbrMetallicRoughness']
    assert m.in_finish_range(cu['baseColorFactor'], 'copper') and 'metallicFactor' in cu and 'roughnessFactor' in cu
    pad_mesh = next(me for me in g['meshes'] if me['name'] == 'test_pad')
    assert g['materials'][pad_mesh['primitives'][0]['material']]['name'] == 'AdomCopperPads'
    assert 'mat_26' in mats   # the shared KiCad material itself is untouched: only the pad layer moved

    # the rebuilt mask: open over every pad (incl. mask margin), closed over the tented via
    L = m.board_layers(board); fr = m.Frame(g, m.board_meshes(g, 'test'), L['edge'])
    mi, ni = m.board_meshes(g, 'test')['soldermask'][0]
    top = m.coverage(g, b, fr, mi, ni, True); bot = m.coverage(g, b, fr, mi, ni, False)
    from shapely.geometry import Point
    assert not top.contains(Point(104, 105)) and not top.contains(Point(104.53, 105)), 'mask margin 0.05 mm around TP1'
    assert top.contains(Point(104.6, 105)), 'mask beyond the margin'
    assert top.contains(Point(108, 108)), 'tented via stays covered'
    assert not bot.contains(Point(117, 102)) and bot.contains(Point(112, 105)), 'bottom mask opens only at the through-hole contact'

    # 3. solder only where paste exists: on R1, never on TP1 or J1; paste margin applied
    sol = d / 'solder.glb'
    code, out = run('copper', '--glb', raw, '--board', board, '--out', sol, '--paste', 'solder')
    assert code == 0, out
    g2, b2 = m.read_glb(sol); lay = m.board_meshes(g2, 'test'); fr2 = m.Frame(g2, lay, L['edge'])
    s_cov = m.coverage(g2, b2, fr2, *lay['solder'][0], True)
    assert s_cov.contains(Point(112, 105.8)) and not s_cov.contains(Point(104, 105)) and not s_cov.contains(Point(117, 102))
    assert abs(s_cov.area - 2 * 0.9 * 1.0) > 0.05, 'paste margin -0.05 shrinks pad 1 aperture'

    # 4. finishes: ENIG passes as ENIG, and copper pads fail an ENIG gate
    enig = d / 'enig.glb'
    assert run('copper', '--glb', raw, '--board', board, '--out', enig, '--finish', 'enig')[0] == 0
    assert run('check', '--glb', fixed, '--board', board, '--finish', 'enig')[0] == 1

    # 5. a stale board (pads moved) is refused instead of painting the wrong mask
    stale = d / 'stale.kicad_pcb'; stale.write_text(BOARD.replace('(at 117 102)', '(at 101 108)'))
    code, out = run('copper', '--glb', raw, '--board', stale, '--out', d / 'x.glb')
    assert code != 0 and 'board file' in out, out

    # 6. never overwrite the input
    assert run('copper', '--glb', raw, '--board', board, '--out', raw)[0] != 0
    # Transparent export materials must be fixed without changing shared components or mesh data.
    import copy
    transparent, payload = m.read_glb(fixed)
    transparent['materials'][0]['alphaMode'] = 'BLEND'
    transparent['materials'][0]['pbrMetallicRoughness']['baseColorFactor'][3] = 0.98
    ink = copy.deepcopy(transparent['meshes'][0]); ink['name'] = 'test_silkscreen'
    component = copy.deepcopy(ink); component['name'] = 'component_body'
    for mesh in (ink, component):
        transparent['meshes'].append(mesh)
        transparent['nodes'].append({'mesh': len(transparent['meshes']) - 1})
    transparent['nodes'].append({'mesh': len(transparent['meshes']) - 2})  # repeated ink instance
    original = copy.deepcopy(transparent)
    bad = d / 'transparent.glb'; m.write_glb(bad, transparent, payload)
    code, out = run('check', '--glb', bad, '--board', board)
    assert code == 1 and 'pcb material is transparent' in out and 'silkscreen material is transparent' in out, out
    layers = m.board_meshes(transparent, 'test')
    m.opaque_board_materials(transparent, layers)
    assert transparent['materials'][:len(original['materials'])] == original['materials']
    assert len(transparent['materials']) == len(original['materials']) + 2
    assert transparent['meshes'][-1] == original['meshes'][-1], 'shared component stays untouched'
    for role in ('pcb', 'silkscreen'):
        for mi, _ in layers[role]:
            before = copy.deepcopy(original['meshes'][mi]); after = copy.deepcopy(transparent['meshes'][mi])
            for a, b in zip(before['primitives'], after['primitives']):
                material = transparent['materials'][b.pop('material')]; a.pop('material')
                assert material['alphaMode'] == 'OPAQUE'
                assert material['pbrMetallicRoughness']['baseColorFactor'] == [0.42, 0.45, 0.29, 1]
            assert before == after, 'geometry unchanged'
    once = copy.deepcopy(transparent)
    m.opaque_board_materials(transparent, layers)
    assert once == transparent, 'normalization is idempotent'
    assert transparent['nodes'] == original['nodes'] and transparent['accessors'] == original['accessors']
    repaired = d / 'opaque.glb'
    code, out = run('copper', '--glb', bad, '--board', board, '--out', repaired)
    assert code == 0, out
    assert run('check', '--glb', repaired, '--board', board)[0] == 0
    # 7. KHR_mesh_quantization (step2glb's output): normalized int16 positions, interleaved, dequantized by the
    # node scale/translation, under a rotated root. Before the fix the board body read as 32767 x 32767 "mm".
    import math as _math
    for rot in (None, [-_math.sqrt(0.5), 0, 0, _math.sqrt(0.5)]):
        qraw = d / f'quantized-{rot is not None}.glb'; quantize_glb(raw, qraw, rot)
        qg, qb = m.read_glb(qraw)
        assert qg['accessors'][0]['componentType'] == 5122 and qg['accessors'][0]['normalized']
        code, out = run('check', '--glb', qraw, '--board', board)
        assert code == 1 and 'does not match the Edge.Cuts' not in out and 'solder mask covers 5 exposed pad' in out, out
        qfixed = d / f'quantized-{rot is not None}-fixed.glb'
        code, out = run('copper', '--glb', qraw, '--board', board, '--out', qfixed)
        assert code == 0, out
        qg, qb = m.read_glb(qfixed); ql = m.board_meshes(qg, 'test'); qf = m.Frame(qg, ql, L['edge'])
        assert qg['extensionsRequired'] == ['KHR_mesh_quantization']   # kept: untouched meshes stay quantized
        qmi, qni = ql['soldermask'][0]
        qtop = m.coverage(qg, qb, qf, qmi, qni, True); qbot = m.coverage(qg, qb, qf, qmi, qni, False)
        assert not qtop.contains(Point(104, 105)) and qtop.contains(Point(104.6, 105)) and qtop.contains(Point(108, 108))
        assert not qbot.contains(Point(117, 102)) and qbot.contains(Point(112, 105))
        assert abs(qtop.area - top.area) < 0.05, (qtop.area, top.area)
        # generated layers land at the board in world space: the silk-free solder build stays on the pads
        qs = d / f'quantized-{rot is not None}-solder.glb'
        assert run('copper', '--glb', qraw, '--board', board, '--out', qs, '--paste', 'solder')[0] == 0
        sg2, sb2 = m.read_glb(qs); sl2 = m.board_meshes(sg2, 'test'); sf2 = m.Frame(sg2, sl2, L['edge'])
        scov = m.coverage(sg2, sb2, sf2, *sl2['solder'][0], True)
        assert scov.contains(Point(112, 105.8)) and not scov.contains(Point(104, 105))
        wm2, _ = m.world_matrices(sg2); smi, sni = sl2['solder'][0]; pmi, pni = sl2['pcb'][0]
        sw = m.transformed_bounds(*m.acc_bounds(sg2, smi), wm2(sni)); pw = m.transformed_bounds(*m.acc_bounds(sg2, pmi), wm2(pni))
        assert np.all(sw[0] >= pw[0] - 1e-3) and np.all(sw[1] <= pw[1] + 1e-3), 'solder sits on the board in world space'
        # a stale board is still refused, with plain numbers in the message
        code, out = run('copper', '--glb', qraw, '--board', stale, '--out', d / 'qx.glb')
        assert code != 0 and 'board file' in out and 'np.float64' not in out, out
    # 8. issue #81: a board with no pads and no mask apertures (a reflector: copper gr_poly all under mask) has no
    # exposed copper. copper writes the GLB and the gate passes with a note; the --include-pads hint never shows.
    pad_free = d / 'reflector.kicad_pcb'
    pad_free.write_text(BOARD.split(' (footprint')[0] + ' (gr_poly (pts (xy 103 103) (xy 106 103) (xy 106 106) (xy 103 106)) (layer "F.Cu") (fill solid))\n)')
    assert m.board_layers(pad_free)['noExposedCopper'] and not m.board_layers(board)['noExposedCopper']
    refl_raw = d / 'reflector.glb'
    glb_with([('reflector_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('reflector_copper', quad(103, 103, 106, 106, 1.55), 1),
              ('reflector_silkscreen', quad(101, 101, 102, 102, 1.57), 0),
              ('reflector_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], refl_raw)
    code, out = run('check', '--glb', refl_raw, '--board', pad_free)
    assert code == 0 and 'no exposed copper on this board' in out and '--include-pads' not in out, out
    refl = d / 'reflector-fixed.glb'
    code, out = run('copper', '--glb', refl_raw, '--board', pad_free, '--out', refl, '--report', d / 'refl.json')
    assert code == 0 and 'no exposed copper on this board' in out and '--include-pads' not in out, out
    rr = json.loads((d / 'refl.json').read_text())
    assert rr['pads'] == 0 and rr['noExposedCopper'] and rr['gate']['ok'] and 'no exposed copper' in ' '.join(rr['gate']['notes']), rr
    rg, rb = m.read_glb(refl); rl = m.board_meshes(rg, 'reflector'); rf = m.Frame(rg, rl, m.board_layers(pad_free)['edge'])
    rtop = m.coverage(rg, rb, rf, *rl['soldermask'][0], True)
    assert abs(rtop.area - 200.0) < 0.01, ('mask stays whole', rtop.area)
    for role in ('pcb', 'silkscreen'):
        for mi, _ in rl[role]:
            for pr in rg['meshes'][mi]['primitives']:
                assert rg['materials'][pr['material']].get('alphaMode', 'OPAQUE') == 'OPAQUE'
    # a board WITH pads whose GLB has no pad mesh is still refused, with the export hint
    no_pad_glb = d / 'no-pad-mesh.glb'
    glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], no_pad_glb)
    code, out = run('check', '--glb', no_pad_glb, '--board', board)
    assert code == 1 and 'no pad copper in the GLB' in out and 'has 4 pad(s)' in out and '--include-pads' in out, out
    # a padless board with a mask aperture (exposed copper but no pads) is not excused
    open_board = d / 'reflector-open.kicad_pcb'
    open_board.write_text(pad_free.read_text().replace('(fill solid))', '(fill solid))\n (gr_rect (start 104 104) (end 105 105) (layer "F.Mask") (fill solid))'))
    assert not m.board_layers(open_board)['noExposedCopper']
    (d / 'reflector-open.glb').write_bytes(refl_raw.read_bytes())
    code, out = run('check', '--glb', d / 'reflector-open.glb', '--board', open_board)
    assert code == 1 and 'no pad copper' in out and '--include-pads' not in out, out
    # 9. issue #91: a reflector whose pads are all under mask (custom F.Cu pads with no mask layer) plus dowel NPTHs
    # with mask openings ("*.Cu" "*.Mask", KiCad's usual mounting hole: no copper once drilled) has no exposed copper.
    # copper passes its own gate with no silkclip layer, and no NPTH is a bare-copper pad.
    edge = BOARD.split(' (footprint')[0]
    disc = ''.join(f' (footprint "Reflector" (layer "F.Cu") (at {x} 105) (property "Reference" "E{i}")\n'
                   f'  (pad "1" smd custom (at 0 0) (size 0.5 0.5) (layers "F.Cu") (options (clearance outline) (anchor rect))\n'
                   f'   (primitives (gr_poly (pts (xy -1 -1) (xy 1 -1) (xy 1 1) (xy -1 1)) (width 0) (fill yes)))))\n'
                   for i, x in enumerate((106, 110, 114), 1))
    dowels = ''.join(f' (footprint "MountingHole" (layer "F.Cu") (at {x} {y}) (property "Reference" "MH{i}")\n'
                     f'  (pad "" np_thru_hole circle (at 0 0) (size 1.5 1.5) (drill 1.5) (layers "*.Cu" "*.Mask")))\n'
                     for i, (x, y) in enumerate(((102, 102), (118, 102), (110, 108.5)), 1))
    covered = d / 'covered.kicad_pcb'; covered.write_text(edge + disc + dowels + ')')
    CL = m.board_layers(covered)
    assert CL['noExposedCopper'], 'all pads under mask, NPTH openings over no copper'
    assert len(CL['pads']) == 3 and not any(p['ref'].startswith('MH') for p in CL['pads']), [p['id'] for p in CL['pads']]
    assert not CL['openings']['F'].is_empty and not CL['openings']['B'].is_empty, 'the dowels keep their mask openings'
    cov_raw = d / 'covered.glb'
    cpads = []
    for x in (106, 110, 114):
        cpads += quad(x - 1, 104, x + 1, 106, 1.55)
    glb_with([('covered_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('covered_pad', cpads, 1),
              ('covered_silkscreen', quad(105, 104.5, 107, 105.5, 1.57), 0),
              ('covered_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], cov_raw)
    cov = d / 'covered-fixed.glb'
    code, out = run('copper', '--glb', cov_raw, '--board', covered, '--out', cov, '--report', d / 'covered.json')
    assert code == 0 and 'not clipped' not in out, out
    cr = json.loads((d / 'covered.json').read_text())
    assert cr['pads'] == 3 and cr['exposedPads'] == 0 and cr['barePads'] == 0 and cr['noExposedCopper'] and cr['gate']['ok'], cr
    assert 'silkclip' not in m.board_meshes(m.read_glb(cov)[0], 'covered')
    assert run('check', '--glb', cov_raw.with_name('covered-fixed.glb'), '--board', covered)[0] == 0
    # the same dowels listing only "*.Mask" read the same; an NPTH whose pad is larger than its drill keeps copper
    assert m.board_layers(d / 'covered.kicad_pcb')['noExposedCopper']
    star_mask = d / 'covered-mask-only.kicad_pcb'; star_mask.write_text(covered.read_text().replace('(layers "*.Cu" "*.Mask")', '(layers "*.Mask")'))
    assert m.board_layers(star_mask)['noExposedCopper'] and len(m.board_layers(star_mask)['pads']) == 3
    ring = d / 'covered-ring.kicad_pcb'; ring.write_text(covered.read_text().replace('(size 1.5 1.5) (drill 1.5)', '(size 2.5 2.5) (drill 1.5)', 1))
    RL = m.board_layers(ring)
    assert not RL['noExposedCopper'] and [p['ref'] for p in RL['pads'] if p['mask']] == ['MH1'], 'an NPTH copper ring under an opening is exposed'
    # 10. a genuinely exposed pad still needs the silkscreen clipped (0.2.8): the raw export with ink fails, copper
    # builds the silkclip layer over the exposed pads, and a GLB without it is refused
    silk_raw = d / 'silk.glb'
    glb_with([('test_PCB', quad(100, 100, 120, 110, 0) + quad(100, 100, 120, 110, 1.51), 0),
              ('test_pad', pads, 1),
              ('test_silkscreen', quad(103, 104.8, 106, 105.2, 1.57), 0),
              ('test_soldermask', quad(100, 100, 120, 110, 1.56) + quad(100, 100, 120, 110, -0.05), 2)], silk_raw)
    code, out = run('check', '--glb', silk_raw, '--board', board)
    assert code == 1 and 'not clipped at the mask openings' in out, out
    silk_fixed = d / 'silk-fixed.glb'
    code, out = run('copper', '--glb', silk_raw, '--board', board, '--out', silk_fixed)
    assert code == 0, out
    sg3, sb3 = m.read_glb(silk_fixed)
    assert 'silkclip' in m.board_meshes(sg3, 'test')
    for me in sg3['meshes']:
        if (me.get('extras') or {}).get('adomBoardLayer') == 'silkclip':
            me['extras']['adomBoardLayer'] = 'other'; me['name'] = 'unrelated'
    unclipped = d / 'silk-unclipped.glb'; m.write_glb(unclipped, sg3, sb3)
    code, out = run('check', '--glb', unclipped, '--board', board)
    assert code == 1 and 'not clipped at the mask openings' in out, out
print('PASS: grey/masked pads refused; quantized (KHR_mesh_quantization) GLBs measured through node transforms; copper by construction from pad, mask and paste layers; bare test points; finishes; stale board refused; a padless board with all copper under mask passes with a note; pads all under mask and NPTH mask openings need no silkclip; NPTH *.Cu *.Mask is not bare copper; exposed pads still need the silkscreen clipped')