PROCITY/pipeline/merge_prims.py
m3ultra 8ad446a48a Lane E R41 (§41.1+§41.2): 46 clips and 110 fittings — and every handover number was the wrong unit
MOTION (§41.1). The clip library goes 8 -> 46: ten idles, eight browse, eight sit/lean,
eight social, six locomotion, six venue, in SIX grouped GLBs (one fetch each), 3.35 MB —
LESS than the 4.29 MB the old eight cost, via lossless dedup + int16 rotations (worst
error 0.0034 deg). All six verified skeleton-only (tris 0, meshes 0, nodes 66): ZERO DRAW,
which is what makes this round affordable. No retarget was run and none was wanted — the
bank and the peds are the same mixamorig skeleton, so retargeting would add error AND bake
the ped mesh into the clip, ending zero-draw. MIRPAMO 'make smoke' green to prove the tool,
then deliberately unused. Three seeds in the brief were duds and got substituted: the whole
*_Degree_Turn set is RIFLE-AIMING, and two 'examine' clips are 262 KB static poses, not
motion. R16 flat-body trap re-checked on all 46 (spine tilt 24 samples/clip, 0 frames >75
deg); turn_in_place auto-demoted from loopable on a 36.7 deg seam.

RULING 1 — the Bandai hazard, closed non-destructively and better than specified. 3,077 CC
BY-NC clips sat unzoned in a neutrally-named path. Renamed PER FILE (3,077/3,077) not just
the directory, because mirpamo names output <rig>@<clip>.glb — the _NC-research marker now
PROPAGATES into any retargeted GLB automatically. Nothing deleted; ultra's red/bandai
re-verified as canonical. Ledger corrected: the manifest claimed CC-BY-NC-ND, the bundled
licence text says CC BY-NC, no ND.

PROPS (§41.2). 110 assets from three libraries, published, sha1-verified, 0 validator errors
both modes. THE FINDING: every handover number was a TRIANGLE count, and triangles were
never the binding constraint — DRAW CALLS were. As handed over this cargo cost 14,140 draws
against a 162-draw margin; it ships at 117. '3dstore passes as-is' was true on tris/metres/
Draco/textures and FALSE on draws — 30 of 46 files carried up to 16 materials on one mesh
(one per record sleeve), so a 1,020-tri tub cost 16 draws; baked to COLOR_0, 288 -> 40, zero
tri drift, A/B identical. dj-gear's own manifest claimed median 6,288 tris / 18 under budget;
measured 51,528 and 12, with draws to 2,145. Pub props were NOT metre-correct (every source
unit-normalised to max dim 1.00 m) and 40x decimation was impossible as specified (loungeChair
is 94% non-manifold).

Ruling 5 vs the draw budget was a real conflict (one mixer = 4.6x a whole room); resolved by
joining SCENERY selectively while every control node keeps its object/name/pivot — verified
by parsing the SHIPPED GLB, not the tool that wrote it: PASS 4 / STATIC 10 / N/A 17 / FAIL 0.
deck_1200_rigged = 5 draws with Platter_SPIN, Arm_YAW, Fader_PITCH, Btn_STARTSTOP intact.

The R40 four-surface transmission gate FIRED ON LIVE CARGO: 5 genuinely transmissive
materials (cartridge dust-covers, a mixer meter window) that would each have doubled every
opaque draw in the room. 9 assets rejected on eyeball after decimation rather than shipping
mush. Two existing-tooling bugs fixed: normalize.py's yaw/up were silent no-ops (the jukebox
exported 0.40 m instead of 0.97 m), and footprints now measure the shipped GLB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:10:12 +10:00

246 lines
11 KiB
Python

