molecule
Steven's Nameplate: three revisions
Public Made by Adomby adom
A USB-C desk nameplate for Steven Moss (Astera Institute), built three ways and published as open experiments: a 3x5 LED board, a 5x7 LED board, and a laser-ablated silkscreen plate. Source, fab files, negative results, reproduce steps.
main
John Lauer
Rev 3: the finished plate as a 3D model (name line + Astera logo), now the page's 3D Model
d81a79a
12d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
#!/usr/bin/env python3
"""Put a person's etched nameplate artwork onto the Nametag Blank Large LED GLB.
Draws the artwork (full name on line 1, the organisation logo on line 2) into a transparent
texture the size of the board outline, then adds one textured quad a few micrometres above the
soldermask. Everything else in the GLB (Draco meshes, materials, anchoring) is left untouched.
usage: make-plate.py <blank.glb> <out.glb> <out-art.png> "<Full Name>" <logo.png>
"""
import sys, json, struct, io
from PIL import Image, ImageDraw, ImageFont
blank, out_glb, out_png, name, logo_path = sys.argv[1:6]
# Board outline in the blank's frame (metres, MP1 at the origin, y towards the LED edge).
X0, Y0, X1, Y1 = -0.003, -0.003, 0.163, 0.051
Z_TOP = 0.0083475 + 0.00005 # soldermask node z + its mesh max z
PX_PER_MM = 24
W_MM, H_MM = (X1 - X0) * 1000, (Y1 - Y0) * 1000
W, H = round(W_MM * PX_PER_MM), round(H_MM * PX_PER_MM)
INK = (232, 235, 233, 255) # ablated letters read as bright, near-white
def mm(v): return v * PX_PER_MM
def to_px(x_mm, y_mm): # board mm (y up) -> canvas px (y down), outline-relative
return mm(x_mm - X0 * 1000), mm(Y1 * 1000 - y_mm)
img = Image.new('RGBA', (W, H), (0, 0, 0, 0)); d = ImageDraw.Draw(img)
font_path = '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf'
# Line 1: the full name, 118 mm wide, centred at (80, 27.5) mm (measured from the finished plate).
target_w = mm(118)
size = 200
while True:
f = ImageFont.truetype(font_path, size)
l, t, r, b = d.textbbox((0, 0), name, font=f)
if r - l >= target_w or size > 900: break
size += 4
cap_h = d.textbbox((0, 0), 'H', font=f); cap_mm = (cap_h[3] - cap_h[1]) / PX_PER_MM
if cap_mm > 13: # a short name must not grow taller than the plate's name line
size = int(size * 13 / cap_mm); f = ImageFont.truetype(font_path, size)
l, t, r, b = d.textbbox((0, 0), name, font=f)
cx, cy = to_px(80, 27.5)
d.text((cx - (r - l) / 2 - l, cy - (b - t) / 2 - t), name, font=f, fill=INK)
# Line 2: the organisation logo, recoloured to the ink, 34 mm wide, centred at (80, 10.5) mm.
logo = Image.open(logo_path).convert('RGBA')
lw = round(mm(34)); lh = round(logo.height * lw / logo.width)
logo = logo.resize((lw, lh), Image.LANCZOS)
ink = Image.new('RGBA', logo.size, INK); ink.putalpha(logo.getchannel('A'))
lx, ly = to_px(80, 10.5)
img.alpha_composite(ink, (round(lx - lw / 2), round(ly - lh / 2)))
img.save(out_png)
# ---- GLB surgery: append one textured quad ------------------------------------------------
raw = open(blank, 'rb').read()
jl = struct.unpack('<I', raw[12:16])[0]
g = json.loads(raw[20:20 + jl])
bl = struct.unpack('<I', raw[20 + jl:24 + jl])[0]
binc = bytearray(raw[28 + jl:28 + jl + bl])
def add_view(data, target=None):
while len(binc) % 4: binc.append(0)
off = len(binc); binc.extend(data)
v = {'buffer': 0, 'byteOffset': off, 'byteLength': len(data)}
if target: v['target'] = target
g['bufferViews'].append(v); return len(g['bufferViews']) - 1
def add_acc(view, ctype, count, typ, mn=None, mx=None):
a = {'bufferView': view, 'componentType': ctype, 'count': count, 'type': typ}
if mn is not None: a['min'], a['max'] = mn, mx
g['accessors'].append(a); return len(g['accessors']) - 1
z = Z_TOP + 0.00001
pos = [X0, Y0, z, X1, Y0, z, X1, Y1, z, X0, Y1, z]
nrm = [0, 0, 1] * 4
uv = [0, 1, 1, 1, 1, 0, 0, 0]
idx = [0, 1, 2, 0, 2, 3]
pa = add_acc(add_view(struct.pack('<12f', *pos), 34962), 5126, 4, 'VEC3', [X0, Y0, z], [X1, Y1, z])
na = add_acc(add_view(struct.pack('<12f', *nrm), 34962), 5126, 4, 'VEC3')
ua = add_acc(add_view(struct.pack('<8f', *uv), 34962), 5126, 4, 'VEC2')
ia = add_acc(add_view(struct.pack('<6H', *idx), 34963), 5123, 6, 'SCALAR')
buf = io.BytesIO(); img.save(buf, 'PNG')
iv = add_view(buf.getvalue())
g.setdefault('images', []).append({'bufferView': iv, 'mimeType': 'image/png', 'name': 'etched-artwork'})
g.setdefault('samplers', []).append({'magFilter': 9729, 'minFilter': 9987, 'wrapS': 33071, 'wrapT': 33071})
g.setdefault('textures', []).append({'source': len(g['images']) - 1, 'sampler': len(g['samplers']) - 1})
t = len(g['textures']) - 1
g['materials'].append({'name': 'etched-artwork', 'alphaMode': 'BLEND', 'doubleSided': False,
'pbrMetallicRoughness': {'baseColorTexture': {'index': t}, 'metallicFactor': 0.0, 'roughnessFactor': 0.85},
'emissiveTexture': {'index': t}, 'emissiveFactor': [0.35, 0.35, 0.35]})
g['meshes'].append({'name': 'Adom.LaserEtch.Name', 'primitives': [{'attributes': {'POSITION': pa, 'NORMAL': na, 'TEXCOORD_0': ua},
'indices': ia, 'material': len(g['materials']) - 1}]})
g['nodes'].append({'name': 'Adom.LaserEtch.Name', 'mesh': len(g['meshes']) - 1,
'extras': {'tooltip': f'Laser-ablated artwork\n{name}', 'adomLayer': {'key': 'etch', 'label': 'Etched name', 'group': 'Artwork'}}})
g['scenes'][g.get('scene', 0)]['nodes'].append(len(g['nodes']) - 1)
while len(binc) % 4: binc.append(0)
g['buffers'][0]['byteLength'] = len(binc)
js = json.dumps(g, separators=(',', ':')).encode()
while len(js) % 4: js += b' '
out = struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(binc)) + struct.pack('<II', len(js), 0x4E4F534A) + js + struct.pack('<II', len(binc), 0x004E4942) + bytes(binc)
open(out_glb, 'wb').write(out)
print(f'{out_glb}: {len(out)/1e6:.2f} MB, font {size}px, cap {cap_mm:.1f} mm')