#!/usr/bin/env python3 """Dependency-free GLB inspector for the PROCITY asset audit. Parses .glb binary containers (no Blender, no pygltflib) and reports, per file: tri count, bounding box in metres (scale sanity), node/mesh/material/image counts, and embedded texture pixel dimensions. Also flags likely house-GLB-law violations. Usage: python3 glb_stat.py FILE.glb [FILE2.glb ...] # human table python3 glb_stat.py --json DIR # JSON, recurse *.glb in DIR python3 glb_stat.py --json FILE.glb # JSON for one file House GLB law (CITY_SPEC / LANE_E): metres, +Y up, origin at base, facing -Z, props <=5k tris, textures <=1024, no Draco. """ import json, struct, sys, os, glob COMPONENT_SIZE = {5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4} TYPE_COUNT = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT2": 4, "MAT3": 9, "MAT4": 16} def _read_glb(path): with open(path, "rb") as f: data = f.read() if data[:4] != b"glTF": # maybe a .gltf JSON, or non-glb; try JSON parse try: return json.loads(data.decode("utf-8")), b"" except Exception: raise ValueError("not a GLB/glTF") ver, length = struct.unpack(" mn[i] else 0.0 for i in range(3)] # texture pixel sizes from embedded images (PNG/JPEG header sniff) tex_sizes = [] images = gltf.get("images", []) bufviews = gltf.get("bufferViews", []) _, binc = _read_glb(path) for im in images: wh = None if "bufferView" in im and binc: bv = bufviews[im["bufferView"]] start = bv.get("byteOffset", 0) blob = binc[start:start + bv.get("byteLength", 0)] wh = _img_size(blob) if wh: tex_sizes.append(wh) exts = gltf.get("extensionsUsed", []) or [] draco = any("draco" in e.lower() for e in exts) return { "file": os.path.basename(path), "tris": tris, "dims_m": dims, # x,y,z extent in file units (hopefully metres) "nodes": len(gltf.get("nodes", [])), "meshes": len(meshes), "materials": len(gltf.get("materials", [])), "images": len(images), "tex_sizes": tex_sizes, "max_tex": max((max(w, h) for w, h in tex_sizes), default=0), "draco": draco, "exts": exts, } def _img_size(blob): if blob[:8] == b"\x89PNG\r\n\x1a\n": w, h = struct.unpack(">II", blob[16:24]) return (w, h) if blob[:2] == b"\xff\xd8": # JPEG: scan SOF markers i = 2 while i < len(blob) - 9: if blob[i] != 0xFF: i += 1 continue marker = blob[i + 1] if marker in (0xC0, 0xC1, 0xC2, 0xC3): h, w = struct.unpack(">HH", blob[i + 5:i + 9]) return (w, h) seglen = struct.unpack(">H", blob[i + 2:i + 4])[0] i += 2 + seglen if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP": # WebP (normalize.py exports these) fmt = blob[12:16] if fmt == b"VP8 ": # lossy: 16-bit dims at offset 26 (14-bit each) w = struct.unpack("> 6) + 1 return (w, h) if fmt == b"VP8X": # extended: 24-bit dims at offset 24 w = (blob[24] | blob[25] << 8 | blob[26] << 16) + 1 h = (blob[27] | blob[28] << 8 | blob[29] << 16) + 1 return (w, h) return None def main(): args = sys.argv[1:] as_json = "--json" in args args = [a for a in args if a != "--json"] files = [] for a in args: if os.path.isdir(a): files += sorted(glob.glob(os.path.join(a, "**", "*.glb"), recursive=True)) else: files.append(a) rows = [] for f in files: try: rows.append(stat(f)) except Exception as e: rows.append({"file": os.path.basename(f), "error": str(e)}) if as_json: print(json.dumps(rows, indent=2)) return hdr = f"{'file':38} {'tris':>7} {'dims (m) x,y,z':>22} {'mtl':>3} {'img':>3} {'maxtex':>6} draco" print(hdr) print("-" * len(hdr)) for r in rows: if "error" in r: print(f"{r['file']:38} ERROR: {r['error']}") continue d = r["dims_m"] dstr = f"{d[0]:.2f},{d[1]:.2f},{d[2]:.2f}" print(f"{r['file']:38} {r['tris']:>7} {dstr:>22} {r['materials']:>3} " f"{r['images']:>3} {r['max_tex']:>6} {'YES' if r['draco'] else '-'}") if __name__ == "__main__": main()