#!/usr/bin/env python3
"""Collapse a multi-material GLB to ONE primitive per mesh by baking baseColorFactor -> COLOR_0.
WHY THIS EXISTS (R41 §41.2, the 3dstore import)
-----------------------------------------------
`~/Documents/3dstore/` was handed over as "45 of 46 pass the house law as-is". On tris, scale,
Draco and textures that is true. On the number that actually decides whether Lane C can place them
it is not: **14 of the files carry 16 materials on a single mesh**, one flat colour per record
sleeve (`Mat_Sleeve_<hex>`). glTF gives each material its own primitive, and three.js
`GLTFLoader` instantiates one `Mesh` per primitive — so a 1,020-tri "Black Tub" costs **16 draw
calls**, not one. The interior draw margin is 162. Ten tubs would be the entire budget.
The materials are pure `baseColorFactor` — no textures, no UVs, nothing but a flat colour and a
metallic/roughness pair. That is exactly what a vertex colour reproduces. So we merge every
primitive of a mesh into one, write each source primitive's `baseColorFactor` into a `COLOR_0`
attribute on its own vertices, and emit a single white material with the dominant primitive's
PBR values. glTF multiplies `COLOR_0` by `baseColorFactor`, and both live in LINEAR space, so a
white factor times the original linear colour is **numerically the original colour**. Byte-exact
look, 16 draws -> 1.
This is deliberately NOT `gltf-transform optimize` (ruling 5) and NOT a Blender re-export: the
geometry is already correct and every extra tool is another chance to move it. We rewrite the
container and touch nothing but the material binding.
python3 pipeline/merge_prims.py IN.glb OUT.glb
python3 pipeline/merge_prims.py --check OUT.glb # report prims/draws/materials
"""
import json, os, struct, sys
COMPONENT = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2),
5123: ("H", 2), 5125: ("I", 4), 5126: ("f", 4)}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
# ── container io ─────────────────────────────────────────────────────────────────────────────────
def read_glb(path):
d = open(path, "rb").read()
if d[:4] != b"glTF":
raise ValueError(f"{path}: not a GLB")
off, gltf, binc = 12, None, b""
while off + 8 <= len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8])
chunk = d[off + 8: off + 8 + clen]
if ctype == 0x4E4F534A:
gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942:
binc = chunk
off += 8 + clen
return gltf, binc
def write_glb(path, gltf, binc):
js = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
binc += b"\x00" * ((4 - len(binc) % 4) % 4)
total = 12 + 8 + len(js) + (8 + len(binc) if binc else 0)
with open(path, "wb") as f:
f.write(b"glTF" + struct.pack("<II", 2, total))
f.write(struct.pack("<II", len(js), 0x4E4F534A) + js)
if binc:
f.write(struct.pack("<II", len(binc), 0x004E4942) + binc)
def read_accessor(gltf, binc, idx):
"""Return a list of tuples (or scalars) for accessor `idx`, honouring byteStride."""
a = gltf["accessors"][idx]
fmt, size = COMPONENT[a["componentType"]]
n = NCOMP[a["type"]]
count = a["count"]
if "bufferView" not in a: # all-zero accessor (spec-legal)
return [(0,) * n for _ in range(count)] if n > 1 else [0] * count
bv = gltf["bufferViews"][a["bufferView"]]
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
stride = bv.get("byteStride") or (size * n)
out = []
for i in range(count):
off = base + i * stride
v = struct.unpack_from("<" + fmt * n, binc, off)
out.append(v if n > 1 else v[0])
return out
def norm_component(v, ctype):
"""glTF normalized-integer -> float, per the spec's exact divisors."""
if ctype == 5121:
return v / 255.0
if ctype == 5123:
return v / 65535.0
if ctype == 5120:
return max(v / 127.0, -1.0)
if ctype == 5122:
return max(v / 32767.0, -1.0)
return float(v)
# ── the merge ────────────────────────────────────────────────────────────────────────────────────
def merge(gltf, binc):
"""Rewrite every multi-primitive mesh as one primitive with baked COLOR_0. Returns a report."""
mats = gltf.get("materials", [])
new_bin = bytearray()
new_views, new_accs = [], []
def add_accessor(data, fmt, size, ncomp, atype, ctype, minmax=False, target=None):
while len(new_bin) % 4:
new_bin.append(0)
off = len(new_bin)
for v in data:
new_bin.extend(struct.pack("<" + fmt * ncomp, *(v if ncomp > 1 else (v,))))
bv = {"buffer": 0, "byteOffset": off, "byteLength": len(new_bin) - off}
if target:
bv["target"] = target
new_views.append(bv)
acc = {"bufferView": len(new_views) - 1, "componentType": ctype,
"count": len(data), "type": atype}
if minmax and data:
cols = list(zip(*data)) if ncomp > 1 else [data]
acc["min"] = [min(c) for c in cols]
acc["max"] = [max(c) for c in cols]
new_accs.append(acc)
return len(new_accs) - 1
report = []
for mi, mesh in enumerate(gltf.get("meshes", [])):
prims = mesh.get("primitives", [])
if not prims:
continue
pos, nrm, col, idx = [], [], [], []
tri_by_prim = []
for p in prims:
attrs = p["attributes"]
P = read_accessor(gltf, binc, attrs["POSITION"])
N = (read_accessor(gltf, binc, attrs["NORMAL"])
if "NORMAL" in attrs else [(0.0, 1.0, 0.0)] * len(P))
# existing COLOR_0 multiplies the factor (spec); normalize ints first
if "COLOR_0" in attrs:
ca = gltf["accessors"][attrs["COLOR_0"]]
raw = read_accessor(gltf, binc, attrs["COLOR_0"])
ct, nc = ca["componentType"], NCOMP[ca["type"]]
existing = [tuple(norm_component(x, ct) for x in (v if nc > 1 else (v,)))
for v in raw]
existing = [(e + (1.0,)) if len(e) == 3 else e for e in existing]
else:
existing = None
m = mats[p["material"]] if p.get("material") is not None and mats else {}
bcf = (m.get("pbrMetallicRoughness", {}) or {}).get("baseColorFactor", [1, 1, 1, 1])
bcf = tuple(float(x) for x in (list(bcf) + [1, 1, 1, 1])[:4])
base = len(pos)
pos.extend(P)
nrm.extend(N)
if existing:
col.extend(tuple(bcf[k] * existing[i][k] for k in range(4))
for i in range(len(P)))
else:
col.extend([bcf] * len(P))
if "indices" in p:
I = read_accessor(gltf, binc, p["indices"])
else:
I = list(range(len(P)))
idx.extend(i + base for i in I)
tri_by_prim.append((len(I) // 3, p.get("material")))
# PBR donor = the material covering the most triangles, so the dominant surface keeps its
# exact metallic/roughness. (On the 3dstore tubs that is a sleeve: metallic 0, rough 0.6.)
donor_mat = max(tri_by_prim, key=lambda t: t[0])[1] if tri_by_prim else None
donor = mats[donor_mat] if donor_mat is not None and mats else {}
dp = donor.get("pbrMetallicRoughness", {}) or {}
ctype = 5125 if len(pos) > 65535 else 5123
fmt = "I" if ctype == 5125 else "H"
i_acc = add_accessor(idx, fmt, 4 if ctype == 5125 else 2, 1, "SCALAR", ctype,
target=34963)
p_acc = add_accessor(pos, "f", 4, 3, "VEC3", 5126, minmax=True, target=34962)
n_acc = add_accessor(nrm, "f", 4, 3, "VEC3", 5126, target=34962)
c_acc = add_accessor(col, "f", 4, 4, "VEC4", 5126, target=34962)
mesh["primitives"] = [{
"attributes": {"POSITION": p_acc, "NORMAL": n_acc, "COLOR_0": c_acc},
"indices": i_acc, "material": 0, "mode": 4,
}]
report.append({"mesh": mi, "name": mesh.get("name"), "prims_before": len(prims),
"prims_after": 1, "tris": len(idx) // 3, "verts": len(pos)})
gltf["materials"] = [{
"name": "Merged_VertexColor",
"doubleSided": any(m.get("doubleSided") for m in mats) if mats else False,
"pbrMetallicRoughness": {
"baseColorFactor": [1, 1, 1, 1], # identity: COLOR_0 carries it all
"metallicFactor": dp.get("metallicFactor", 1.0),
"roughnessFactor": dp.get("roughnessFactor", 1.0),
},
}]
gltf["bufferViews"] = new_views
gltf["accessors"] = new_accs
gltf["buffers"] = [{"byteLength": len(new_bin)}]
for k in ("images", "textures", "samplers"): # nothing references them now
gltf.pop(k, None)
gltf["extensionsUsed"] = [e for e in gltf.get("extensionsUsed", [])
if "draco" not in e.lower()] or None
if not gltf["extensionsUsed"]:
gltf.pop("extensionsUsed")
return report, bytes(new_bin)
def check(path):
gltf, _ = read_glb(path)
meshes = gltf.get("meshes", [])
nodes = gltf.get("nodes", [])
draws = sum(len(meshes[n["mesh"]].get("primitives", [])) for n in nodes if "mesh" in n)
prims = sum(len(m.get("primitives", [])) for m in meshes)
tris = 0
for m in meshes:
for p in m.get("primitives", []):
a = gltf["accessors"][p["indices"]] if "indices" in p else \
gltf["accessors"][p["attributes"]["POSITION"]]
tris += a["count"] // 3
return {"file": os.path.basename(path), "draws": draws, "prims": prims,
"materials": len(gltf.get("materials", [])), "tris": tris,
"images": len(gltf.get("images", []))}
def main():
args = sys.argv[1:]
if args and args[0] == "--check":
for f in args[1:]:
print(check(f))
return 0
if len(args) != 2:
print(__doc__)
return 2
src, dst = args
gltf, binc = read_glb(src)
before = check(src)
rep, nb = merge(gltf, binc)
os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
write_glb(dst, gltf, nb)
after = check(dst)
print(f"{os.path.basename(src):42} draws {before['draws']:>3} -> {after['draws']:>2} "
f"| mats {before['materials']:>2} -> {after['materials']} "
f"| tris {before['tris']} -> {after['tris']}"
f"{' *** TRI DRIFT ***' if before['tris'] != after['tris'] else ''}")
return 0
if __name__ == "__main__":
sys.exit(main())