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

232 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Render a labelled contact sheet of many GLBs in ONE Blender run — the eyeball tool.
The house rule is "always eyeball the thumbnails", and R41 §41.2 imports ~150 assets across three
libraries. One Blender startup per asset would cost more than the work; this lays every GLB out on
a grid in a single scene, one camera, one render, with a Blender text label and a metre reference
under each. Blender startup is the slow part, so 48 assets cost about what 2 do.
Every tile is framed to the SAME world scale by default (`--absolute`), which is the only way a
contact sheet can answer "is this metre-correct" — a per-tile auto-zoom makes a 0.2 m stool and a
2 m fridge look identical, which is exactly how a wrong scale ships. A 1 m grid square sits under
each asset as the ruler.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/contact_sheet.py -- OUT.png GLB [GLB ...]
"$BL" --background --python pipeline/contact_sheet.py -- OUT.png --cols 8 --tile 320 DIR/*.glb
Options (after OUT.png, in any order among the file list):
--cols N tiles per row (default: ceil(sqrt(n)))
--tile N pixel size per tile (default 256)
--fit per-tile auto-zoom instead of one shared scale (looks prettier, lies about size)
--title TEXT caption drawn at the top of the sheet
"""
import bpy, sys, os, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT = ARGV[0]
rest = ARGV[1:]
COLS = TILE = None
FIT = False
TITLE = ""
files = []
i = 0
while i < len(rest):
a = rest[i]
if a == "--cols":
COLS = int(rest[i + 1]); i += 2
elif a == "--tile":
TILE = int(rest[i + 1]); i += 2
elif a == "--title":
TITLE = rest[i + 1]; i += 2
elif a == "--fit":
FIT = True; i += 1
else:
files.append(a); i += 1
TILE = TILE or 256
files = [f for f in files if f.lower().endswith(".glb")]
COLS = COLS or max(1, int(math.ceil(math.sqrt(len(files)))))
ROWS = int(math.ceil(len(files) / COLS))
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for c in list(bpy.data.collections):
bpy.data.collections.remove(c)
def 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])
if lo.x > 1e8:
return Vector((0, 0, 0)), Vector((0, 0, 0))
return lo, hi
def label(text, x, y, size, colour=(0.1, 0.1, 0.1, 1)):
cu = bpy.data.curves.new(type="FONT", name="lbl")
cu.body = text
cu.align_x = 'CENTER'
cu.size = size
ob = bpy.data.objects.new("lbl", cu)
ob.location = (x, y, 0)
m = bpy.data.materials.new("lblmat")
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = colour
bsdf.inputs["Roughness"].default_value = 1.0
ob.data.materials.append(m)
bpy.context.scene.collection.objects.link(ob)
return ob
def main():
wipe()
scn = bpy.context.scene
THETA = math.radians(58) # camera tilt from straight-down
PITCH = 1.0 # world metres between tile centres, ACROSS (x)
# Rows are separated along world Y, which the tilted camera foreshortens by cos(THETA) ~ 0.53,
# while object HEIGHT projects at sin(THETA) ~ 0.85. Using one pitch for both axes therefore
# stacks every tall asset on top of the row behind it. Space the rows out by the reciprocal.
PITCH_Y = PITCH / math.cos(THETA)
# --- import every GLB, park it on the grid ------------------------------------------------
placed = []
for n, path in enumerate(files):
r, c = divmod(n, COLS)
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=path)
except Exception as e:
print(f"IMPORT FAIL {path}: {e}")
continue
new = [o for o in bpy.data.objects if o not in before]
roots = [o for o in new if o.parent is None]
meshes = [o for o in new if o.type == 'MESH']
if not meshes:
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
print(f"NO MESH {path}")
continue
lo, hi = bounds(meshes)
size = max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z, 1e-4)
cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2
# scale: shared world scale (honest) or per-tile fit (pretty)
s = (PITCH * 0.62) / size if FIT else 1.0
tx, ty = c * PITCH, -r * PITCH_Y
for o in roots:
o.scale = (o.scale.x * s, o.scale.y * s, o.scale.z * s)
o.location = (o.location.x * s + tx - cx * s,
o.location.y * s + ty - cy * s,
o.location.z * s - lo.z * s)
placed.append((path, size, hi.z - lo.z, meshes))
label(os.path.basename(path)[:-4][:26], tx, ty - PITCH * 0.50, PITCH * 0.052)
label(f"{hi.x-lo.x:.2f} x {hi.y-lo.y:.2f} x {hi.z-lo.z:.2f} m",
tx, ty - PITCH * 0.60, PITCH * 0.042, (0.30, 0.30, 0.38, 1))
# --- a 1 m reference square under every tile (the ruler) -----------------------------------
if not FIT:
for n in range(len(placed)):
r, c = divmod(n, COLS)
bpy.ops.mesh.primitive_plane_add(size=1.0, location=(c * PITCH, -r * PITCH_Y, -0.002))
p = bpy.context.object
m = bpy.data.materials.new("grid")
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = (0.80, 0.80, 0.84, 1)
b.inputs["Roughness"].default_value = 1.0
p.data.materials.append(m)
# --- backdrop, light, camera ----------------------------------------------------------------
W = (COLS - 1) * PITCH
H = (ROWS - 1) * PITCH_Y
tall = max((t[2] for t in placed), default=1.0)
bpy.ops.mesh.primitive_plane_add(size=max(W, H) * 4 + 40, location=(W / 2, -H / 2, -0.004))
bg = bpy.context.object
m = bpy.data.materials.new("bg")
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = (0.94, 0.94, 0.96, 1)
b.inputs["Roughness"].default_value = 1.0
bg.data.materials.append(m)
scn.world.use_nodes = True
scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.9, 0.92, 0.96, 1)
scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.85
bpy.ops.object.light_add(type='SUN', location=(W / 2 + 3, -H / 2 - 4, 8 + tall))
sun = bpy.context.object
sun.data.energy = 1.9
sun.rotation_euler = (math.radians(52), 0, math.radians(38))
# A CONTACT SHEET IS A COLOUR JUDGEMENT, so it must not go through a film emulation.
# Blender's default view transform (AgX in 5.x) desaturates hard: the first pass of the
# art_incoming props read as bleached white and looked like the vertex-colour transfer had
# failed, when measuring COLOR_0 in the exported GLBs showed the means intact to within 3%.
# The tool was lying, not the asset. Standard = what you see is what is in the file.
scn.view_settings.view_transform = 'Standard'
scn.view_settings.look = 'None'
# Aim the camera AT the grid centre. A camera rotated theta about X looks along
# (0, sin theta, -cos theta), so it must sit back along the reverse of that ray from the target
# -- an arbitrary offset misses by t*sin(theta) and renders an empty backdrop (this tool's
# first take came back blank exactly that way).
tgt = Vector((W / 2, -H / 2 - PITCH * 0.15, PITCH * 0.20))
cam_d = (max(W, H) + tall) * 3.0 + 10.0
bpy.ops.object.camera_add(location=(tgt.x,
tgt.y - cam_d * math.sin(THETA),
tgt.z + cam_d * math.cos(THETA)))
cam = bpy.context.object
cam.data.type = 'ORTHO'
cam.data.clip_end = cam_d * 4
cam.rotation_euler = (THETA, 0, 0)
scn.camera = cam
# World extent the frame must contain. Screen-vertical mixes the ground span BETWEEN row
# centres ((ROWS-1) gaps, each PITCH_Y foreshortened by cos = PITCH) with the tallest object's
# height (x sin) and one label gutter. Counting a gutter PER ROW instead of between rows
# over-estimates badly at ROWS=1 and renders everything as postage stamps.
need_w = COLS * PITCH * 1.02
need_h = (ROWS - 1) * PITCH + tall * math.sin(THETA) + PITCH * 0.75 \
+ (PITCH * 0.42 if TITLE else 0.0)
res_x = int(COLS * TILE)
res_y = max(TILE // 2, int(res_x * need_h / need_w))
# ortho_scale governs the LARGER pixel dimension; solve so BOTH fit.
if res_x >= res_y:
cam.data.ortho_scale = max(need_w, need_h * res_x / res_y)
else:
cam.data.ortho_scale = max(need_h, need_w * res_y / res_x)
if TITLE:
# well clear of row 0: tall assets project upward in screen space from y=0, so a title
# parked at +0.6 m lands on top of them.
label(TITLE, W / 2, PITCH_Y * 0.72 + tall, PITCH * 0.085, (0.05, 0.05, 0.1, 1))
# Blender 5.x renamed the EEVEE enum back to BLENDER_EEVEE; keep both spellings working.
engines = scn.bl_rna.properties["render"].fixed_type.properties["engine"].enum_items.keys() \
if False else ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT')
for e in engines:
try:
scn.render.engine = e
break
except TypeError:
continue
scn.render.film_transparent = False
scn.render.resolution_x = res_x
scn.render.resolution_y = res_y
scn.render.image_settings.file_format = 'PNG'
scn.render.filepath = os.path.abspath(OUT)
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
bpy.ops.render.render(write_still=True)
print(f"SHEET {OUT} {len(placed)}/{len(files)} rendered {COLS}x{ROWS}")
main()