""" blender_process_heads.py Usage: tooling/blender --background --python tooling/blender_process_heads.py -- Processes the 4 Quaternius OnlyHead .blend files into the head template library. Source .blends (from ): Regular_Female_OnlyHead.blend -> head_001.glb + head_001_mask.png Regular_Male_OnlyHead.blend -> head_002.glb + head_002_mask.png Teen_Female_OnlyHead.blend -> head_003.glb + head_003_mask.png Teen_Male_OnlyHead.blend -> head_004.glb + head_004_mask.png Output: / (typically client/assets/characters/heads/templates/) Requirements (D-161, architecture doc): - GLB: BoneAttachment3D-compatible, no animation data, embedded textures - Scale: 1 unit = 1 meter, Y-up, -Z forward (glTF standard) - Mask PNG: greyscale, white = tintable skin tone region (entire head surface = skin) """ import sys import os import bpy HEAD_MAPPING = [ ("Regular_Female_OnlyHead.blend", "head_001"), ("Regular_Male_OnlyHead.blend", "head_002"), ("Teen_Female_OnlyHead.blend", "head_003"), ("Teen_Male_OnlyHead.blend", "head_004"), ] MASK_RESOLUTION = 1024 # Default; overridden if texture found in .blend def process_head(blend_path: str, output_dir: str, head_id: str) -> None: glb_path = os.path.join(output_dir, head_id + ".glb") mask_path = os.path.join(output_dir, head_id + "_mask.png") print(f"\n--- Processing: {os.path.basename(blend_path)}") print(f" GLB -> {glb_path}") print(f" Mask -> {mask_path}") # Load the .blend file bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.open_mainfile(filepath=blend_path) all_objects = list(bpy.context.scene.objects) print(f" Imported {len(all_objects)} objects:") for obj in all_objects: print(f" {obj.name} ({obj.type})") meshes = [obj for obj in all_objects if obj.type == 'MESH'] if not meshes: print("ERROR: No mesh found in the .blend file") sys.exit(1) # Detect texture resolution for mask sizing. # NOTE: This picks the first non-HDR texture found in bpy.data.images, which is # fragile — iteration order is not guaranteed and multiple textures may be present. # If no texture is found, falls back to MASK_RESOLUTION (512x512 default) so the # mask is still generated at a sensible size rather than failing. mask_w = mask_h = MASK_RESOLUTION for img in bpy.data.images: if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'): print(f" Texture found: {img.name} — {img.size[0]}x{img.size[1]}") mask_w, mask_h = img.size[0], img.size[1] break else: print(f" No texture found — using fallback mask size {mask_w}x{mask_h}") # Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them) for obj in all_objects: if obj.animation_data: obj.animation_data_clear() for action in list(bpy.data.actions): bpy.data.actions.remove(action) # Note: WGT-* rig widget meshes are automatically excluded by the GLTF exporter # (they have no materials/geometry that exports). No deletion needed. # Select all objects for export bpy.ops.object.select_all(action='SELECT') # Export GLB — embedded textures, no animations print(" Exporting GLB...") bpy.ops.export_scene.gltf( filepath=glb_path, use_selection=True, export_format='GLB', export_animations=False, export_yup=True, export_image_format='AUTO', # embed textures (AUTO embeds for GLB) export_texcoords=True, export_normals=True, export_materials='EXPORT', ) print(f" GLB exported ({os.path.getsize(glb_path):,} bytes)") # Generate mask PNG — solid white: entire head surface is skin-tone tintable # Format: greyscale (R channel), white = tintable, black = preserve # Using RGBA internally; Blender PNG export honours this correctly. mask_img = bpy.data.images.new( name=head_id + "_mask", width=mask_w, height=mask_h, alpha=False, float_buffer=False, ) # Fill with solid white (RGBA 1.0 per channel) mask_img.pixels = [1.0] * (mask_w * mask_h * 4) mask_img.colorspace_settings.name = 'Non-Color' mask_img.file_format = 'PNG' mask_img.filepath_raw = mask_path mask_img.save() print(f" Mask saved ({mask_w}x{mask_h}, solid white = all-skin)") if __name__ == "__main__": argv = sys.argv if "--" not in argv: print("Usage: tooling/blender --background --python tooling/blender_process_heads.py -- ") sys.exit(1) args = argv[argv.index("--") + 1:] if len(args) < 2: print("ERROR: Provide and ") sys.exit(1) source_dir = args[0] output_dir = args[1] os.makedirs(output_dir, exist_ok=True) for blend_filename, head_id in HEAD_MAPPING: blend_path = os.path.join(source_dir, blend_filename) if not os.path.exists(blend_path): print(f"ERROR: Source file not found: {blend_path}") sys.exit(1) process_head(blend_path, output_dir, head_id) print("\n=== All 4 head templates processed ===") for _, head_id in HEAD_MAPPING: glb = os.path.join(output_dir, head_id + ".glb") mask = os.path.join(output_dir, head_id + "_mask.png") print(f" {head_id}.glb ({os.path.getsize(glb):,} bytes)") print(f" {head_id}_mask.png ({os.path.getsize(mask):,} bytes)")