PROCITY/pipeline/glb_stat.py
m3ultra 12eb571357 Lane E R39 (6/n): THREE GLBs shipped as GLASS, not one — transmissionFactor 1 stripped, published, sha1-verified, and now GATED. Plus the arcade's three meshes.
Lane C measured the bookshelf's transmission halving interior draws (opshop/hall 191->104, book/hall
101->51, dept/hall 116->116 control). The cause, with vendored line numbers: three.module.js:16209
enters renderTransmissionPass(opaqueObjects, ...) whenever currentRenderList.transmissive is
non-empty, and :16433 re-renders the WHOLE OPAQUE LIST into a render target — every opaque draw
issued twice. Trigger is material.transmission > 0 (:6890), set from the glTF at GLTFLoader:1202.
And at 1.0 it is not 'a bit glassy': :494 does totalDiffuse = mix(totalDiffuse, transmitted.rgb,
material.transmission) — the diffuse term is REPLACED by the backdrop.

I SCANNED ALL 103 GLBs. Three carried it:
  procity_fit_bookshelf_01      mtl_10218_Bookshelves_v1     shipped and hurting (C's)
  procity_street_longbench_01   lambert3SG                   NOT WIRED — the 5.86 m arcade bench
                                                             the v9 charter names, on my R38
                                                             recommend list for Lane B
  procity_street_streetlight_01 Streetlight_HighResSG3       NOT WIRED — one per ~18 m of street
The two unwired ones are the worse finding: the street budget is 292/300 with EIGHT draws of margin,
and a pre-pass there costs another ~292, not eight. Armed, not fired.

FIXED SURGICALLY. pipeline/strip_transmission.py rewrites only the JSON chunk and copies BIN through,
printing sha1(BIN) before and after: BIN IDENTICAL on all three, tris/dims/mtl/img/tex unchanged.
KHR_materials_ior deliberately left (ior does not trigger the pre-pass).

PUBLISHED + VERIFIED under John's R22 standing authorization via the passwordless tailnet ingress:
6/6 HTTP 200 off the PUBLIC depot, byte counts exact, sha1 identical, depot copies re-scanned clean.
_published.json 56 -> 59.

GATED, which matters more than the fix: validate_manifest.py now HARD FAILS on transmissionFactor>0
with the three.js line numbers and the fix command in the message; glb_stat.py grew a transmission
column and a loud footer. Nobody looked for three epochs because a bookshelf is obviously not glass.

ALSO: the arcade's three meshes, published and catalogued the same round they were generated —
aframe_board 366 tris (<=16 inst), blade_sign 299 (17 inst), keycutter_sign 498 (1 inst). manifest
furniture 13->16, GLBs 36->39, validate 0 errors 0 warnings. blade_sign ships FLAGGED: its back face
carries TRELLIS-hallucinated art where the concept had no data, which is one more reason the
+0-draw perpendicular-quad answer is the recommendation and this GLB is the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:11:34 +10:00

250 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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("<II", data[4:12])
off, gltf, bin_chunk = 12, None, b""
while off < len(data):
clen, ctype = struct.unpack("<II", data[off:off + 8])
chunk = data[off + 8: off + 8 + clen]
if ctype == 0x4E4F534A: # 'JSON'
gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942: # 'BIN\0'
bin_chunk = chunk
off += 8 + clen
return gltf, bin_chunk
def _prim_tris(gltf, prim):
"""Triangle count for one primitive (mode 4 / default = TRIANGLES)."""
mode = prim.get("mode", 4)
accessors = gltf.get("accessors", [])
if "indices" in prim:
count = accessors[prim["indices"]]["count"]
else:
pos = prim.get("attributes", {}).get("POSITION")
if pos is None:
return 0
count = accessors[pos]["count"]
if mode == 4:
return count // 3
if mode in (5, 6): # triangle strip / fan
return max(0, count - 2)
return 0 # points/lines contribute no tris
def _mat_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 _node_matrix(n):
if "matrix" in n: # column-major → row-major
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), 0],
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), 0],
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y), 0],
[0, 0, 0, 1]]
S = [[s[0], 0, 0, 0], [0, s[1], 0, 0], [0, 0, s[2], 0], [0, 0, 0, 1]]
T = [[1, 0, 0, t[0]], [0, 1, 0, t[1]], [0, 0, 1, t[2]], [0, 0, 0, 1]]
return _mat_mul(_mat_mul(T, R), S)
def _xform(M, p):
return [sum(M[i][j] * ([p[0], p[1], p[2], 1][j]) for j in range(4)) for i in range(3)]
def stat(path):
gltf, _ = _read_glb(path)
accessors = gltf.get("accessors", [])
meshes = gltf.get("meshes", [])
nodes = gltf.get("nodes", [])
tris = 0
for m in meshes: # tri count is transform-independent
for p in m.get("primitives", []):
tris += _prim_tris(gltf, p)
# WORLD-space bbox: walk the scene graph so node TRS (unit scale / pivot offset) is respected —
# local accessor min/max alone lies for multi-node assets (missed a 1km off-origin bug once).
mn = [float("inf")] * 3
mx = [float("-inf")] * 3
scenes = gltf.get("scenes") or [{"nodes": list(range(len(nodes)))}]
roots = scenes[gltf.get("scene", 0)].get("nodes", [])
def walk(ni, M):
n = nodes[ni]
W = _mat_mul(M, _node_matrix(n))
if "mesh" in n:
for p in meshes[n["mesh"]].get("primitives", []):
pos = p.get("attributes", {}).get("POSITION")
if pos is None:
continue
a = accessors[pos]
if "min" not in a or "max" not in a:
continue
lo, hi = a["min"], a["max"]
for cx in (lo[0], hi[0]):
for cy in (lo[1], hi[1]):
for cz in (lo[2], hi[2]):
w = _xform(W, [cx, cy, cz])
for k in range(3):
mn[k] = min(mn[k], w[k]); mx[k] = max(mx[k], w[k])
for c in n.get("children", []):
walk(c, W)
I = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
for r in roots:
walk(r, I)
dims = [round(mx[i] - mn[i], 3) if mx[i] > 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)
# [R39] TRANSMISSION IS A DRAW-BUDGET PROPERTY, NOT A LOOK. three.js runs a transmission
# PRE-PASS — `renderTransmissionPass(opaqueObjects, …)` at three.module.js:16433, entered from
# :16209 whenever `currentRenderList.transmissive.length > 0` — which re-renders the whole
# OPAQUE list into a render target, so every draw in that scene is issued TWICE. Three PROCITY
# GLBs shipped with `transmissionFactor: 1` and nobody looked, because a bookshelf, a bench and
# a streetlight are not glass. Lane C measured the bookshelf's at 46%/50% of interior draws.
# It is reported here so it can never hide in a material block again.
transmission = []
for i, m in enumerate(gltf.get("materials", [])):
ex = m.get("extensions") or {}
t = ex.get("KHR_materials_transmission")
if t and t.get("transmissionFactor", 0) > 0:
transmission.append({"material": i, "name": m.get("name"),
"factor": t.get("transmissionFactor")})
return {
"file": os.path.basename(path),
"tris": tris,
"transmission": transmission,
"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("<H", blob[26:28])[0] & 0x3FFF
h = struct.unpack("<H", blob[28:30])[0] & 0x3FFF
return (w, h)
if fmt == b"VP8L": # lossless: 14-bit dims packed after the 0x2F sig
b0, b1, b2, b3 = blob[21], blob[22], blob[23], blob[24]
w = ((b1 & 0x3F) << 8 | b0) + 1
h = ((b3 & 0x0F) << 10 | b2 << 2 | (b1 & 0xC0) >> 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} "
f"draco transmission")
print(hdr)
print("-" * len(hdr))
bad = 0
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}"
tx = r.get("transmission") or []
bad += len(tx)
txs = ("*** " + ",".join(f"{t['name']}={t['factor']}" for t in tx) + " ***") if tx else "-"
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 '-':5} {txs}")
if bad:
print(f"\n*** {bad} material(s) carry KHR_materials_transmission > 0. three.js will run a "
f"transmission PRE-PASS (three.module.js:16209/:16433) and issue EVERY opaque draw in "
f"that scene TWICE. Fix with: python3 pipeline/strip_transmission.py <file> ***")
if __name__ == "__main__":
main()