main
John Lauer Add symbol, footprint, Fusion and Altium libraries, viewers, provenance, README ab389b2 5h ago
#!/usr/bin/env python3
"""Lossless primitive batching for a step2glb chip GLB (uncompressed float accessors), plus an optional graft of
the reviewed Adom.LaserEtch mark from another GLB in the same frame.
  * every mesh node keeps its name (body, pins, Adom.LaserEtch); its primitives are joined per material
    (positions, normals and indices concatenated; geometry unchanged)
  * --etch-from etched.glb copies that file's Adom.LaserEtch mesh node (with its material) into this scene
usage: glb_batch.py in.glb out.glb [--etch-from etched.glb]
prints JSON stats: triangles, primitives, bytes, nodes, bounds (mm)"""
import sys, json, struct
import numpy as np
from pygltflib import GLTF2, Buffer, BufferView, Accessor, Mesh, Primitive, Node, Material, PbrMetallicRoughness, Attributes

CT = {5126: np.float32, 5125: np.uint32, 5123: np.uint16, 5121: np.uint8}
NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}

def read(g, blob, ai):
    a = g.accessors[ai]; bv = g.bufferViews[a.bufferView]
    dt = CT[a.componentType]; n = NC[a.type]
    off = (bv.byteOffset or 0) + (a.byteOffset or 0)
    stride = bv.byteStride or np.dtype(dt).itemsize * n
    if stride == np.dtype(dt).itemsize * n:
        return np.frombuffer(blob, dt, a.count * n, off).reshape(a.count, n) if n > 1 else np.frombuffer(blob, dt, a.count, off)
    raw = np.frombuffer(blob, np.uint8, stride * (a.count - 1) + np.dtype(dt).itemsize * n, off)
    return np.stack([np.frombuffer(raw[i * stride:i * stride + np.dtype(dt).itemsize * n].tobytes(), dt) for i in range(a.count)])

def parts(path):
    g = GLTF2().load(path); blob = g.binary_blob()
    out = []
    for ni, n in enumerate(g.nodes):
        if n.mesh is None: continue
        by_mat = {}
        for p in g.meshes[n.mesh].primitives:
            P = read(g, blob, p.attributes.POSITION).astype(np.float32)
            N = read(g, blob, p.attributes.NORMAL).astype(np.float32) if p.attributes.NORMAL is not None else None
            I = read(g, blob, p.indices).astype(np.uint32) if p.indices is not None else np.arange(len(P), dtype=np.uint32)
            by_mat.setdefault(p.material, []).append((P, N, I))
        out.append((ni, n.name, by_mat))
    return g, out

def main():
    src, dst = sys.argv[1], sys.argv[2]
    etch = sys.argv[sys.argv.index("--etch-from") + 1] if "--etch-from" in sys.argv else None
    g, ps = parts(src)
    data = bytearray(); bviews = []; accs = []
    def add(arr, target, comp, typ, minmax=False):
        nonlocal data
        while len(data) % 4: data += b"\0"
        off = len(data); b = arr.tobytes(); data += b
        bviews.append(BufferView(buffer=0, byteOffset=off, byteLength=len(b), target=target))
        a = Accessor(bufferView=len(bviews) - 1, componentType=comp, count=len(arr), type=typ)
        if minmax: a.min = arr.min(0).tolist(); a.max = arr.max(0).tolist()
        accs.append(a); return len(accs) - 1
    def build_mesh(name, by_mat, matmap):
        prims = []
        for mi, lst in by_mat.items():
            Ps, Ns, Is, o = [], [], [], 0
            for P, N, I in lst:
                Ps.append(P); Ns.append(N if N is not None else np.zeros_like(P)); Is.append(I + o); o += len(P)
            P = np.concatenate(Ps); N = np.concatenate(Ns); I = np.concatenate(Is).astype(np.uint32)
            pa = add(P, 34962, 5126, "VEC3", True); na = add(N, 34962, 5126, "VEC3")
            ia = add(I, 34963, 5125, "SCALAR")
            prims.append(Primitive(attributes=Attributes(POSITION=pa, NORMAL=na), indices=ia, material=matmap(mi)))
        return Mesh(name=name, primitives=prims)
    meshes = []
    for ni, name, by_mat in ps:
        meshes.append(build_mesh(name, by_mat, lambda m: m)); g.nodes[ni].mesh = len(meshes) - 1
    if etch:
        ge, pe = parts(etch)
        hits = [x for x in pe if (x[1] or "").startswith("Adom.LaserEtch")]
        if not hits: sys.exit("ERROR: no Adom.LaserEtch node in " + etch)
        for ni, name, by_mat in hits:
            def mm(m):
                em = ge.materials[m]
                g.materials.append(Material(name=em.name, pbrMetallicRoughness=PbrMetallicRoughness(
                    baseColorFactor=list(em.pbrMetallicRoughness.baseColorFactor), metallicFactor=0.0, roughnessFactor=0.7)))
                return len(g.materials) - 1
            meshes.append(build_mesh(name, by_mat, mm))
            g.nodes.append(Node(name=name, mesh=len(meshes) - 1))
            g.nodes[g.scenes[g.scene or 0].nodes[0]].children.append(len(g.nodes) - 1)
    g.meshes = meshes; g.accessors = accs; g.bufferViews = bviews
    g.buffers = [Buffer(byteLength=len(data))]; g.set_binary_blob(bytes(data))
    g.save(dst)
    tris = sum(accs[p.indices].count // 3 for m in meshes for p in m.primitives)
    allp = np.concatenate([np.array(accs[p.attributes.POSITION].min + accs[p.attributes.POSITION].max).reshape(2, 3) for m in meshes for p in m.primitives])
    import os
    print(json.dumps({"out": dst, "triangles": tris, "primitives": sum(len(m.primitives) for m in meshes),
                      "bytes": os.path.getsize(dst), "nodes": [n.name for n in g.nodes if n.mesh is not None],
                      "boundsMm": [round(v * 1000, 4) for v in list(allp.min(0)) + list(allp.max(0))]}))
main()