PROCITY/pipeline/rig_join.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

264 lines
10 KiB
Python

"""PROCITY DJ-gear normalizer — collapse draws WITHOUT breaking the named-node contract.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/rig_join.py -- BATCH.json OUT_DIR [THUMB_DIR]
WHY (R41 §41.2, and the tension nobody had measured)
----------------------------------------------------
Integrator ruling 5 forbids `gltf-transform optimize` on this library: it collapsed a 696-node
mixer to 25 nodes and deleted `Fader_CROSS`. True — and the reason it was reached for is also
true. Measured on ultra's 60 exports, these models cost, in DRAW CALLS:
TR-1000 2145 · MPC-XL 1319 · MP2016 1201 · TR-808 1092 · PMC-08Pro 751 · TTM54i 659
The interior draw margin is **162**. One PMC-08Pro is 4.6x the entire budget for a room. So the
library as shipped is unplaceable, and the fix that makes it placeable is the one that is banned —
because both "join everything" and "join nothing" are wrong.
The third option is to join SELECTIVELY. Everything that is not a control, and everything under a
control that is not the control itself, is scenery: it merges into one body mesh. Each node named
in the contract keeps its own object, its own name, and its own pivot, so it can still be driven:
Platter_SPIN · Arm_YAW · Fader_PITCH · Fader_CROSS · Btn_STARTSTOP
That is 1 body + at most 5 controls = **<=6 draws** for a fully drivable deck, instead of 751 for
one that nobody can afford to place. `mode:"static"` drops the controls into the body too (1 draw)
for the dressing copies, which is what most of a record shop actually needs.
The contract is VERIFIED, not assumed: every model is scanned for contract nodes before and after,
and `_rig_results.json` records both lists so a deletion can never pass silently.
BATCH.json: [ {"src":"TTM54i.glb", "id":"mixer_ttm54i", "category":"fit",
"mode":"rigged"|"static", "height_m":0.10, "tris":5000, "yaw":0} ]
"""
import bpy, sys, os, math, json
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH = ARGV[0]
OUT_DIR = ARGV[1] if len(ARGV) > 1 else "/tmp/procity_rig"
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
ROOT = os.environ.get("PROCITY_MODELS_ROOT", os.path.expanduser("~/Documents/3D=models/dj-gear/glb"))
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(THUMB_DIR, exist_ok=True)
CONTRACT = ["Platter_SPIN", "Arm_YAW", "Fader_PITCH", "Fader_CROSS", "Btn_STARTSTOP"]
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for blk in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
for d in list(blk):
if d.users == 0:
blk.remove(d)
def world_bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
return lo, hi
def descendants(o):
out = []
for c in o.children:
out.append(c)
out.extend(descendants(c))
return out
def join_into(active, others):
"""Join `others` into `active`, keeping active's origin (so a control keeps its pivot)."""
others = [o for o in others if o.type == 'MESH' and o is not active]
bpy.ops.object.select_all(action='DESELECT')
active.select_set(True)
for o in others:
o.select_set(True)
bpy.context.view_layer.objects.active = active
if others:
bpy.ops.object.join()
return active
def tris_of(o):
return len(o.data.polygons)
def decimate_to(o, budget):
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.quads_convert_to_tris()
bpy.ops.object.mode_set(mode='OBJECT')
n = tris_of(o)
passes = 0
while n > budget * 1.05 and passes < 6:
m = o.modifiers.new("dec", 'DECIMATE')
m.ratio = max(0.01, budget / n)
bpy.ops.object.modifier_apply(modifier=m.name)
new = tris_of(o)
passes += 1
if new >= n * 0.97:
n = new
break
n = new
return n
def scan_contract():
names = {o.name for o in bpy.data.objects}
return [c for c in CONTRACT if c in names]
def process(spec):
wipe()
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(ROOT, spec["src"])
bpy.ops.import_scene.gltf(filepath=src)
before = scan_contract()
all_mesh = [o for o in bpy.data.objects if o.type == 'MESH']
tri_before = sum(tris_of(o) for o in all_mesh)
draws_before = len(all_mesh)
mode = spec.get("mode", "static")
budget = spec.get("tris", 5000)
keep, ctrl_meshes = [], []
if mode == "rigged":
# Each contract node absorbs its own subtree and keeps its pivot; scenery goes to the body.
#
# The named node stays whatever the source made it. In this library the controls are
# EMPTIES with the cap/stem/rib parented underneath (dj-gear/README.md: "moving the bare
# node moves the whole fader"), so the empty IS the contract and the pivot. An earlier
# pass promoted the biggest mesh child to carry the name and deleted the empty — which
# silently dropped the empty's translation and flung Btn_STARTSTOP to the origin, making
# the rigged deck 0.28 m tall against the static copy's 0.12 m. Keep the empty.
for name in before:
o = bpy.data.objects.get(name)
if not o:
continue
subtree = [d for d in descendants(o) if d.type == 'MESH']
if o.type == 'MESH':
join_into(o, subtree)
ctrl_meshes.append(o)
else:
if not subtree:
continue
host = max(subtree, key=tris_of)
join_into(host, [s for s in subtree if s is not host])
ctrl_meshes.append(host)
# detach from the source hierarchy, keeping the world placement
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
keep.append(o)
keep_names = {o.name for o in keep} | {o.name for o in ctrl_meshes}
body_parts = [o for o in bpy.data.objects
if o.type == 'MESH' and o.name not in keep_names]
if not body_parts:
raise ValueError("no body geometry left")
body = max(body_parts, key=tris_of)
body = join_into(body, body_parts)
body.name = "Body"
# controls become children of the body so one transform moves the whole unit
for o in keep:
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
body.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.parent_set(type='OBJECT', keep_transform=True)
ctrl_tris = sum(tris_of(o) for o in ctrl_meshes)
decimate_to(body, max(600, budget - ctrl_tris))
for o in ctrl_meshes: # keep controls readable, but bounded
if tris_of(o) > 400:
decimate_to(o, 400)
# orientation, height-normalise, base-origin — on the whole unit
objs = [body] + keep + ctrl_meshes
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = body
if spec.get("yaw"):
body.rotation_mode = 'XYZ'
body.rotation_euler[2] += math.radians(spec["yaw"])
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
h = spec.get("height_m", 0)
if h and h > 0:
s = h / max(hi.z - lo.z, 1e-6)
body.scale *= s
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
body.location -= Vector(((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, lo.z))
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
for img in bpy.data.images:
if img.size[0] > 1024 or img.size[1] > 1024:
sc = 1024 / max(img.size)
img.scale(max(1, int(img.size[0] * sc)), max(1, int(img.size[1] * sc)))
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_yup=True,
use_selection=True, export_apply=False,
export_image_format='WEBP', export_image_quality=85)
after = scan_contract()
return {
"src": spec["src"], "id": spec["id"], "file": name, "mode": mode,
"tri_before": tri_before, "tri_after": sum(tris_of(o) for o in objs if o.type == 'MESH'),
"draws_before": draws_before,
"draws_after": sum(1 for o in objs if o.type == 'MESH'),
"contract_before": before, "contract_after": after,
"contract_ok": set(before) == set(after) if mode == "rigged" else True,
"controls": sorted(o.name for o in keep),
"size_m": [round(hi.x - lo.x, 3), round(hi.z - lo.z, 3), round(hi.y - lo.y, 3)],
"footprint": [max(round(hi.x - lo.x, 2), 0.01), max(round(hi.y - lo.y, 2), 0.01)],
"height": max(round(hi.z - lo.z, 3), 0.01),
"out": out, "thumb": f"thumbs/{name[:-4]}.png",
}
def main():
specs = json.load(open(BATCH))
results, errors = [], []
for spec in specs:
try:
r = process(spec)
results.append(r)
flag = "" if r["contract_ok"] else " *** CONTRACT BROKEN ***"
print(f"OK {r['file']:44} {r['mode']:6} tris {r['tri_before']:>7}->{r['tri_after']:>5} "
f"draws {r['draws_before']:>5}->{r['draws_after']:<3} "
f"contract {','.join(r['contract_after']) or '-'}{flag}")
except Exception as e:
import traceback
traceback.print_exc()
errors.append({"id": spec.get("id"), "src": spec.get("src"), "error": str(e)})
print(f"ERR {spec.get('src')}: {e}")
json.dump({"results": results, "errors": errors},
open(os.path.join(OUT_DIR, "_rig_results.json"), "w"), indent=2)
broken = [r for r in results if not r["contract_ok"]]
print(f"\nRIG_DONE ok={len(results)} err={len(errors)} contract_broken={len(broken)} -> {OUT_DIR}")
main()