master
John Lauer Fix: keep the insertion animation in the rebuilt page GLBs 7932b17 20d ago
"""Minimal GLB reader/writer (numpy only): read accessors to float/int arrays (dequantizing normalized
types, honouring byteStride), and re-pack a document whose primitives carry numpy arrays."""
import json, struct
import numpy as np

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 load(path):
    b = open(path, 'rb').read(); assert b[:4] == b'glTF'
    jn = struct.unpack_from('<I', b, 12)[0]; d = json.loads(b[20:20 + jn])
    off = 20 + jn; binc = b''
    if off < len(b):
        bl = struct.unpack_from('<I', b, off)[0]; binc = b[off + 8: off + 8 + bl]
    return d, binc

def accessor(d, binc, i):
    a = d['accessors'][i]; bv = d['bufferViews'][a['bufferView']]; dt = np.dtype(CT[a['componentType']])
    n, k = a['count'], NC[a['type']]; start = bv.get('byteOffset', 0) + a.get('byteOffset', 0)
    stride = bv.get('byteStride', dt.itemsize * k)
    raw = np.frombuffer(binc, dtype=np.uint8, count=stride * (n - 1) + dt.itemsize * k, offset=start)
    arr = np.lib.stride_tricks.as_strided(raw, shape=(n, dt.itemsize * k), strides=(stride, 1)).copy()
    arr = arr.view(dt).reshape(n, k)
    if a.get('normalized'):
        arr = np.maximum(arr.astype(np.float64) / np.iinfo(dt).max, -1.0)
    return arr if k > 1 else arr[:, 0]

def node_matrix(n):
    if 'matrix' in n: return np.array(n['matrix'], dtype=np.float64).reshape(4, 4).T
    t = np.array(n.get('translation', [0, 0, 0])); s = np.array(n.get('scale', [1, 1, 1])); x, y, z, w = n.get('rotation', [0, 0, 0, 1])
    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)]])
    M = np.eye(4); M[:3, :3] = R * s; M[:3, 3] = t; return M

def save(path, d, arrays):
    """d: glTF json WITHOUT accessors/bufferViews/buffers; primitives reference keys into `arrays`
    (dict key -> (np.ndarray, target)); keys are replaced with accessor indices."""
    blob = bytearray(); d['accessors'] = []; d['bufferViews'] = []; idx = {}
    def add(key, pos=False):
        if key in idx: return idx[key]
        arr, target = arrays[key]
        if arr.dtype.kind == 'f': arr = arr.astype(np.float32); ct = 5126
        else:
            arr = arr.astype(np.uint32 if arr.max() > 65535 else np.uint16); ct = 5125 if arr.dtype == np.uint32 else 5123
        while len(blob) % 4: blob.append(0)
        off = len(blob); blob.extend(arr.tobytes())
        bv = {'buffer': 0, 'byteOffset': off, 'byteLength': arr.nbytes}
        if target: bv['target'] = target
        d['bufferViews'].append(bv)
        k = 1 if arr.ndim == 1 else arr.shape[1]
        acc = {'bufferView': len(d['bufferViews']) - 1, 'componentType': ct, 'count': int(arr.shape[0]),
               'type': {1: 'SCALAR', 2: 'VEC2', 3: 'VEC3', 4: 'VEC4'}[k]}
        if pos or key.endswith('POSITION'):
            mn, mx = arr.min(0), arr.max(0)
            acc['min'] = [float(v) for v in np.atleast_1d(mn)]; acc['max'] = [float(v) for v in np.atleast_1d(mx)]
        d['accessors'].append(acc); idx[key] = len(d['accessors']) - 1; return idx[key]
    for m in d['meshes']:
        for p in m['primitives']:
            p['attributes'] = {k: add(v, k == 'POSITION') for k, v in p['attributes'].items()}
            if 'indices' in p: p['indices'] = add(p['indices'])
    for an in d.get('animations', []):             # keyframe times need min/max (glTF requires it on sampler input)
        for sm in an['samplers']:
            sm['input'] = add(sm['input'], True); sm['output'] = add(sm['output'])
    while len(blob) % 4: blob.append(0)
    d['buffers'] = [{'byteLength': len(blob)}]
    j = json.dumps(d, separators=(',', ':')).encode(); j += b' ' * ((-len(j)) % 4)
    out = struct.pack('<III', 0x46546C67, 2, 28 + len(j) + len(blob)) + struct.pack('<II', len(j), 0x4E4F534A) + j + struct.pack('<II', len(blob), 0x004E4942) + bytes(blob)
    open(path, 'wb').write(out); return len(out)

