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>
232 lines
9.9 KiB
Python
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()
|