#!/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()