master
John Lauer MPN laser etch: etched STEP is the default, plain kept as -unetched.step; hero GLB with footprint and silkscreen; hero… b2b8af3 21d ago
#!/usr/bin/env python3
"""Swap the part mesh in a page GLB (plain part GLB or the composite board scene) for the step2glb conversion
of the ETCHED STEP. Everything else (board, pads, solder, silk, node names and order, other transforms) is
copied unchanged. The part node keeps its name and transform; its mesh becomes the etched body grouped per
material, and the flat MPN mark becomes a child node Adom.LaserEtch.MPN.

--zscale S: scale the part node in Z so the body is datasheet-true (used when the vendor model is thinner
than the part); the mark child gets the inverse Z scale so its glyphs keep their true shape.
Materials: a new material per step2glb colour; when an old part material has (almost) the same base colour
its PBR settings (metallic/roughness/alpha mode) are reused, otherwise metallic 0 / roughness 0.5.
Usage: swap_part.py IN.glb S2G.glb OUT.glb --mpn MPN [--zscale 1]"""
import sys, os, json, argparse, hashlib
import numpy as np
sys.path.insert(0, '/home/adom/project/page-work/led-pass/tools'); import gltfio
ap = argparse.ArgumentParser(); ap.add_argument('src'); ap.add_argument('s2g'); ap.add_argument('out'); ap.add_argument('--mpn', required=True)
ap.add_argument('--zscale', type=float, default=1.0); a = ap.parse_args()
d, b = gltfio.load(a.src); arrays = gltfio.explode(d, b)
named = [i for i, n in enumerate(d['nodes']) if n.get('name') == a.mpn and 'mesh' in n]
meshn = [i for i, n in enumerate(d['nodes']) if 'mesh' in n]
ni = named[0] if named else (meshn[0] if len(meshn) == 1 else None); assert ni is not None, 'part node not found'
node = d['nodes'][ni]; mold = node['mesh']
old_mats = {p.get('material') for p in d['meshes'][mold]['primitives']}
old_tris = sum(len(arrays[p['indices']][0]) // 3 for p in d['meshes'][mold]['primitives'] if 'indices' in p)
old_P = np.concatenate([arrays[p['attributes']['POSITION']][0] for p in d['meshes'][mold]['primitives']])
# part-local frame of the node (its world matrix, parents included)
parent = {c: i for i, n in enumerate(d['nodes']) for c in n.get('children', [])}
W = np.eye(4); k = ni
while k is not None: W = gltfio.node_matrix(d['nodes'][k]) @ W; k = parent.get(k)
Winv = np.linalg.inv(W)
s, sb = gltfio.load(a.s2g)
groups = {}; mark = None
for mi, P, N, I in gltfio.flatten(s, sb):
    c = tuple(round(v, 4) for v in s['materials'][mi]['pbrMetallicRoughness']['baseColorFactor'][:3])
    P = P @ Winv[:3, :3].T + Winv[:3, 3]
    is_mark = any(max(abs(x - y) for x, y in zip(c, e)) < 1.5e-3 for e in gltfio.ETCH_RGB.values())
    g = groups.setdefault(('mark' if is_mark else c), [[], [], [], 0, c])
    g[0].append(P); g[1].append(N); g[2].append(I + g[3]); g[3] += len(P)
def pbr_like(c):
    for om in old_mats:
        if om is None: continue
        m = d['materials'][om]; oc = m.get('pbrMetallicRoughness', {}).get('baseColorFactor', [9, 9, 9])[:3]
        if max(abs(x - y) for x, y in zip(c, oc)) < 0.02:
            q = {k: v for k, v in m.get('pbrMetallicRoughness', {}).items() if k != 'baseColorFactor'}; return q, m.get('alphaMode')
    return {'metallicFactor': 0, 'roughnessFactor': 0.5}, None
prims = []; new_tris = 0
for key, (Ps, Ns, Is, _, c) in groups.items():
    if key == 'mark': continue
    tag = f'part_{len(prims)}'; P = np.concatenate(Ps); I = np.concatenate(Is)
    arrays[tag + '_POSITION'] = (P.astype(np.float32), 34962); arrays[tag + '_IDX'] = (I, 34963)
    attrs = {'POSITION': tag + '_POSITION'}
    if all(n is not None for n in Ns): arrays[tag + '_NORMAL'] = (np.concatenate(Ns).astype(np.float32), 34962); attrs['NORMAL'] = tag + '_NORMAL'
    q, am = pbr_like(c); m = {'name': f'part_{"%.3f_%.3f_%.3f" % c}', 'pbrMetallicRoughness': dict(baseColorFactor=list(c) + [1], **q)}
    if am: m['alphaMode'] = am
    d['materials'].append(m); prims.append({'attributes': attrs, 'indices': tag + '_IDX', 'material': len(d['materials']) - 1, 'mode': 4})
    new_tris += len(I) // 3
d['meshes'][mold] = {'name': d['meshes'][mold].get('name', a.mpn), 'primitives': prims}
body = np.concatenate([np.concatenate(g[0]) for k_, g in groups.items() if k_ != 'mark'])
old_sz = node.get('scale', [1, 1, 1])[2]
if a.zscale != 1.0: node['scale'] = [node.get('scale', [1, 1, 1])[0], node.get('scale', [1, 1, 1])[1], old_sz * a.zscale]
etch = None
if 'mark' in groups:
    Ps, Ns, Is, _, c = groups['mark']; P = np.concatenate(Ps); I = np.concatenate(Is); zc = float((P[:, 2].min() + P[:, 2].max()) / 2)
    arrays['mark_POSITION'] = ((P - [0, 0, zc]).astype(np.float32), 34962); arrays['mark_IDX'] = (I, 34963); attrs = {'POSITION': 'mark_POSITION'}
    if all(n is not None for n in Ns): arrays['mark_NORMAL'] = (np.concatenate(Ns).astype(np.float32), 34962); attrs['NORMAL'] = 'mark_NORMAL'
    d['materials'].append({'name': 'laser_etch', 'pbrMetallicRoughness': {'baseColorFactor': list(c) + [1], 'metallicFactor': 0, 'roughnessFactor': 0.6}, 'doubleSided': True})
    d['meshes'].append({'name': 'laser_etch', 'primitives': [{'attributes': attrs, 'indices': 'mark_IDX', 'material': len(d['materials']) - 1, 'mode': 4}]})
    sz = node.get('scale', [1, 1, 1])[2]
    d['nodes'].append({'name': 'Adom.LaserEtch.MPN', 'mesh': len(d['meshes']) - 1, 'translation': [0, 0, zc], 'scale': [1, 1, 1 / sz],
                       'extras': {'adomRole': 'laser_etch', 'virtualMark': True, 'text': a.mpn, 'face': 'top',
                                  '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, rgb_linear=list(c), z_mm=[round(float(P[:, 2].min()) * 1000, 4), round(float(P[:, 2].max()) * 1000, 4)])
d.setdefault('asset', {})['generator'] = (d['asset'].get('generator', '') + ' + adom bp-etch swap_part.py (etched part from step2glb)').strip(' +')
n = gltfio.save(a.out, d, arrays)
sz = node.get('scale', [1, 1, 1])[2]
print(json.dumps(dict(out=os.path.basename(a.out), bytes=n, sha256=hashlib.sha256(open(a.out, 'rb').read()).hexdigest(), part_node=node.get('name'),
                      old_part_tris=old_tris, new_part_tris=new_tris, old_part_bbox_mm=[(old_P.min(0) * 1000).round(4).tolist(), (old_P.max(0) * 1000).round(4).tolist()],
                      new_part_bbox_mm_local=[(body.min(0) * 1000).round(4).tolist(), (body.max(0) * 1000).round(4).tolist()], node_scale_z=sz,
                      rendered_top_mm=round(float(body[:, 2].max()) * sz * 1000, 4), laser_etch=etch)))