master
barrett-land Publish 2.3.0 f796f1f 2mo ago
#!/usr/bin/env python3
"""Build a top-face overlay PNG (exposed copper pads + silkscreen) from Altium Gerbers.

Altium's STEP export contains only the board substrate and component 3D bodies -- no copper
and no silkscreen. Both are recovered from the Gerbers and composited into one RGBA image
that step2glb lays onto the board's top face via --silk-top.

    python3 make_overlay.py [--gerber-dir gerber] [--out overlay_top.png] [--dpi 600]

Alignment is the whole game: the image must map 1:1 onto the board outline, so every layer
is rendered through gerbv with the SAME explicit window, taken from the board rect rather
than from each layer's own extents.
"""

import argparse
import re
import subprocess
import sys
from pathlib import Path

import numpy as np
from PIL import Image

# Exposed-metal and silkscreen colours. The board's own solder mask green comes from the
# STEP, so anything left transparent here shows the substrate through.
PAD_RGB = (206, 168, 76)      # ENIG-ish gold
SILK_RGB = (245, 245, 240)    # slightly warm white, matches real silkscreen ink

COORD = re.compile(rb'X(-?\d+)Y(-?\d+)')


def drill_hits(path: Path):
    """Parse an Excellon drill file -> [(x_mm, y_mm, dia_mm), ...].

    Needed because the board geometry has real holes but this overlay is a flat decal laid on
    top of it: without punching the holes out of the artwork, a mounting hole renders as a solid
    gold disc sitting over the hole. Assumes Altium's METRIC / LZ / FILE_FORMAT=4:4 header,
    which is what its NC Drill output emits.
    """
    if not path.exists():
        return []
    text = path.read_text(errors='replace')
    if 'METRIC' not in text:
        print(f'  warning: {path.name} is not METRIC; skipping drill subtraction')
        return []

    def coord(v):
        neg = v.startswith('-')
        v = v.lstrip('-').ljust(8, '0')          # 4 integer + 4 decimal digits
        val = int(v[:4]) + int(v[4:]) / 10000.0
        return -val if neg else val

    tools, cur, hits = {}, None, []
    for line in text.splitlines():
        line = line.strip()
        m = re.match(r'^T(\d+)F\d+S\d+C([\d.]+)', line)
        if m:
            tools[m.group(1)] = float(m.group(2)); continue
        m = re.match(r'^T(\d+)$', line)
        if m:
            cur = m.group(1); continue
        m = re.match(r'^X(-?\d+)Y(-?\d+)$', line)
        if m and cur:
            hits.append((coord(m.group(1)), coord(m.group(2)), tools.get(cur, 0.0)))
    return hits


def board_rect_inches(gm1: Path):
    """Board outline rect from the Unit PCB Boundary layer (%FSLAX25Y25% -> 5 decimals).

    Only the positive quadrant is taken: the layer also carries an annotation below the
    board (negative Y) that would otherwise inflate the rect and shrink the artwork.
    """
    xs, ys = [], []
    for m in COORD.finditer(gm1.read_bytes()):
        xs.append(int(m.group(1)) * 1e-5)
        ys.append(int(m.group(2)) * 1e-5)
    if not xs:
        sys.exit(f'no coordinates in {gm1}')
    return max(0.0, min(xs)), max(0.0, min(ys)), max(xs), max(ys)


