main
John Lauer Add KiCad symbol and footprint, viewers, provenance, README, models.json and verification log 354b2dd 11h ago
#!/usr/bin/env python3
"""GLB stats + preview render (numpy/PIL, flat shading), reading quantized accessors correctly.
usage: glbtool.py stats a.glb [...]      -> JSON per file
       glbtool.py render a.glb out.png [view=iso|top] [size=400]"""
import sys, json, hashlib, math, struct
import numpy as np
from pygltflib import GLTF2
from PIL import Image, ImageDraw
CT = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16, 5125: np.uint32, 5126: np.float32}
NC = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
def acc(g, blob, i):
    a = g.accessors[i]; bv = g.bufferViews[a.bufferView]; dt = np.dtype(CT[a.componentType]); n = NC[a.type]
    off = (bv.byteOffset or 0) + (a.byteOffset or 0); stride = bv.byteStride or dt.itemsize * n
    raw = np.frombuffer(blob, dtype=np.uint8, count=stride * (a.count - 1) + dt.itemsize * n, offset=off)
    arr = np.lib.stride_tricks.as_strided(raw, shape=(a.count, n * dt.itemsize), strides=(stride, 1)).copy().view(dt).reshape(a.count, n).astype(np.float64)
    if a.normalized:
        arr = {np.int8: lambda x: np.maximum(x / 127, -1), np.uint8: lambda x: x / 255, np.int16: lambda x: np.maximum(x / 32767, -1), np.uint16: lambda x: x / 65535}[dt.type](arr)
    return arr
def node_mat(n):
    if n.matrix: return np.array(n.matrix).reshape(4, 4).T
    M = np.eye(4)
    if n.scale: M = np.diag(list(n.scale) + [1]) @ M
    if n.rotation:
        x, y, z, w = n.rotation
        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)]])
        R4 = np.eye(4); R4[:3, :3] = R; M = R4 @ M
    if n.translation:
        T = np.eye(4); T[:3, 3] = n.translation; M = T @ M
    return M
def walk(g):
    out = []
    def rec(i, P, path):
        n = g.nodes[i]; M = P @ node_mat(n); p = path + [n.name or str(i)]
        if n.mesh is not None: out.append((n, M, p))
        for c in n.children or []: rec(c, M, p)
    for r in g.scenes[g.scene or 0].nodes: rec(r, np.eye(4), [])
    return out
def load(f):
    g = GLTF2().load(f); return g, g.binary_blob()
def tris(f):
    g, blob = load(f); res = []
    for n, M, path in walk(g):
        for pr in g.meshes[n.mesh].primitives:
            P = acc(g, blob, pr.attributes.POSITION); P = (M @ np.c_[P, np.ones(len(P))].T).T[:, :3]
            I = acc(g, blob, pr.indices).astype(int).reshape(-1, 3) if pr.indices is not None else np.arange(len(P)).reshape(-1, 3)
            mat = g.materials[pr.material] if pr.material is not None else None
            col = mat.pbrMetallicRoughness.baseColorFactor if mat and mat.pbrMetallicRoughness else [0.6, 0.6, 0.6, 1]
            res.append(dict(path="/".join(path), P=P, I=I, col=col))
    return g, res
def stats(f):
    g, res = tris(f); data = open(f, "rb").read()
    allP = np.vstack([r["P"] for r in res])
    dec = 0
    for a in g.accessors: dec += a.count * NC[a.type] * np.dtype(CT[a.componentType]).itemsize
    names = [n.name or "" for n in g.nodes]
    helpers = [n for n in names if any(k in n.lower() for k in ("pad outline", "silkscreen", "signal name", "reference artwork", "footprint pads", "courtyard"))]
    mark = [r for r in res if "LaserEtch" in r["path"]]
    chip = [r for r in res if "LaserEtch" not in r["path"]]
    mz = None
    if mark:
        mp = np.vstack([r["P"] for r in mark]); top = max(r["P"][:, 2].max() for r in chip)
        mz = dict(mark_z_min_mm=round(mp[:, 2].min() * 1000, 4), mark_z_max_mm=round(mp[:, 2].max() * 1000, 4), chip_top_mm=round(top * 1000, 4),
                  lift_um=round((mp[:, 2].min() - top) * 1e6, 2))
    return dict(file=f.split("/")[-1], sha256=hashlib.sha256(data).hexdigest(), bytes=len(data), triangles=int(sum(len(r["I"]) for r in res)),
                primitives=len(res), decodedBytes=int(dec), boundsMm=[round(v * 1000, 3) for v in (*allP.min(0), *allP.max(0))],
                helperNodes=helpers, mark=mz, extensions=g.extensionsUsed or [])
def render(f, out, view="iso", size=400):
    g, res = tris(f)
    allP = np.vstack([r["P"] for r in res]); c = (allP.min(0) + allP.max(0)) / 2
    if view == "top":
        R = np.eye(3)
    elif view.startswith("hero"):
        # pose: reading direction (-Y in the board frame) turned to +X, then a 3/4 view from the front, above
        P90 = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]])
        a = math.radians(28); b = math.radians(-48)
        Rz = np.array([[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]])
        Rx = np.array([[1, 0, 0], [0, math.cos(b), -math.sin(b)], [0, math.sin(b), math.cos(b)]])
        R = Rx @ Rz @ P90
    else:   # iso: rotate -45 about z then tilt 55 about x (camera from front-left-up, pin-1 side)
        a = math.radians(-35); b = math.radians(-55)
        Rz = np.array([[math.cos(a), -math.sin(a), 0], [math.sin(a), math.cos(a), 0], [0, 0, 1]])
        Rx = np.array([[1, 0, 0], [0, math.cos(b), -math.sin(b)], [0, math.sin(b), math.cos(b)]])
        R = Rx @ Rz
    light = np.array([0.3, -0.5, 0.8]); light /= np.linalg.norm(light)
    polys = []
    for r in res:
        Q = (r["P"] - c) @ R.T
        for t in r["I"]:
            v = Q[t]; nrm = np.cross(v[1] - v[0], v[2] - v[0]); ln = np.linalg.norm(nrm)
            if ln == 0: continue
            nrm /= ln
            if nrm[2] < 0: nrm = -nrm
            sh = 0.35 + 0.65 * max(0, float(nrm @ light))
            col = tuple(int(255 * min(1, (x ** (1 / 2.2)) * sh)) for x in r["col"][:3])
            polys.append(((1 if "LaserEtch" in r["path"] else 0), v[:, 2].mean(), v[:, :2], col))
    polys.sort(key=lambda p: (p[0], p[1]))
    polys = [(p[1], p[2], p[3]) for p in polys]
    Q = np.vstack([p[1] for p in polys]); ext = (Q.max(0) - Q.min(0)).max()
    s = size * 0.86 / ext; S = 3
    im = Image.new("RGBA", (size * S, size * S), (0, 0, 0, 0) if view.startswith("hero") else (36, 40, 46, 255)); d = ImageDraw.Draw(im)
    for _, v, col in polys:
        d.polygon([(size * S / 2 + x * s * S, size * S / 2 - y * s * S) for x, y in v], fill=col)
    im.resize((size, size), Image.LANCZOS).save(out)
if __name__ == "__main__":
    if sys.argv[1] == "stats":
        print(json.dumps([stats(f) for f in sys.argv[2:]], indent=1))
    else:
        render(sys.argv[2], sys.argv[3], *(sys.argv[4:5] or ["iso"]), *(map(int, sys.argv[5:6])))