by adom
0603 SMD Green LED · 3.2 V drop · 25 mA max
master
John Lauer
Datasheet-true STEP height (0.55 mm), side MPN mark re-applied; GLBs, hero rebuilt without Z stretch
2e9a25d
20d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
#!/usr/bin/env python3
"""Rebuild a page's composite LED scene GLB (<MPN>.glb / <MPN>.insertion.glb: fr4_board, pad_top, silk,
solder_top, the LED, LED_lit glow, fov_cone, LED_light) with the LED geometry and colours taken from the
coloured STEP's step2glb conversion. Everything else in the scene (board, pads, silk, solder, glow, FOV
cone, light, node names, node order, the LED node's name/children) is copied unchanged.
- LED geometry: every step2glb primitive is dequantized, the step2glb node transform is baked in (metres,
Z-up, same frame as the old mesh), and primitives are merged per material (like the old composite).
- LED node Z scale is set so the part keeps the old composite's rendered height
(new_scale = old_scale * old_mesh_top / new_mesh_top); a child light keeps its world position.
- Materials: the old slots keep their names/PBR settings; baseColor comes from the STEP colour
(linear, as step2glb emits it). The lens slot is renamed lens_<colour>, baseColor = STEP lens colour,
emissiveFactor = 0.35 x that colour (old slots used a similar dim self-glow), emissive strength kept,
roughness 0.35 + KHR_materials_clearcoat (1.0, roughness 0.08) so it reads as tinted epoxy.
Usage: rebuild_composite.py COMPOSITE.glb STEP2GLB.glb OUT.glb --mpn MPN --colour red --lens R,G,B(srgb)"""
import sys, json, argparse, hashlib
import numpy as np
sys.path.insert(0, __import__('os').path.dirname(__file__))
import gltfio
def lin(c): return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
KNOWN = {'body': (0.7776, 0.7697, 0.6261), 'terminal': (0.7087, 0.5040, 0.2104), 'cathode_mark': (0.0946, 0.4200, 0.1666)}
def main():
ap = argparse.ArgumentParser(); ap.add_argument('composite'); ap.add_argument('s2g'); ap.add_argument('out')
ap.add_argument('--mpn', required=True); ap.add_argument('--node-zscale', type=float, default=None, help='set the LED node Z scale explicitly (1 = the STEP is already datasheet-true)'); ap.add_argument('--colour', required=True); ap.add_argument('--lens', required=True)
a = ap.parse_args()
lens_lin = tuple(lin(float(v)) for v in a.lens.split(','))
d, binc = gltfio.load(a.composite); arrays = gltfio.explode(d, binc)
ni = next(i for i, n in enumerate(d['nodes']) if n.get('name') == a.mpn); node = d['nodes'][ni]
mold = node['mesh']; old_prims = d['meshes'][mold]['primitives']
old_top = max(arrays[p['attributes']['POSITION']][0][:, 2].max() for p in old_prims)
old_tris = sum(len(arrays[p['indices']][0]) // 3 for p in old_prims)
# role of each old material slot
slot = {}
for p in old_prims:
nm = d['materials'][p['material']]['name']
role = 'lens' if nm.startswith('lens') else {'mat_0': 'body', 'mat_1': 'terminal', 'mat_2': 'cathode_mark'}[nm]
slot[role] = p['material']
# new LED geometry from step2glb (any node tree: flattened with world matrices, grouped by material colour)
s, sb = gltfio.load(a.s2g)
G = gltfio.led_groups(s, sb, KNOWN, lens_lin)
prims = []
for role in ('body', 'terminal', 'cathode_mark', 'lens'):
P, N, I = G[role]; key = f'led_{role}'
arrays[key + '_POSITION'] = (P.astype(np.float32), 34962); arrays[key + '_NORMAL'] = (N.astype(np.float32), 34962); arrays[key + '_IDX'] = (I, 34963)
prims.append({'attributes': {'POSITION': key + '_POSITION', 'NORMAL': key + '_NORMAL'}, 'indices': key + '_IDX', 'material': slot[role], 'mode': 4})
groups = {r: [[G[r][0]], None, [G[r][2]]] for r in G}
d['meshes'][mold]['primitives'] = prims
new_top = float(max(G[r][0][:, 2].max() for r in G if r != 'laser_etch'))
new_tris = sum(len(G[r][2]) // 3 for r in G)
# materials
for role in ('body', 'terminal', 'cathode_mark'):
m = d['materials'][slot[role]]; m['pbrMetallicRoughness']['baseColorFactor'] = [round(v, 4) for v in KNOWN[role]] + [1]
m = d['materials'][slot['lens']]; old_lens = json.loads(json.dumps(m))
m['name'] = 'lens_' + a.colour; m['pbrMetallicRoughness']['baseColorFactor'] = [round(v, 4) for v in lens_lin] + [1]
m['emissiveFactor'] = [round(0.35 * v, 4) for v in lens_lin]
# tinted epoxy, not paint: smoother lens with a clear coat
m['pbrMetallicRoughness'].update(metallicFactor=0, roughnessFactor=0.35)
m.setdefault('extensions', {})['KHR_materials_clearcoat'] = {'clearcoatFactor': 1.0, 'clearcoatRoughnessFactor': 0.08}
if 'KHR_materials_clearcoat' not in d.setdefault('extensionsUsed', []): d['extensionsUsed'].append('KHR_materials_clearcoat')
# keep rendered height: node Z scale, child light world position
sc = node.get('scale', [1, 1, 1]); old_sz = sc[2]; new_sz = a.node_zscale if a.node_zscale is not None else old_sz * float(old_top) / new_top
node['scale'] = [sc[0], sc[1], new_sz]
lit = next((n for n in d['nodes'] if n.get('name') == 'LED_lit' and 'mesh' in n), None)
if lit is not None: # the flat emissive skin sits on the lens top: keep it there when the LED height changes
lz = max(arrays[p['attributes']['POSITION']][0][:, 2].max() for p in d['meshes'][lit['mesh']]['primitives'])
ls = lit.get('scale', [1, 1, 1]); lit['scale'] = [ls[0], ls[1], float((new_top * new_sz + 2e-6) / lz)] # 2 um above the lens top: no z-fighting
for ci in node.get('children', []):
c = d['nodes'][ci]
if 'translation' in c: c['translation'] = [c['translation'][0], c['translation'][1], c['translation'][2] * old_sz / new_sz]
etch = None
if 'laser_etch' in G: # side MPN mark: child of the LED node (rides the insertion animation) with the inverse Z scale,
P, N, I = G['laser_etch'] # so the glyphs keep their true height while the body is scaled to the datasheet height
zc = float((P[:, 2].min() + P[:, 2].max()) / 2); c = P.mean(0)
arrays['etch_POSITION'] = ((P - [0, 0, zc]).astype(np.float32), 34962); arrays['etch_NORMAL'] = (N.astype(np.float32), 34962); arrays['etch_IDX'] = (I, 34963)
rgb = d0 = None
d['materials'].append({'name': 'laser_etch', 'pbrMetallicRoughness': {'baseColorFactor': list(s['materials'][[mi for mi, *_ in gltfio.flatten(s, sb) if any(max(abs(x - y) for x, y in zip(s['materials'][mi]['pbrMetallicRoughness']['baseColorFactor'][:3], k)) < 3e-3 for k in gltfio.ETCH_RGB.values())][0]]['pbrMetallicRoughness']['baseColorFactor'][:3]) + [1], 'metallicFactor': 0, 'roughnessFactor': 0.6}, 'doubleSided': True})
d['meshes'].append({'name': 'laser_etch', 'primitives': [{'attributes': {'POSITION': 'etch_POSITION', 'NORMAL': 'etch_NORMAL'}, 'indices': 'etch_IDX', 'material': len(d['materials']) - 1, 'mode': 4}]})
d['nodes'].append({'name': 'Adom.LaserEtch.MPN', 'mesh': len(d['meshes']) - 1, 'translation': [0, 0, zc], 'scale': [1, 1, 1 / new_sz],
'extras': {'adomRole': 'laser_etch', 'virtualMark': True, 'text': a.mpn, 'face': 'side (-Y, lens band)', 'tooltip': f'Virtual laser-etched MPN ({a.mpn})\nidentification aid, not the manufacturer marking'}})
node.setdefault('children', []).append(len(d['nodes']) - 1)
etch = dict(tris=len(I) // 3, z_mm=[round(float(P[:, 2].min()) * 1000, 4), round(float(P[:, 2].max()) * 1000, 4)])
d.setdefault('asset', {})['generator'] = 'adom led-pass rebuild_composite.py (scene from ' + a.composite.split('/')[-1] + ', LED from step2glb)'
n = gltfio.save(a.out, d, arrays)
rep = dict(out=a.out.split('/')[-1], bytes=n, sha256=hashlib.sha256(open(a.out, 'rb').read()).hexdigest(),
led_node=a.mpn, old_led_tris=old_tris, new_led_tris=new_tris, old_mesh_top_m=float(old_top), new_mesh_top_m=new_top,
old_node_scale_z=old_sz, new_node_scale_z=new_sz, rendered_height_mm=round(new_top * new_sz * 1000, 4), laser_etch=etch,
lens_material={'name': m['name'], 'baseColor_linear': m['pbrMetallicRoughness']['baseColorFactor'], 'replaced': old_lens['name'], 'old_baseColor': old_lens['pbrMetallicRoughness']['baseColorFactor']})
print(json.dumps(rep))
if __name__ == '__main__': main()