component
RC0402FR-07102KL - YAGEO
Public Made by Adomby adom
Thick film chip resistor 102 kOhm +/-1 %, 1/16 W, 0402 (1005 metric)
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
"""Build an annotated hero GLB (adom-hero 1.4 layout) for a two-terminal chip page.
usage: hero.py <board-use.glb> <footprint.kicad_mod> <page.json> <glyphs.json> <out-hero.glb> [--png preview.png]
Written for this page set (an independent implementation of the published
adom-hero 1.4 rules on adom/component-hero; that page's generator was not run).
The physical model is copied unchanged; three nonphysical layers are added under
one root node "Adom reference artwork (nonphysical)" at z = -0.02 mm:
teal dashed pad outlines (alpha 0.5), silkscreen (alpha 0.3), pad labels (alpha 0.5).
glyphs.json is the pre-triangulated DejaVu Sans Mono table published with the
standard (pure data). Prints one OK/FAIL line and writes <out>.overlay.json.
"""
import sys, os, re, json, math, hashlib, struct
import numpy as np
import pygltflib
from pygltflib import GLTF2, Node, Mesh, Primitive, Attributes, Accessor, BufferView, Material, PbrMetallicRoughness
args = sys.argv[1:]
png = None
if "--png" in args:
i = args.index("--png"); png = args[i + 1]; del args[i:i + 2]
glb_in, fp_path, page_path, glyph_path, out = args
ZMM = -0.02
ROOT_NAME = "Adom reference artwork (nonphysical)"
N_PAD = "Footprint pads - teal dashed outlines - 50 percent opacity"
N_SILK = "Silkscreen reference lines - 30 percent opacity"
N_LAB = "Signal names - 50 percent opacity"
RGBA = {N_PAD: [0, 0.902, 0.863, 0.5], N_SILK: [0.92, 0.96, 1.0, 0.3], N_LAB: [0.92, 0.96, 1.0, 0.5]}
clamp = lambda v, a, b: max(a, min(b, v))
sha = lambda f: hashlib.sha256(open(f, "rb").read()).hexdigest()
# ---------------- footprint ----------------
fp = open(fp_path).read()
pads = []
for m in re.finditer(r'\(pad "([^"]+)" smd (roundrect|rect)\s*\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\)\s*\(size ([\d.]+) ([\d.]+)\)(.*?)\n\t\)', fp, re.S):
num, shape, x, y, rot, w, h, rest = m.groups()
rr = re.search(r"roundrect_rratio ([\d.]+)", rest)
pads.append({"number": num, "shape": shape, "center_mm": [float(x), float(y)], "size_mm": [float(w), float(h)],
"rotation_deg": float(rot or 0), "rratio": float(rr.group(1)) if rr else 0.0})
silk = []
for m in re.finditer(r'\(fp_line\s*\(start ([-\d.]+) ([-\d.]+)\)\s*\(end ([-\d.]+) ([-\d.]+)\)\s*\(stroke\s*\(width ([\d.]+)\).*?\(layer "F\.SilkS"\)', fp, re.S):
x0, y0, x1, y1, w = map(float, m.groups())
silk.append({"kind": "line", "start": [x0, y0], "end": [x1, y1], "width": w})
assert pads, "no pads parsed"
page = json.load(open(page_path))
pins = {p["number"]: p.get("name", "") for p in page["component"].get("pins", [])}
mpn = page["component"]["mpn"]
# ---------------- geometry helpers (mm, KiCad frame; converted at the end) ----------------
def rrect_path(cx, cy, w, h, r, step=0.005):
pts = []
hw, hh = w / 2, h / 2
corners = [(hw - r, hh - r, 0), (-hw + r, hh - r, 90), (-hw + r, -hh + r, 180), (hw - r, -hh + r, 270)]
for (ox, oy, a0) in corners:
if r > 0:
n = max(2, int(math.ceil((math.pi / 2 * r) / step)))
for i in range(n + 1):
a = math.radians(a0 + 90 * i / n)
pts.append((cx + ox + r * math.cos(a), cy + oy + r * math.sin(a)))
else:
sx = 1 if a0 in (0, 270) else -1; sy = 1 if a0 in (0, 90) else -1
pts.append((cx + sx * hw, cy + sy * hh))
# densify straight runs
out = []
for i in range(len(pts)):
a, b = np.array(pts[i]), np.array(pts[(i + 1) % len(pts)])
n = max(1, int(math.ceil(np.linalg.norm(b - a) / step)))
for k in range(n):
out.append(a + (b - a) * k / n)
return np.array(out)
def ribbon(poly, width, closed=False, limit=4.0):
"""Polyline -> (verts Nx2, tris) as one continuous mitred ribbon."""
P = np.asarray(poly, float); n = len(P)
hw = width / 2
V, T = [], []
for i in range(n):
if closed:
a, b = P[i - 1], P[(i + 1) % n]
d0 = P[i] - a; d1 = b - P[i]
else:
d0 = P[i] - P[i - 1] if i > 0 else P[1] - P[0]
d1 = P[i + 1] - P[i] if i < n - 1 else P[i] - P[i - 1]
d0 = d0 / (np.linalg.norm(d0) or 1); d1 = d1 / (np.linalg.norm(d1) or 1)
n0 = np.array([-d0[1], d0[0]]); n1 = np.array([-d1[1], d1[0]])
mvec = n0 + n1; ln = np.linalg.norm(mvec)
if ln < 1e-9:
mvec = n1; scale = 1.0
else:
mvec = mvec / ln; scale = 1.0 / max(np.dot(mvec, n1), 1.0 / limit)
V.append(P[i] + mvec * hw * scale); V.append(P[i] - mvec * hw * scale)
segs = n if closed else n - 1
for i in range(segs):
j = (i + 1) % n
a, b, c, d = 2 * i, 2 * i + 1, 2 * j, 2 * j + 1
T += [(a, b, c), (c, b, d)]
return np.array(V), T
def dashed(path, dash, gap, stroke):
P = np.vstack([path, path[:1]])
seg = np.linalg.norm(np.diff(P, axis=0), axis=1)
s = np.concatenate([[0], np.cumsum(seg)]); L = s[-1]
n = max(1, int(round(L / (dash + gap))))
period = L / n
def at(t):
t = t % L
i = min(np.searchsorted(s, t, side="right") - 1, len(seg) - 1)
f = (t - s[i]) / (seg[i] or 1)
return P[i] + (P[i + 1] - P[i]) * f
VV, TT = [], []
for k in range(n):
t0 = k * period; t1 = t0 + dash
pts = [at(t0)]
for i in range(len(s)):
for base in (0, L):
if t0 < s[i] + base < t1:
pts.append(P[i % len(path)])
pts.append(at(t1))
v, t = ribbon(np.array(pts), stroke)
off = sum(len(x) for x in VV)
VV.append(v); TT += [(a + off, b + off, c + off) for a, b, c in t]
return np.vstack(VV), TT, n
# ---------------- layer 1: pad outlines ----------------
xs = [p["center_mm"][0] + sgn * p["size_mm"][0] / 2 for p in pads for sgn in (-1, 1)]
ys = [p["center_mm"][1] + sgn * p["size_mm"][1] / 2 for p in pads for sgn in (-1, 1)]
pads_bbox = [min(xs), max(xs), min(ys), max(ys)]
span = max(1.0, pads_bbox[1] - pads_bbox[0], pads_bbox[3] - pads_bbox[2])
dash = clamp(0.025 * span, 0.12, 0.45); gap = 0.6 * dash; stroke = clamp(0.006 * span, 0.03, 0.09)
padV, padT = [], []
for p in pads:
w, h = p["size_mm"]; r = p["rratio"] * min(w, h) if p["shape"] == "roundrect" else 0.0
path = rrect_path(p["center_mm"][0], p["center_mm"][1], w, h, r)
perim = float(np.sum(np.linalg.norm(np.diff(np.vstack([path, path[:1]]), axis=0), axis=1)))
d = min(dash, max(perim / 6, 0.05)); st = min(stroke, max(perim / 25, 0.02))
v, t, nd = dashed(path, d, 0.6 * d, st)
off = sum(len(x) for x in padV); padV.append(v); padT += [(a + off, b + off, c + off) for a, b, c in t]
p["dashes"] = nd; p["perimeter_mm"] = round(perim, 4)
padV = np.vstack(padV)
# ---------------- layer 2: silkscreen ----------------
silkV, silkT = [], []
for it in silk:
a, b = np.array(it["start"]), np.array(it["end"])
v, t = ribbon(np.array([a, b]), it["width"])
off = sum(len(x) for x in silkV); silkV.append(v); silkT += [(i + off, j + off, k + off) for i, j, k in t]
silkV = np.vstack(silkV) if silkV else np.zeros((0, 2))
named = {p["number"]: pins.get(p["number"], "") for p in pads}
polar = [n for n, nm in named.items() if nm.upper() in ("K", "KA", "CATHODE", "+")]
pin1 = None
if len(pads) >= 3 or polar:
raise SystemExit("FAIL: this builder only covers unpolarised two-terminal parts")
# ---------------- layer 3: labels ----------------
G = json.load(open(glyph_path)); adv = G["advance"]; glyphs = G["glyphs"]
def text_mesh(txt, height):
V, T = [], []
capref = max(glyphs["1"]["v"][1::2]) # digit height in em
em = height / capref; x = 0.0
for ch in txt:
g = glyphs.get(ch) or glyphs["?"]
v = np.array(g["v"], float).reshape(-1, 2)
off = len(V)
V += [(x + vx * em, vy * em) for vx, vy in v]
tr = np.array(g["t"]).reshape(-1, 3)
for a, b, c in tr:
pa, pb, pc = v[a], v[b], v[c]
area = (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pc[0] - pa[0]) * (pb[1] - pa[1])
T.append((a + off, b + off, c + off) if area >= 0 else (a + off, c + off, b + off))
x += adv * em
return np.array(V), T, x, capref * em
labels = []
labV_up, labT, labV_dn = [], [], []
for p in pads:
nm = named[p["number"]]
txt = nm if nm not in ("", "~") else p["number"]
hgt = clamp(0.7 * min(p["size_mm"]), 0.12, 0.6); gapl = max(0.25 * hgt, 0.1)
v, t, wtxt, htxt = text_mesh(txt, hgt)
left = p["center_mm"][0] < 0
# KiCad frame: x right, y down. Text drawn in a y-up frame then mapped to KiCad by y -> -y at the end.
if left:
x0 = p["center_mm"][0] - p["size_mm"][0] / 2 - gapl - wtxt; align = "right"
else:
x0 = p["center_mm"][0] + p["size_mm"][0] / 2 + gapl; align = "left"
cy_up = -p["center_mm"][1]
vu = v + np.array([x0, cy_up - htxt / 2])
vd = vu.copy(); vd[:, 1] = 2 * cy_up - vd[:, 1]
off = sum(len(a) for a in labV_up)
labV_up.append(vu); labV_dn.append(vd); labT += [(a + off, b + off, c + off) for a, b, c in t]
labels.append({"pad": p["number"], "text": txt, "anchor_mm": [round(x0 if not left else x0 + wtxt, 4), p["center_mm"][1]],
"align": align, "orientation_deg": 0, "height_mm": round(hgt, 4), "fallback_pad_number": txt == p["number"]})
labV_up = np.vstack(labV_up); labV_dn = np.vstack(labV_dn)
def to_gltf(V2, kicad_frame=True):
V2 = np.asarray(V2, float)
y = -V2[:, 1] if kicad_frame else V2[:, 1]
return np.column_stack([V2[:, 0] / 1000.0, y / 1000.0, np.full(len(V2), ZMM / 1000.0)]).astype(np.float32)
# winding: up-facing = CCW seen from +Z in the glTF frame
def fix_up(V3, T):
out = []
for a, b, c in T:
pa, pb, pc = V3[a], V3[b], V3[c]
area = (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pc[0] - pa[0]) * (pb[1] - pa[1])
out.append((a, b, c) if area >= 0 else (a, c, b))
return out
padV3 = to_gltf(padV); silkV3 = to_gltf(silkV)
labU3 = to_gltf(labV_up, kicad_frame=False); labD3 = to_gltf(labV_dn, kicad_frame=False)
padT = fix_up(padV3, padT); silkT = fix_up(silkV3, silkT)
labTu = fix_up(labU3, labT)
labTd = [(a, c, b) for a, b, c in labTu] # mirrored copy faces down
# ---------------- alignment guard ----------------
g = GLTF2().load(glb_in)
blob = bytearray(g.binary_blob())
def acc_data(ai):
a = g.accessors[ai]; bv = g.bufferViews[a.bufferView]
comp = {5126: ("f", 4), 5122: ("h", 2), 5123: ("H", 2), 5120: ("b", 1), 5121: ("B", 1), 5125: ("I", 4)}[a.componentType]
ncomp = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[a.type]
stride = bv.byteStride or comp[1] * ncomp
base = (bv.byteOffset or 0) + (a.byteOffset or 0)
arr = np.array([struct.unpack_from("<" + comp[0] * ncomp, blob, base + i * stride) for i in range(a.count)], float)
if a.normalized:
arr = arr / {5122: 32767.0, 5120: 127.0, 5123: 65535.0, 5121: 255.0}.get(a.componentType, 1)
return arr
def node_mat(n):
if n.matrix: return np.array(n.matrix).reshape(4, 4).T
M = np.eye(4)
s = n.scale or [1, 1, 1]; t = n.translation or [0, 0, 0]; q = n.rotation or [0, 0, 0, 1]
x, y, z, w = q
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[:3, :3] = R * np.array(s); M[:3, 3] = t
return M
allpts = []
def walk(ni, M):
n = g.nodes[ni]; M2 = M @ node_mat(n)
if n.mesh is not None:
for p in g.meshes[n.mesh].primitives:
P = acc_data(p.attributes.POSITION)
allpts.append((M2 @ np.column_stack([P, np.ones(len(P))]).T).T[:, :3])
for c in n.children or []: walk(c, M2)
for r in g.scenes[g.scene or 0].nodes: walk(r, np.eye(4))
W = np.vstack(allpts) * 1000.0 # mm, glTF frame
zmin = W[:, 2].min()
low = W[W[:, 2] < zmin + 0.01]
cb = [low[:, 0].min(), low[:, 0].max(), -low[:, 1].max(), -low[:, 1].min()] # KiCad y
cc = np.array([(cb[0] + cb[1]) / 2, (cb[2] + cb[3]) / 2]); pc = np.array([(pads_bbox[0] + pads_bbox[1]) / 2, (pads_bbox[2] + pads_bbox[3]) / 2])
offset = float(np.linalg.norm(cc - pc))
tol = max(0.25, 0.08 * max(pads_bbox[1] - pads_bbox[0], pads_bbox[3] - pads_bbox[2]))
cw, ch = cb[1] - cb[0], cb[3] - cb[2]; pw, ph = pads_bbox[1] - pads_bbox[0], pads_bbox[3] - pads_bbox[2]
flipped = (cw > 1.25 * ch and ph > 1.25 * pw) or (ch > 1.25 * cw and pw > 1.25 * ph)
align_ok = offset <= tol and not flipped and abs(zmin) < 0.005
# ---------------- write the hero ----------------
def add_acc(arr, target, kind):
global blob
while len(blob) % 4: blob.append(0)
data = arr.tobytes(); off = len(blob); blob += data
g.bufferViews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(data), target=target))
if kind == "pos":
a = Accessor(bufferView=len(g.bufferViews) - 1, componentType=5126, count=len(arr), type="VEC3",
min=arr.min(axis=0).tolist(), max=arr.max(axis=0).tolist())
else:
a = Accessor(bufferView=len(g.bufferViews) - 1, componentType=5125, count=arr.size, type="SCALAR")
g.accessors.append(a); return len(g.accessors) - 1
def add_mat(name, double):
g.materials.append(Material(name=name, alphaMode="BLEND", doubleSided=double,
pbrMetallicRoughness=PbrMetallicRoughness(baseColorFactor=RGBA[name], metallicFactor=0.0, roughnessFactor=1.0),
extensions={"KHR_materials_unlit": {}}))
return len(g.materials) - 1
def prim(V3, T, mat):
return Primitive(attributes=Attributes(POSITION=add_acc(V3, 34962, "pos")),
indices=add_acc(np.array(T, np.uint32).ravel(), 34963, "idx"), material=mat, mode=4)
children = []
for name, V3, T, dbl in ((N_PAD, padV3, padT, True), (N_SILK, silkV3, silkT, True)):
if len(T) == 0: continue
mi = add_mat(name, dbl)
g.meshes.append(Mesh(name=name, primitives=[prim(V3, T, mi)]))
g.nodes.append(Node(name=name, mesh=len(g.meshes) - 1)); children.append(len(g.nodes) - 1)
mi = add_mat(N_LAB, False)
g.meshes.append(Mesh(name=N_LAB, primitives=[prim(labU3, labTu, mi), prim(labD3, labTd, mi)]))
g.nodes.append(Node(name=N_LAB, mesh=len(g.meshes) - 1)); children.append(len(g.nodes) - 1)
g.nodes.append(Node(name=ROOT_NAME, children=children)); g.scenes[g.scene or 0].nodes.append(len(g.nodes) - 1)
for ext in ("KHR_materials_unlit",):
if ext not in (g.extensionsUsed or []): g.extensionsUsed = (g.extensionsUsed or []) + [ext]
meta = {
"spec": "adom-hero 1.4", "mpn": mpn, "kind": "component + footprint reference art", "board_use": False,
"plane_z_mm": ZMM, "units": "metre", "upAxis": "Z",
"layers": {
"pad_outlines": {"material": N_PAD, "rgba": RGBA[N_PAD], "pads": len(pads), "span_mm": round(span, 4),
"dash_mm": round(dash, 4), "gap_mm": round(gap, 4), "pad_stroke_mm": round(stroke, 4)},
"silkscreen": {"material": N_SILK, "rgba": RGBA[N_SILK], "footprint_items": len(silk), "pin1": pin1,
"pin1_note": "unpolarised two-terminal part: no orientation, so no pin-1 mark"},
"signal_names": {"material": N_LAB, "rgba": RGBA[N_LAB], "font": G["font"], "labels": len(labels),
"fallback_to_pad_number": sum(1 for l in labels if l["fallback_pad_number"]),
"note": "the pins have no names (~); each label is its pad number, so the two labels stay unique"}},
"align": {"ok": bool(align_ok), "centre_offset_mm": round(offset, 4), "tolerance_mm": round(tol, 4), "orientation_flipped": bool(flipped),
"contacts_bbox_mm": [round(v, 4) for v in cb], "pads_bbox_mm": [round(v, 4) for v in pads_bbox], "seating_z_mm": round(float(zmin), 4)},
"sources": {"glb": os.path.basename(glb_in), "glb_sha256": sha(glb_in), "footprint": os.path.basename(fp_path),
"footprint_sha256": sha(fp_path), "pins": "page.json component.pins", "glyphs": "hero_glyphs.json from adom/component-hero (data)",
"glyphs_sha256": sha(glyph_path)},
"generator": "provenance/tools/hero.py (independent implementation of adom-hero 1.4)"}
g.asset.extras = dict(g.asset.extras or {}); g.asset.extras["adomHero"] = meta
g.buffers[0].byteLength = len(blob)
g.set_binary_blob(bytes(blob))
g.save_binary(out)
side = dict(meta); side["pads"] = [{k: p[k] for k in ("number", "shape", "center_mm", "size_mm", "rotation_deg", "rratio", "dashes")} for p in pads]
side["labels"] = labels; side["silk_items"] = silk
json.dump(side, open(re.sub(r"\.glb$", ".overlay.json", out), "w"), indent=1)
if png:
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
h2 = GLTF2().load(out); blob = bytearray(h2.binary_blob()); g = h2
fig = plt.figure(figsize=(12, 5.5), facecolor="#1b1f24")
for k, (elev, azim, title) in enumerate(((90, -90, "TOP"), (35, -60, "OBLIQUE"))):
ax = fig.add_subplot(1, 2, k + 1, projection="3d"); ax.set_facecolor("#1b1f24")
def draw(ni, M):
n = g.nodes[ni]; M2 = M @ node_mat(n)
if n.mesh is not None:
for p in g.meshes[n.mesh].primitives:
P = acc_data(p.attributes.POSITION); P = (M2 @ np.column_stack([P, np.ones(len(P))]).T).T[:, :3] * 1000
I = acc_data(p.indices).astype(int).reshape(-1, 3)
col = g.materials[p.material].pbrMetallicRoughness.baseColorFactor
ax.add_collection3d(Poly3DCollection(P[I], facecolor=col[:3], alpha=col[3], linewidth=0))
for c in n.children or []: draw(c, M2)
for r in g.scenes[0].nodes: draw(r, np.eye(4))
ax.set_xlim(-1.2, 1.2); ax.set_ylim(-1.2, 1.2); ax.set_zlim(-1.2, 1.2)
ax.view_init(elev=elev, azim=azim); ax.set_axis_off(); ax.set_title(title, color="w")
fig.suptitle(f"{mpn} hero (adom-hero 1.4 layers)", color="w")
plt.savefig(png, dpi=110, facecolor=fig.get_facecolor()); plt.close(fig)
print(f"{'OK' if align_ok else 'FAIL'} {os.path.basename(out)}: pads {len(pads)}, silk items {len(silk)}, pin1 none (unpolarised), "
f"labels {len(labels)}; align {'OK' if align_ok else 'FAIL'} (offset {offset:.3f} mm)")
sys.exit(0 if align_ok else 3)