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>
264 lines
10 KiB
Python
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()
|