app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
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()