skill
altium-export-molecule
Public Made by Adomby adom
Direct Altium -> OCCT molecule/board path: STEP export (bodies) + Gerber export (copper + silkscreen) -> composited top-face overlay -> step2glb molecule mode. Includes make_overlay.py. Hands off to molecule-publish.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
#!/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()