#!/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))) --pitch M world metres between tile centres (default 1.0). Raise it when the set holds a 2.7 m beer umbrella next to a 0.2 m ashtray: at the default pitch the big asset overlaps its neighbours and the sheet stops being readable. --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 PITCH_OPT = 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 == "--pitch": PITCH_OPT = float(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 = PITCH_OPT or 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: # SOLVE for the title's world Y instead of guessing a metre offset. A camera rotated theta # about X puts a point at screen-vertical (Y - tgt.y)*cos(theta) + (z - tgt.z)*sin(theta); # the old "PITCH_Y*0.72 + tall" guess scaled with PITCH and silently walked off the top of # the frame at --pitch 2.9 (the 39-prop sheet rendered with no title at all and nobody # would have known). Target the screen offset the frame budget actually reserves. base = (0 - tgt.y) * math.cos(THETA) - tgt.z * math.sin(THETA) # row-0 ground line, in screen units want = base + tall * math.sin(THETA) + PITCH * 0.18 # clear the tallest asset yt = tgt.y + (want + tgt.z * math.sin(THETA)) / math.cos(THETA) label(TITLE, W / 2, yt, PITCH * 0.16, (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()