"""Measure a .glb without any dependency: world bounds, triangles, draw calls, materials, textures, nodes, rig, clips.

Bounds come from the POSITION accessor min/max (mandatory in glTF) pushed through each node's world matrix, so they
are the box around the transformed accessor boxes (exact for unrotated parts, a little loose for rotated ones).
"""
import json, math, struct

GL_TRIANGLES, GL_STRIP, GL_FAN = 4, 5, 6


def read(path):
    data = open(path, 'rb').read()
    magic, version, length = struct.unpack_from('<4sII', data, 0)
    if magic != b'glTF':
        raise ValueError(f'{path}: not a GLB')
    off, doc, binc = 12, None, b''
    while off < len(data):
        clen, ctype = struct.unpack_from('<I4s', data, off)
        chunk = data[off + 8: off + 8 + clen]
        if ctype == b'JSON':
            doc = json.loads(chunk)
        elif ctype == b'BIN\x00':
            binc = chunk
        off += 8 + clen
    return doc, binc, len(data)


def _mat(n):
    if 'matrix' in n:
        m = n['matrix']
        return [[m[c * 4 + r] for c in range(4)] for r in range(4)]
    t = n.get('translation', [0, 0, 0]); q = n.get('rotation', [0, 0, 0, 1]); s = n.get('scale', [1, 1, 1])
    x, y, z, w = q
    r = [[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)]]
    return [[r[i][0] * s[0], r[i][1] * s[1], r[i][2] * s[2], t[i]] for i in range(3)] + [[0, 0, 0, 1]]


def _mul(a, b):
    return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] for i in range(4)]


def _img_size(doc, binc, img):
    """(w, h, mime) from the image header bytes (PNG, JPEG, WebP)."""
    if 'bufferView' not in img:
        return None
    bv = doc['bufferViews'][img['bufferView']]
    b = binc[bv.get('byteOffset', 0): bv.get('byteOffset', 0) + bv['byteLength']]
    mime = img.get('mimeType', '')
    try:
        if b[:8] == b'\x89PNG\r\n\x1a\n':
            w, h = struct.unpack('>II', b[16:24]); return w, h, 'png'
        if b[:2] == b'\xff\xd8':
            i = 2
            while i < len(b):
                if b[i] != 0xFF: i += 1; continue
                mk = b[i + 1]
                if mk in (0xC0, 0xC1, 0xC2):
                    h, w = struct.unpack('>HH', b[i + 5:i + 9]); return w, h, 'jpeg'
                i += 2 + struct.unpack('>H', b[i + 2:i + 4])[0]
        if b[:4] == b'RIFF' and b[8:12] == b'WEBP':
            kind = b[12:16]
            if kind == b'VP8 ':
                w, h = struct.unpack('<HH', b[26:30]); return w & 0x3FFF, h & 0x3FFF, 'webp'
            if kind == b'VP8L':
                v = struct.unpack('<I', b[21:25])[0]; return (v & 0x3FFF) + 1, ((v >> 14) & 0x3FFF) + 1, 'webp'
            if kind == b'VP8X':
                w = int.from_bytes(b[24:27], 'little') + 1; h = int.from_bytes(b[27:30], 'little') + 1; return w, h, 'webp'
        if b[:12] == b'\xabKTX 20\xbb\r\n\x1a\n':
            w, h = struct.unpack('<II', b[20:28]); return w, h, 'ktx2'
    except Exception:
        pass
    return None, None, mime or '?'


def measure(path):
    doc, binc, nbytes = read(path)
    acc = doc.get('accessors', []); meshes = doc.get('meshes', []); nodes = doc.get('nodes', [])
    scene = doc.get('scenes', [{}])[doc.get('scene', 0)] if doc.get('scenes') else {'nodes': list(range(len(nodes)))}
    lo = [math.inf] * 3; hi = [-math.inf] * 3
    tris = verts = prims = 0
    mats_used = set()
    names = []
    node_pos = {}

    def walk(i, parent):
        nonlocal tris, verts, prims
        n = nodes[i]; m = _mul(parent, _mat(n))
        if n.get('name'):
            names.append(n['name'])
            if len(node_pos) < 200: node_pos[n['name']] = [round(m[r][3], 3) for r in range(3)]
        if 'mesh' in n:
            for p in meshes[n['mesh']].get('primitives', []):
                mode = p.get('mode', GL_TRIANGLES)
                pos = acc[p['attributes']['POSITION']]
                cnt = acc[p['indices']]['count'] if 'indices' in p else pos['count']
                t = cnt // 3 if mode == GL_TRIANGLES else max(0, cnt - 2) if mode in (GL_STRIP, GL_FAN) else 0
                tris += t; verts += pos['count']; prims += 1
                if 'material' in p: mats_used.add(p['material'])
                a, b = pos.get('min'), pos.get('max')
                if a and b and pos.get('normalized'):  # quantized positions (KHR_mesh_quantization): ints -> [-1, 1]
                    d = {5120: 127, 5121: 255, 5122: 32767, 5123: 65535}.get(pos['componentType'], 1)
                    a = [v / d for v in a]; b = [v / d for v in b]
                if a and b:
                    for cx in (a[0], b[0]):
                        for cy in (a[1], b[1]):
                            for cz in (a[2], b[2]):
                                v = [m[r][0] * cx + m[r][1] * cy + m[r][2] * cz + m[r][3] for r in range(3)]
                                for k in range(3):
                                    lo[k] = min(lo[k], v[k]); hi[k] = max(hi[k], v[k])
        for c in n.get('children', []):
            walk(c, m)

    ident = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
    for r in scene.get('nodes', []):
        walk(r, ident)
    texs = []
    gpu = 0.0
    for img in doc.get('images', []):
        w, h, fmt = _img_size(doc, binc, img)
        texs.append({'px': [w, h], 'fmt': fmt})
        if w and h:  # RGBA8 + mip chain; KTX2 (Basis) stays block-compressed on the GPU: count 1 byte per pixel (BC7/ASTC)
            gpu += w * h * (1 if fmt == 'ktx2' else 4) * 4 / 3 / 1e6
    ext = sorted(set(doc.get('extensionsUsed', [])))
    skins = doc.get('skins', [])
    if lo[0] == math.inf:
        lo = hi = [0, 0, 0]
    size = [round(hi[k] - lo[k], 3) for k in range(3)]
    return {
        'bytes': nbytes,
        'bounds': {'min': [round(v, 3) for v in lo], 'max': [round(v, 3) for v in hi]},
        'size_xyz': size,                      # x = width, y = height, z = length (front +Z)
        'tris': tris, 'verts': verts, 'draw_calls': prims,
        'materials': len(mats_used) or len(doc.get('materials', [])),
        'material_names': [m.get('name', '') for m in doc.get('materials', [])][:24],
        'textures': texs, 'gpu_mb_decoded': round(gpu, 1),
        'nodes': names[:200], 'node_pos': node_pos,
        'rig': {'skins': len(skins), 'joints': max((len(s.get('joints', [])) for s in skins), default=0)} if skins else None,
        'animations': [a.get('name', f'clip{i}') for i, a in enumerate(doc.get('animations', []))],
        'extensions': ext,
        'codec': 'draco' if 'KHR_draco_mesh_compression' in ext else 'meshopt' if 'EXT_meshopt_compression' in ext else 'none',
    }


if __name__ == '__main__':
    import sys
    for p in sys.argv[1:]:
        print(p, json.dumps(measure(p)))
