#!/usr/bin/env python3 """PROCITY ped contact sheet — the CAST tool (R42 §42.3, Lane E cast half). Why this exists and `contact_sheet.py` was not enough: the ped GLBs are Mixamo exports in CENTIMETRE units (bbox ~177 "metres" tall) and the game height-normalizes them at runtime in `rigs.js buildFigure()`. `contact_sheet.py` is an ABSOLUTE-scale, top-down-ish tool whose frame height is derived from the pre-scale bbox, so a 177-unit rig makes it emit a 50 000 px strip; and even fitted, a 58 degrees-from-vertical camera shows you the tops of heads. A cast audit is a question about FACES, CLOTHES and SILHOUETTES ("would this person look wrong on a suburban Sydney street in 1996"), so this tool does what the game does — normalize every rig to the same standing height — and shoots it at eye level, front and 3/4, like a casting sheet. BL=/Applications/Blender.app/Contents/MacOS/Blender "$BL" --background --python pipeline/ped_sheet.py -- OUT.png GLB [GLB ...] --cols N tiles per row (default 6) --tile N px per tile (default 320) --title TXT caption --height M normalized standing height (default 1.75, = rigs.js nominal) Every tile is the SAME normalized height, so the sheet answers "who is this" and deliberately does NOT answer "is the scale right" — that is the no-giants gate's job, and it measures the runtime figure, not the file. """ import bpy, sys, os, math from mathutils import Vector ARGV = sys.argv[sys.argv.index("--") + 1:] OUT, rest = ARGV[0], ARGV[1:] COLS, TILE, TITLE, HEIGHT = 6, 320, "", 1.75 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 == "--height": HEIGHT = float(rest[i + 1]); i += 2 else: files.append(a); i += 1 files = [f for f in files if f.lower().endswith(".glb")] ROWS = int(math.ceil(len(files) / COLS)) PITCH_X = HEIGHT * 0.80 # tile centres across PITCH_Y = HEIGHT * 1.45 # rows go BACK in world Y; the eye-level cam foreshortens little def wipe(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) 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 label(text, x, y, z, size, colour=(0.10, 0.10, 0.12, 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, z) ob.rotation_euler = (math.radians(90), 0, 0) # stand the text up, facing the camera m = bpy.data.materials.new("lblmat") m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") b.inputs["Base Color"].default_value = colour b.inputs["Roughness"].default_value = 1.0 ob.data.materials.append(m) bpy.context.scene.collection.objects.link(ob) def main(): wipe() scn = bpy.context.scene placed = 0 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) span = max(hi.z - lo.z, 1e-6) s = HEIGHT / span # what rigs.js buildFigure does, by bbox cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2 tx, ty = c * PITCH_X, -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) # Caption stands on the floor IN FRONT of the figure (camera sits at -Y), not under it — # a label at negative Z is occluded by the floor plane and renders as nothing, which is how # this tool's first three audit strips came back unlabelled. label(os.path.basename(path)[:-4][:24], tx, ty - PITCH_X * 0.46, HEIGHT * 0.012, HEIGHT * 0.055) placed += 1 W = (COLS - 1) * PITCH_X D = (ROWS - 1) * PITCH_Y # floor + backdrop bpy.ops.mesh.primitive_plane_add(size=max(W, D) * 6 + 40, location=(W / 2, -D / 2, -0.003)) for name, col in (("floor", (0.90, 0.90, 0.93, 1)),): m = bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") b.inputs["Base Color"].default_value = col b.inputs["Roughness"].default_value = 1.0 bpy.context.object.data.materials.append(m) scn.world.use_nodes = True scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.93, 0.94, 0.97, 1) scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.9 bpy.ops.object.light_add(type='SUN', location=(W / 2 + 4, 8, 10)) sun = bpy.context.object sun.data.energy = 2.6 sun.rotation_euler = (math.radians(58), 0, math.radians(198)) bpy.ops.object.light_add(type='SUN', location=(W / 2 - 6, 6, 6)) fill = bpy.context.object fill.data.energy = 1.1 fill.rotation_euler = (math.radians(66), 0, math.radians(150)) # Standard view transform, same reason contact_sheet.py gives: a film emulation would # desaturate the clothing this sheet exists to judge. scn.view_settings.view_transform = 'Standard' scn.view_settings.look = 'None' # ORTHO camera at chest height, looking down the +Y axis (rows recede away from it). # glTF import puts the character's front along -Y in Blender, so this sees FACES. tgt = Vector((W / 2, -D / 2, HEIGHT * 0.46)) dist = (max(W, D) + 10) * 2 + 12 bpy.ops.object.camera_add(location=(tgt.x, tgt.y - dist, tgt.z + dist * 0.16)) cam = bpy.context.object cam.data.type = 'ORTHO' cam.data.clip_end = dist * 5 cam.rotation_euler = (math.radians(81), 0, 0) scn.camera = cam need_w = COLS * PITCH_X need_h = D * 0.36 + HEIGHT * 1.30 + (HEIGHT * 0.30 if TITLE else 0.0) cam.data.ortho_scale = max(need_w, need_h * (need_w / max(need_h, 1e-6)) * 0) or need_w cam.data.ortho_scale = need_w res_x = int(COLS * TILE) res_y = max(TILE // 2, int(res_x * need_h / need_w)) if TITLE: label(TITLE, W / 2, -D - PITCH_Y * 0.30, HEIGHT * 1.16, HEIGHT * 0.085, (0.06, 0.06, 0.10, 1)) scn.render.resolution_x = res_x scn.render.resolution_y = res_y scn.render.resolution_percentage = 100 scn.render.image_settings.file_format = 'PNG' scn.render.filepath = OUT # Blender 5.x renamed the realtime engine back to BLENDER_EEVEE; 4.2-4.5 called it # BLENDER_EEVEE_NEXT. Pick whichever this build actually offers rather than hard-coding one. avail = {e.identifier for e in bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items} for eng in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT', 'CYCLES'): if eng in avail: scn.render.engine = eng break try: scn.eevee.taa_render_samples = 24 except Exception: pass bpy.ops.render.render(write_still=True) print(f"PED SHEET {OUT} {placed}/{len(files)} rendered {COLS}x{ROWS} h={HEIGHT}m") main()