"""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()