def explode(d, binc):
    """Turn every primitive's accessors into keys of an arrays dict (for re-saving unchanged meshes)."""
    arrays = {}
    for mi, m in enumerate(d['meshes']):
        for p in m['primitives']:
            for k, a in list(p['attributes'].items()):
                key = f'a{a}_{k}'; arrays[key] = (accessor(d, binc, a), 34962); p['attributes'][k] = key
            if 'indices' in p:
                key = f'a{p["indices"]}_IDX'; arrays[key] = (accessor(d, binc, p['indices']), 34963); p['indices'] = key
    for an in d.get('animations', []):             # animation keyframes are accessors too: carry them (bug fixed 2026-09-30)
        for sm in an['samplers']:
            for k in ('input', 'output'):
                key = f'a{sm[k]}_ANIM'; arrays[key] = (accessor(d, binc, sm[k]), None); sm[k] = key
    for k in ('accessors', 'bufferViews', 'buffers'): d.pop(k, None)
    return arrays

def flatten(d, binc):
    """Every mesh primitive of the default scene with its node's world matrix baked in: yields
    (material_index, P[n,3] float64 metres, N[n,3] unit, I[m] int64). Handles nested / rotated / scaled nodes
    (step2glb writes an assembly as a node tree) and dequantizes KHR_mesh_quantization data."""
    out = []
    def walk(i, M):
        n = d['nodes'][i]; W = M @ node_matrix(n)
        if 'mesh' in n:
            R = W[:3, :3]; Nm = np.linalg.inv(R).T
            for p in d['meshes'][n['mesh']]['primitives']:
                P = accessor(d, binc, p['attributes']['POSITION']).astype(np.float64) @ R.T + W[:3, 3]
                N = None
                if 'NORMAL' in p['attributes']:
                    N = accessor(d, binc, p['attributes']['NORMAL']).astype(np.float64) @ Nm.T
                    N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-12)
                out.append((p.get('material'), P, N, accessor(d, binc, p['indices']).astype(np.int64)))
        for c in n.get('children', []): walk(c, W)
    for r in d['scenes'][d.get('scene', 0)]['nodes']: walk(r, np.eye(4))
    return out

ETCH_RGB = {'dark': (0.012, 0.012, 0.012), 'light': (0.78, 0.78, 0.76)}
def led_groups(d, binc, known, lens_lin, tol=3e-3):
    """Group a step2glb LED conversion by role (body/substrate, terminal, cathode_mark, lens, laser_etch) using the
    material base colours. known: role -> linear rgb. Returns role -> (P, N, I) concatenated."""
    groups = {}
    for mi, P, N, I in flatten(d, binc):
        c = d['materials'][mi]['pbrMetallicRoughness']['baseColorFactor'][:3]
        near = lambda k: max(abs(x - y) for x, y in zip(c, k)) < tol
        role = next((r for r, k in known.items() if near(k)), None)
        if role is None and near(lens_lin): role = 'lens'
        if role is None and any(near(k) for k in ETCH_RGB.values()): role = 'laser_etch'
        assert role, ('unknown material colour', c)
        g = groups.setdefault(role, [[], [], [], 0]); g[0].append(P); g[1].append(N); g[2].append(I + g[3]); g[3] += len(P)
    return {r: (np.concatenate(g[0]), np.concatenate(g[1]), np.concatenate(g[2])) for r, g in groups.items()}