#!/usr/bin/env python3 """Render ONE staged scene — a prop (or a group of props on a bench) seen from player eye height. WHY THIS EXISTS (R42 §42.2, the DJ-booth diagnosis) --------------------------------------------------- `contact_sheet.py` answers "is this metre-correct and what colour is it" from a tilted ortho camera over a white sweep. It cannot answer "does this READ as a turntable from where the player stands", because that question is about contrast *against the thing it sits on*, at a perspective camera at 1.6 m, at the size it occupies on screen. The R41 booth fault ("a plain black box") is exactly that class of question, and diagnosing it needs the asset rendered THREE ways — alone on neutral, on the dark bench Lane C actually builds, and at the room's light level — so the cause can be attributed to the asset, the composition or the lighting instead of guessed at. BL=/Applications/Blender.app/Contents/MacOS/Blender "$BL" --background --python pipeline/isolate.py -- OUT.png [opts] GLB[@x,y,z,ry] ... Options (any order, before or after the file list): --res W,H output pixels (default 800,620) --bench W,D,H,HEX a bench under the props; props are planted ON it (default: none, floor) --wall HEX back-wall colour (default e8e2cf, a pale interior wall) --floor HEX floor colour (default 8a6a44) --cam D,EYE,YAW,AIM camera distance m, eye height m, yaw deg, aim height m (default 1.5,1.62,18,0.95) --exposure F film exposure (default 1.0) --lamp E ceiling lamp energy in W (default 34) A GLB may carry a placement suffix: `path.glb@x,y,z,ry` (metres, degrees). `y` is measured from the bench top when a bench is present, otherwise from the floor. View transform is **Standard**, for the same reason `contact_sheet.py` uses it: this is a colour judgement and AgX is a film emulation that bleaches exactly the pale props this round is about. """ import bpy, sys, os, math from mathutils import Vector ARGV = sys.argv[sys.argv.index("--") + 1:] OUT = ARGV[0] rest = ARGV[1:] RES = (800, 620) BENCH = None # (w, d, h, (r,g,b)) WALL = "e8e2cf" FLOOR = "8a6a44" CAM = (1.5, 1.62, 18.0, 0.95) EXPOSURE = 1.0 LAMP = 34.0 files = [] def hexcol(h): h = h.lstrip("#") s = [int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)] # sRGB -> linear, because Blender base colours are linear and a hex is not return tuple((c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4) for c in s) i = 0 while i < len(rest): a = rest[i] if a == "--res": RES = tuple(int(v) for v in rest[i + 1].split(",")); i += 2 elif a == "--bench": p = rest[i + 1].split(","); BENCH = (float(p[0]), float(p[1]), float(p[2]), p[3]); i += 2 elif a == "--wall": WALL = rest[i + 1]; i += 2 elif a == "--floor": FLOOR = rest[i + 1]; i += 2 elif a == "--cam": CAM = tuple(float(v) for v in rest[i + 1].split(",")); i += 2 elif a == "--exposure": EXPOSURE = float(rest[i + 1]); i += 2 elif a == "--lamp": LAMP = float(rest[i + 1]); i += 2 else: files.append(a); i += 1 def wipe(): for o in list(bpy.data.objects): bpy.data.objects.remove(o, do_unlink=True) def flat(name, rgb, rough=0.85, metal=0.0): m = bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes.get("Principled BSDF") b.inputs["Base Color"].default_value = (*rgb, 1) b.inputs["Roughness"].default_value = rough b.inputs["Metallic"].default_value = metal return m 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 main(): wipe() scn = bpy.context.scene top = BENCH[2] if BENCH else 0.0 # --- bench (the thing the props stand on — the R42 diagnosis needs it to be the real colour) --- if BENCH: w, d, h, hx = BENCH bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, h / 2)) bo = bpy.context.object bo.scale = (w, d, h) # primitive_cube_add(size=1) is already 1 m across bo.data.materials.append(flat("bench", hexcol(hx), 0.6)) # --- props ----------------------------------------------------------------------------------- placed = 0 for spec in files: path, _, place = spec.partition("@") if not path.lower().endswith(".glb"): continue x = y = z = ry = 0.0 if place: p = [float(v) for v in place.split(",")] x, y, z, ry = (p + [0, 0, 0, 0])[:4] 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) continue for r in roots: r.rotation_euler = (r.rotation_euler.x, r.rotation_euler.y, r.rotation_euler.z + math.radians(ry)) bpy.context.view_layer.update() lo, hi = bounds(meshes) cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2 for r in roots: r.location = (r.location.x + x - cx, r.location.y + z - cy, r.location.z - lo.z + top + y) placed += 1 bpy.context.view_layer.update() allm = [o for o in bpy.data.objects if o.type == 'MESH'] lo, hi = bounds(allm) # --- room shell ------------------------------------------------------------------------------- span = max(hi.x - lo.x, 3.0) * 3 + 4 bpy.ops.mesh.primitive_plane_add(size=span, location=(0, 0, -0.001)) bpy.context.object.data.materials.append(flat("floor", hexcol(FLOOR), 0.9)) wall_y = max(hi.y, 0.4) + 0.55 bpy.ops.mesh.primitive_plane_add(size=span, location=(0, wall_y, span / 2 - 0.4)) wl = bpy.context.object wl.rotation_euler = (math.radians(90), 0, 0) wl.data.materials.append(flat("wall", hexcol(WALL), 0.95)) # --- light: one warm ceiling fluoro + a soft fill, i.e. what an interior room actually has ---- scn.world.use_nodes = True scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.42, 0.44, 0.48, 1) scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.42 # The lamp is SIZED to the scene, not fixed: a 1.2 m softbox that flatters a 0.45 m turntable # burns a 5 m pub corner to white, and a colour judgement rendered through a blow-out is not a # colour judgement. Energy stays the caller's number; only the emitter area follows the set. span_x = max(hi.x - lo.x, 0.6) bpy.ops.object.light_add(type='AREA', location=(0, -0.45, 2.55)) lg = bpy.context.object lg.data.energy = LAMP lg.data.size = min(4.0, max(1.2, span_x * 0.55)) lg.data.color = (1.0, 0.96, 0.88) bpy.ops.object.light_add(type='AREA', location=(-span_x * 0.4 - 0.8, -1.9, 1.9)) fl = bpy.context.object fl.data.energy = LAMP * 0.30 fl.data.size = min(4.0, max(1.6, span_x * 0.5)) fl.rotation_euler = (math.radians(58), 0, math.radians(-38)) # --- camera: perspective, at eye height, looking at the gear ---------------------------------- dist, eye, yaw, aim = CAM yr = math.radians(yaw) cam_loc = Vector((math.sin(yr) * dist, -math.cos(yr) * dist, eye)) tgt = Vector((0, 0, aim)) bpy.ops.object.camera_add(location=cam_loc) cam = bpy.context.object cam.data.lens = 34 d = (tgt - cam_loc) # A Blender camera looks down its LOCAL -Z with +Y up. Hand-rolling the two Euler angles put the # first take of this tool at the sky and rendered a flat grey frame; `to_track_quat` is the only # spelling that is right for every yaw. cam.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() scn.camera = cam scn.view_settings.view_transform = 'Standard' scn.view_settings.look = 'None' scn.view_settings.exposure = math.log2(EXPOSURE) if EXPOSURE > 0 else 0 for e in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'): try: scn.render.engine = e break except TypeError: continue scn.render.film_transparent = False scn.render.resolution_x, scn.render.resolution_y = RES 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"ISOLATE {OUT} {placed}/{len(files)} props bench={BENCH}") main()