def render(gerber: Path, out: Path, rect, dpi: int):
    x0, y0, x1, y1 = rect
    # Round the geometry: gerbv fails outright on full float repr like "1.9881900000000001".
    f = lambda v: f'{v:.5f}'
    subprocess.run(
        ['gerbv', '-x', 'png', '-o', str(out),
         '-O', f'{f(x0)}x{f(y0)}', '-W', f'{f(x1 - x0)}x{f(y1 - y0)}',
         '-D', str(dpi), '-B', '0', '-b', '#000000', '-f', '#FFFFFF', str(gerber)],
        check=True, capture_output=True)
    a = np.asarray(Image.open(out).convert('L')).astype(np.float32)
    # gerbv alpha-blends the foreground, so "white" peaks well below 255; normalise by the
    # real peak or every layer comes out semi-transparent.
    return np.clip(a / max(a.max(), 1.0), 0.0, 1.0)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--gerber-dir', default='gerber')
    ap.add_argument('--out', default='overlay_top.png')
    ap.add_argument('--dpi', type=int, default=600)
    args = ap.parse_args()

    g = Path(args.gerber_dir)
    rect = board_rect_inches(g / 'board.GM1')
    w_mm, h_mm = (rect[2] - rect[0]) * 25.4, (rect[3] - rect[1]) * 25.4
    print(f'board rect {rect[0]:.4f},{rect[1]:.4f} -> {rect[2]:.4f},{rect[3]:.4f} in '
          f'({w_mm:.2f} x {h_mm:.2f} mm)')

    tmp = Path('.overlay-tmp'); tmp.mkdir(exist_ok=True)
    cu   = render(g / 'board.GTL', tmp / 'cu.png',   rect, args.dpi)   # top copper, positive
    mask = render(g / 'board.GTS', tmp / 'mask.png', rect, args.dpi)   # solder mask, NEGATIVE
    silk = render(g / 'board.GTO', tmp / 'silk.png', rect, args.dpi)   # top overlay

    # The mask layer is negative polarity: its drawn features are the openings. Exposed metal
    # is therefore copper AND opening -- not all copper, most of which is a covered pour.
    pad = np.minimum(cu, mask)

    h, w = pad.shape
    rgba = np.zeros((h, w, 4), dtype=np.float32)

    # Pads first, silkscreen painted over them (that is the physical stack-up).
    for i, c in enumerate(PAD_RGB):
        rgba[..., i] = c
    rgba[..., 3] = pad

    for i, c in enumerate(SILK_RGB):
        rgba[..., i] = rgba[..., i] * (1 - silk) + c * silk
    rgba[..., 3] = np.maximum(rgba[..., 3], silk)

    # Punch the drilled holes out of the artwork. The STEP already contains real holes, so any
    # opaque pixel over a hole is a lie -- it hides the geometry underneath.
    hits = drill_hits(g / 'board.drl')
    if hits:
        px_per_mm = w / ((rect[2] - rect[0]) * 25.4)
        h_mm = (rect[3] - rect[1]) * 25.4
        yy, xx = np.mgrid[0:h, 0:w]
        punched = 0
        for x_mm, y_mm, dia in hits:
            if dia <= 0:
                continue
            cx = (x_mm - rect[0] * 25.4) * px_per_mm
            cy = (h_mm - (y_mm - rect[1] * 25.4)) * px_per_mm   # image row 0 is max Y
            r = (dia / 2.0) * px_per_mm
            x0, x1 = max(0, int(cx - r - 2)), min(w, int(cx + r + 3))
            y0, y1 = max(0, int(cy - r - 2)), min(h, int(cy + r + 3))
            if x0 >= x1 or y0 >= y1:
                continue
            sub = ((xx[y0:y1, x0:x1] - cx) ** 2 + (yy[y0:y1, x0:x1] - cy) ** 2) <= r * r
            rgba[y0:y1, x0:x1, 3][sub] = 0.0
            punched += 1
        print(f'  drills: {len(hits)} hits, {punched} punched through the overlay')
    else:
        print('  drills: none found (gerber/board.drl absent) -- holes will be covered by pad art')

    out = Image.fromarray(
        np.concatenate([rgba[..., :3], rgba[..., 3:4] * 255], axis=2).astype(np.uint8), 'RGBA')
    out.save(args.out)
    print(f'copper {(cu > .5).mean() * 100:.1f}%  openings {(mask > .5).mean() * 100:.1f}%  '
          f'pads {(pad > .5).mean() * 100:.1f}%  silk {(silk > .5).mean() * 100:.1f}%')
    print(f'wrote {args.out}  {out.size[0]}x{out.size[1]}')


if __name__ == '__main__':
    main()