skill
Publish a Hardware Component to the Adom Wiki
Public Made by Adomby adom
The one-stop-shop for publishing a hardware component (or molecule/board) to the Adom Wiki. The whole lifecycle: page anatomy, the clone→edit→preview→push loop and its hard rules, making a great hero image (Hero Component Studio — 16:10, overlay-safe, datasheet-backed), the animated 3D composite, the interactive 3D viewer, ct thumbnails, the datasheet-accuracy audit, and every wiki push/pull gotcha we've hit. Now includes the worked Path B (board) pipeline: STEP-to-GLB, refdes tagging, layer de-
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
#!/usr/bin/env python3
"""bake_board_glb.py — turn a KiCad board STEP-derived GLB into a viewer-ready, tagged model.
The Path B model-prep step (see SKILL.md §5c). What it does, and why each step exists:
1. TAG every solid `section|REFDES|i` — **refdes comes from the GLB node hierarchy**: KiCad's
STEP export names each component's assembly node with its refdes (R39, U2 …); both converters
(step2glb, cascadio) preserve it. Geometry-bearing LEAF nodes are OCCT tags (`=>[0:1:1:41]`),
so walk UP the parent chain to the first node matching a refdes that exists in the .kicad_pcb.
Verified 297/297 solids on a 143-footprint board — no positional guessing.
(Do NOT match by nearest centroid as the primary method: on the same board it provably
mistagged 4/297 — a big IC's pin-1 dimple sits closer to a neighbouring 0402 than to its own
origin, and stale geometry lands wherever it was when the STEP was exported.)
2. CROSS-CHECK each solid's centroid against its refdes's .kicad_pcb position → a distance over
~2 mm means the STEP predates a component move (**stale geometry**). Keep the (correct) refdes
tag, but flag it in the report — the page must say the model is stale for those refs.
3. SECTION each refdes. Default: **derive sheet membership from the design files** — the PCB's
`(path "/<sheet-uuid>/<symbol-uuid>")` entries against the root .kicad_sch's sheet
declarations (works without the sub-sheet files). An explicit blocks.json (author's finer
sections) overrides; refs in neither get "other".
4. BAKE node transforms into vertex data (KiCad places purely by node transform; the Adom viewer
bundle does NOT apply node transforms — unbaked, the whole BOM collapses at the origin), then
RECENTER on the board-outline centre. Verified after export: every node matrix is identity.
Coordinate convention: STEP is METRES with Y NEGATED vs the PCB (glb_y = -pcb_y/1000).
5. DE-FIGHT the layers (measured on a real board: mask sheet sits 10-15 µm off copper, silk 10 µm
under component bottoms — about ONE depth-buffer step at viewing distance, i.e. guaranteed
shimmer): lift top MASK +15 µm, top SILK +15 µm, components +30 µm (skip parts that wrap the
board edge); mirror negative on the bottom. Order (silk above mask above copper) is preserved.
Also force the PCB material OPAQUE — exporters emit it at alpha 0.98 BLEND, which re-blends
the exactly-coplanar buried copper faces every frame (the worst clip of all, unfixable by
offsets since one copper mesh carries both slabs). Viewer-side, also raise the camera's
near plane (minZ = radius/100) — see the board-viewer template.
6. COVERAGE REPORT: real parts with NO solid (invisible in the viewer!) vs legitimately-bodiless
footprints, plus DNP refs and stale-geometry refs. Print it and put it on the page (§7B.4).
Usage:
bake_board_glb.py <raw.glb> <board.kicad_pcb> <out.glb>
[--sch root.kicad_sch] [--blocks blocks.json]
[--report coverage.json] [--index board-index.json]
[--silk-um 15] [--mask-um 15] [--comp-um 30]
NOTE: feed this a **cascadio/float32 GLB**. step2glb output is KHR_mesh_quantization-compressed,
which trimesh misreads by 32767x — dequantize first (gltf-transform dequantize) or convert with
cascadio for the bake and keep step2glb for direct-to-viewer uses.
Requires: trimesh + numpy.
"""
import re, json, collections, argparse
import numpy as np
import trimesh
BODILESS_FP = ('SolderPad', 'SolderPads', 'MountingHole', 'SolderJumper', 'TestPoint',
'Fiducial', 'LOGO', 'Logo', 'Feeder_Pins')
REFPAT = re.compile(r'^([A-Z]{1,4}[0-9]+)(?:[ _].*)?$')
ap = argparse.ArgumentParser()
ap.add_argument('raw_glb'); ap.add_argument('kicad_pcb'); ap.add_argument('out_glb')
ap.add_argument('--sch'); ap.add_argument('--blocks')
ap.add_argument('--report'); ap.add_argument('--index')
ap.add_argument('--silk-um', type=float, default=15.0)
ap.add_argument('--mask-um', type=float, default=15.0)
ap.add_argument('--comp-um', type=float, default=30.0)
ap.add_argument('--stale-mm', type=float, default=2.0)
A = ap.parse_args()
# ---------------- footprints (refdes, value, fp name, position, layer, dnp, sheet path)
s = open(A.kicad_pcb, encoding='utf8', errors='replace').read()
fps = []
for b in s.split('\n\t(footprint ')[1:]:
g = lambda k: (re.search(r'\(property "%s" "([^"]*)"' % k, b) or [None, None])[1]
at = re.search(r'\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', b)
lay = re.search(r'\(layer "([^"]+)"\)', b)
head = b[:b.find('(pad ')] if '(pad ' in b else b
pth = re.search(r'\(path "([^"]*)"\)', head)
fps.append(dict(ref=g('Reference'), val=g('Value'),
fp=re.match(r'"([^"]+)"', b).group(1).split(':')[-1],
x=float(at.group(1)), y=float(at.group(2)),
rot=float(at.group(3)) if at.group(3) else 0.0,
layer=lay.group(1) if lay else '?',
dnp=bool(re.search(r'\(attr[^)]*\bdnp\b', head)),
path=pth.group(1) if pth else ''))
fps = [f for f in fps if f['ref'] and not f['ref'].startswith('G*')]
REFS = {f['ref'] for f in fps}
F = {f['ref']: f for f in fps}
# ---------------- sections: sheet-derived default, blocks.json override
sheet_of = {}
if A.sch:
sch = open(A.sch, encoding='utf8', errors='replace').read()
sheets = {} # uuid -> sheet name
for m in re.finditer(r'\(sheet\s(.*?)\n\t\)', sch, re.S):
u = re.search(r'\(uuid "([^"]+)"\)', m.group(1))
n = re.search(r'\(property "Sheetname" "([^"]+)"', m.group(1))
if u and n:
sheets[u.group(1)] = n.group(1)
for f in fps:
parts = [p for p in f['path'].split('/') if p]
f['sheet'] = sheets.get(parts[0], 'root') if parts else 'root'
sheet_of[f['ref']] = f['sheet']
BLK = {}
if A.blocks:
BL = json.load(open(A.blocks))
BLK = {r: k for k, v in BL.items() for r in v}
def section(ref):
return BLK.get(ref) or sheet_of.get(ref, 'other') if (BLK or sheet_of) else 'other'
# ---------------- board outline centre
xs, ys = [], []
for m in re.finditer(r'\(gr_(?:line|arc|rect|circle|poly)\s(.*?)\n\t\)', s, re.S):
if '"Edge.Cuts"' not in m.group(1):
continue
for x, y in re.findall(r'\((?:start|end|mid|center|xy)\s+(-?[\d.]+)\s+(-?[\d.]+)\)', m.group(1)):
xs.append(float(x)); ys.append(float(y))
CX, CY = (min(xs) + max(xs)) / 2 / 1000.0, -(min(ys) + max(ys)) / 2 / 1000.0
W = (max(xs) - min(xs)) / 1000.0
# ---------------- load + refdes from node ancestry
sc = trimesh.load(A.raw_glb)
parent = {c: p for p, c, _ in sc.graph.to_edgelist()}
def refdes_of(node):
cur = node
while True:
m = REFPAT.match(str(cur))
if m and m.group(1) in REFS:
return m.group(1)
if cur not in parent:
return None
cur = parent[cur]
SUBKIND = [('PCB', 'PCB'), ('copper', 'COPPER'), ('soldermask', 'MASK'), ('silkscreen', 'SILK')]
insts = []
z_top = 0.0
for node in sc.graph.nodes_geometry:
T, gname = sc.graph[node]
m = sc.geometry[gname].copy(); m.apply_transform(T)
kind = None
if (m.bounds[1][0] - m.bounds[0][0]) > W * 0.9:
base = gname.rsplit('_', 1)[0] if gname.rsplit('_', 1)[-1].isdigit() else gname
for key, tag in SUBKIND:
if key in base:
kind = tag
if kind == 'PCB':
z_top = float(m.bounds[1][2])
insts.append((node, gname, m, kind))
out = trimesh.Scene(); counts = collections.Counter(); idx = {}
stale = {}; unmatched = []
zmid = z_top / 2
for node, gname, m, kind in insts:
if kind: # substrate
blk, ref = 'substrate', kind
zc = float((m.bounds[0][2] + m.bounds[1][2]) / 2)
if kind in ('MASK', 'SILK'): # de-fight the zero-thickness sheets
d = (A.silk_um if kind == 'SILK' else A.mask_um) * 1e-6
m.apply_translation([0, 0, d if zc > zmid else -d])
if kind == 'PCB': # kill the BLEND-over-buried-copper fight
mat = getattr(m.visual, 'material', None)
if mat is not None:
if getattr(mat, 'baseColorFactor', None) is not None:
bcf = list(mat.baseColorFactor); bcf[3] = 255
mat.baseColorFactor = bcf
mat.alphaMode = 'OPAQUE'
else:
ref = refdes_of(node)
if ref is None:
unmatched.append(gname); continue
blk = section(ref)
c = (m.bounds[0] + m.bounds[1]) / 2
f = F[ref]
d_mm = float(np.hypot(c[0] - f['x'] / 1000.0, c[1] - -f['y'] / 1000.0)) * 1000
# big packages have sub-solids far from the origin; only flag if the WHOLE part drifts,
# so track the minimum distance per ref and decide after the loop
stale.setdefault(ref, []).append(d_mm)
# lift components clear of the raised silk — except parts that wrap the board edge
if m.bounds[0][2] < zmid:
pass # e.g. a USB shell / through-hole pins: leave alone
else:
m.apply_translation([0, 0, A.comp_um * 1e-6])
m.apply_translation([-CX, -CY, 0.0])
name = f"{blk}|{ref}|{counts[(blk, ref)]}"; counts[(blk, ref)] += 1
out.add_geometry(m, node_name=name, geom_name=name)
b = m.bounds; k = f"{blk}|{ref}"
e = idx.setdefault(k, {'blk': blk, 'ref': ref, 'lo': b[0].tolist(), 'hi': b[1].tolist(), 'n': 0})
e['lo'] = np.minimum(e['lo'], b[0]).tolist(); e['hi'] = np.maximum(e['hi'], b[1]).tolist()
e['n'] += 1
data = out.export(file_type='glb'); open(A.out_glb, 'wb').write(data)
r = trimesh.load(A.out_glb)
bad = [n for n in r.graph.nodes_geometry if not np.allclose(r.graph[n][0], np.eye(4), atol=1e-9)]
assert not bad, f'bake failed - nodes still carry transforms: {bad[:5]}'
# ---------------- coverage + staleness
stale_refs = {ref: round(min(ds), 2) for ref, ds in stale.items() if min(ds) > A.stale_mm}
tagged = {v['ref'] for v in idx.values() if v['blk'] != 'substrate'}
missing_real, bodiless = [], []
for f in fps:
if f['ref'] in tagged:
continue
(bodiless if any(k in f['fp'] for k in BODILESS_FP) else missing_real).append(
dict(ref=f['ref'], val=f['val'], fp=f['fp']))
report = dict(solids=sum(1 for i in insts), refs_tagged=len(tagged),
missing_real=missing_real, bodiless_ok=bodiless,
stale_geometry=stale_refs, unmatched_solids=unmatched,
dnp=[f['ref'] for f in fps if f['dnp']],
sections=dict(collections.Counter(section(f['ref']) for f in fps)))
print(f"baked {len(insts)} solids -> {A.out_glb} ({len(data)/1e6:.1f} MB); identity verified")
print(f"tagged {len(tagged)} refs | REAL parts with NO body ({len(missing_real)}): "
f"{[m['ref'] for m in missing_real]}")
print(f"STALE geometry (moved after STEP export): {stale_refs}")
print(f"bodiless-ok {len(bodiless)} | unmatched {unmatched} | DNP {report['dnp']}")
if A.report:
json.dump(report, open(A.report, 'w'), indent=1); print('report ->', A.report)
if A.index:
FI = {f['ref']: dict(ref=f['ref'], val=f['val'], fp=f['fp'], layer=f['layer'], rot=f['rot'],
x=f['x'] / 1000.0 - CX, y=-f['y'] / 1000.0 - CY,
blk=section(f['ref']), sheet=f.get('sheet'), dnp=f['dnp'],
stale_mm=stale_refs.get(f['ref']),
has3d=(f"{section(f['ref'])}|{f['ref']}") in idx)
for f in fps}
json.dump({'groups': idx, 'fps': FI, 'centre_mm': [CX * 1000, -CY * 1000],
'coverage': report},
open(A.index, 'w'), indent=0)
print('index ->', A.index)