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>
113 lines
3.9 KiB
Python
113 lines
3.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a 256px manifest thumbnail for each GLB — many per Blender session.
|
|
|
|
Separate from `normalize.py`'s `render_thumb` on purpose: that one is part of the normalize pass
|
|
and shades with BLENDER_WORKBENCH `color_type='TEXTURE'`, which renders a **vertex-coloured,
|
|
material-less** mesh flat grey. Two of R41's three libraries are exactly that (the 3dstore
|
|
fittings after `merge_prims.py`, and the `art_incoming` props, which carry COLOR_0 and no material
|
|
block at all), so their thumbnails would have shipped colourless. This renders in EEVEE off the
|
|
real material graph, so what Lane C sees in the thumb is what the game draws.
|
|
|
|
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
|
"$BL" --background --python pipeline/thumbs.py -- OUT_DIR GLB [GLB ...]
|
|
"""
|
|
import bpy, sys, os, math
|
|
from mathutils import Vector
|
|
|
|
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
|
OUT_DIR = ARGV[0]
|
|
FILES = [f for f in ARGV[1:] if f.lower().endswith(".glb")]
|
|
RES = 256
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
|
|
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 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 setup_world():
|
|
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
|
|
bpy.context.scene.world = w
|
|
w.use_nodes = True
|
|
w.node_tree.nodes["Background"].inputs[0].default_value = (0.85, 0.87, 0.92, 1)
|
|
w.node_tree.nodes["Background"].inputs[1].default_value = 1.6
|
|
|
|
|
|
def main():
|
|
scn = bpy.context.scene
|
|
for e in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
|
|
try:
|
|
scn.render.engine = e
|
|
break
|
|
except TypeError:
|
|
continue
|
|
scn.render.film_transparent = True
|
|
scn.render.resolution_x = scn.render.resolution_y = RES
|
|
scn.render.image_settings.file_format = 'PNG'
|
|
scn.render.image_settings.color_mode = 'RGBA'
|
|
scn.view_settings.view_transform = 'Standard'
|
|
|
|
ok = 0
|
|
for path in FILES:
|
|
wipe()
|
|
setup_world()
|
|
try:
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
except Exception as ex:
|
|
print(f"IMPORT FAIL {path}: {ex}")
|
|
continue
|
|
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
|
|
if not meshes:
|
|
print(f"NO MESH {path}")
|
|
continue
|
|
lo, hi = bounds(meshes)
|
|
ctr = (lo + hi) / 2
|
|
size = max((hi - lo).x, (hi - lo).y, (hi - lo).z) or 1.0
|
|
|
|
bpy.ops.object.light_add(type='AREA',
|
|
location=(ctr.x + size, ctr.y - size, hi.z + size * 1.2))
|
|
L = bpy.context.object
|
|
L.data.energy = 260 * size * size + 40
|
|
L.data.size = size * 2.5
|
|
L.rotation_euler = (math.radians(40), 0, math.radians(35))
|
|
bpy.ops.object.light_add(type='AREA',
|
|
location=(ctr.x - size, ctr.y - size * 1.4, ctr.z))
|
|
L2 = bpy.context.object
|
|
L2.data.energy = 90 * size * size + 15
|
|
L2.data.size = size * 3
|
|
|
|
bpy.ops.object.camera_add()
|
|
cam = bpy.context.object
|
|
scn.camera = cam
|
|
cam.data.type = 'ORTHO'
|
|
cam.data.ortho_scale = size * 1.5
|
|
d = Vector((0.82, -1.0, 0.62)).normalized()
|
|
cam.location = ctr + d * (size * 4 + 2)
|
|
cam.rotation_euler = (-d).to_track_quat('-Z', 'Y').to_euler()
|
|
cam.data.clip_end = size * 20 + 50
|
|
|
|
scn.render.filepath = os.path.join(OUT_DIR, os.path.basename(path)[:-4] + ".png")
|
|
bpy.ops.render.render(write_still=True)
|
|
ok += 1
|
|
print(f"THUMBS_DONE {ok}/{len(FILES)} -> {OUT_DIR}")
|
|
|
|
|
|
main()
|