""" blender_create_clothing_refs.py Usage: tooling/blender --background --python tooling/blender_create_clothing_refs.py -- \\ Creates v0.2 placeholder reference clothing meshes for all 5 initial clothing items. Meshes are authored on average_m by importing the relevant body segments, joining them, and offsetting vertices outward along normals to simulate clothing thickness. This produces placeholder-quality geometry only — not final art. The geometry reads correctly at gameplay zoom and is sufficient to validate the Surface Deform pipeline and compositor integration. Arguments: bodies_dir Directory with one subdir per body type (e.g. client/assets/characters/bodies/) clothing_output_dir Root directory for clothing output (e.g. client/assets/characters/clothing/) Output per item in //: reference.glb -- placeholder clothing mesh on average_m geometry Items produced: coveralls_basic -- full-body work suit jacket_utility -- upper body outerwear pants_cargo -- lower body shirt_henley -- upper body inner boots_work -- foot slot Decisions: D-162 (clothing pre-baked per body type via Surface Deform) """ import sys import os import bpy import bmesh REFERENCE_BODY = "average_m" # Each item defines: # segments -- body segment GLBs (from average_m) to import and join # thickness -- outward vertex offset in metres (clothing thickness simulation) CLOTHING_ITEMS = { "coveralls_basic": { "description": "Full-body work suit", "segments": [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", "seg_hand_l", "seg_hand_r", "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", "seg_foot_l", "seg_foot_r", ], "thickness": 0.006, }, "jacket_utility": { "description": "Upper body outerwear", "segments": [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", "seg_arm_lower_l", "seg_arm_lower_r", ], "thickness": 0.008, # slightly thicker for outerwear }, "pants_cargo": { "description": "Lower body cargo trousers", "segments": [ "seg_leg_upper_l", "seg_leg_upper_r", "seg_leg_lower_l", "seg_leg_lower_r", ], "thickness": 0.006, }, "shirt_henley": { "description": "Upper body inner shirt", "segments": [ "seg_torso", "seg_torso_upper", "seg_arm_upper_l", "seg_arm_upper_r", ], "thickness": 0.004, # thinner for inner layer }, "boots_work": { "description": "Work boots (foot slot)", "segments": [ "seg_foot_l", "seg_foot_r", "seg_leg_lower_l", "seg_leg_lower_r", # boot shaft reaches up the lower leg ], "thickness": 0.010, # thicker for boots }, } def clear_scene(): """Remove all objects and purge orphan data.""" bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() bpy.ops.outliner.orphans_purge(do_recursive=True) def import_glb(path): """Import a GLB file. Returns newly added objects.""" before = set(bpy.context.scene.objects) bpy.ops.import_scene.gltf(filepath=path) return [o for o in bpy.context.scene.objects if o not in before] def offset_vertices_along_normals(obj, thickness): """ Move each vertex outward along its computed normal by `thickness` metres. Operates directly on mesh data -- no operators, reliable in headless mode. """ bm = bmesh.new() bm.from_mesh(obj.data) bm.verts.ensure_lookup_table() # Ensure normals are up to date bm.normal_update() for v in bm.verts: v.co += v.normal * thickness bm.to_mesh(obj.data) bm.free() obj.data.update() def export_glb(obj, output_path, armature=None): """Export a mesh (and optionally its armature) as GLB with skinning data.""" bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) if armature: armature.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.export_scene.gltf( filepath=output_path, use_selection=True, export_format='GLB', export_animations=False, export_yup=True, export_image_format='AUTO', export_texcoords=True, export_normals=True, export_skins=armature is not None, export_materials='EXPORT', ) def create_reference_mesh(item_id, config, bodies_dir, output_dir): """ Create the reference.glb for one clothing item on average_m geometry. Preserves bone weights from body segments so clothing deforms with the skeleton. Returns True on success, False on error. """ print(f"\n{'='*60}") print(f" Item: {item_id} — {config['description']}") clear_scene() average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY) imported_meshes = [] imported_armatures = [] missing_segments = [] for seg_name in config["segments"]: seg_path = os.path.join(average_m_dir, f"{seg_name}.glb") if not os.path.isfile(seg_path): missing_segments.append(seg_name) continue objs = import_glb(seg_path) meshes = [o for o in objs if o.type == 'MESH'] armatures = [o for o in objs if o.type == 'ARMATURE'] imported_meshes.extend(meshes) imported_armatures.extend(armatures) if missing_segments: print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}") if not imported_meshes: print(f" ERROR: No segment meshes could be imported for {item_id}") return False print(f" Imported {len(imported_meshes)} segments, " f"{len(imported_armatures)} armatures " f"({len(missing_segments)} missing)") # Use the first armature as the canonical one — all segments share the same rig canonical_armature = imported_armatures[0] if imported_armatures else None # Re-parent all meshes to the canonical armature (preserving bone weights) if canonical_armature: for m in imported_meshes: # Clear any existing parent m.parent = None m.matrix_world = m.matrix_world # preserve world transform # Set parent to canonical armature with Armature modifier m.parent = canonical_armature m.parent_type = 'OBJECT' # Ensure Armature modifier exists pointing to canonical armature has_armature_mod = False for mod in m.modifiers: if mod.type == 'ARMATURE': mod.object = canonical_armature has_armature_mod = True if not has_armature_mod: mod = m.modifiers.new(name="Armature", type='ARMATURE') mod.object = canonical_armature # Remove duplicate armatures (keep only canonical) for arm in imported_armatures[1:]: bpy.data.objects.remove(arm, do_unlink=True) # Join all segments into one mesh (vertex groups / bone weights are preserved by join) bpy.ops.object.select_all(action='DESELECT') for m in imported_meshes: if m.name in bpy.data.objects: m.select_set(True) bpy.context.view_layer.objects.active = imported_meshes[0] if len(imported_meshes) > 1: bpy.ops.object.join() clothing_obj = bpy.context.active_object clothing_obj.name = f"ref_{item_id}" vertex_count_before = len(clothing_obj.data.vertices) print(f" Mesh vertices: {vertex_count_before}") print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}") # Offset vertices outward to simulate clothing thickness thickness = config["thickness"] offset_vertices_along_normals(clothing_obj, thickness) print(f" Applied {thickness*1000:.1f}mm outward offset") # Export with armature so clothing is skinned os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, "reference.glb") export_glb(clothing_obj, output_path, armature=canonical_armature) print(f" Exported: {output_path}") return True # ------------------------------------------------------------------------- # Entry point # ------------------------------------------------------------------------- if __name__ == "__main__": argv = sys.argv if "--" not in argv: print("Usage: tooling/blender --background --python " "tooling/blender_create_clothing_refs.py -- " " ") sys.exit(1) args = argv[argv.index("--") + 1:] if len(args) < 2: print("ERROR: Provide and ") sys.exit(1) bodies_dir = args[0] clothing_output_dir = args[1] # Preflight: check average_m dir exists avg_m_dir = os.path.join(bodies_dir, REFERENCE_BODY) if not os.path.isdir(avg_m_dir): print(f"ERROR: Reference body directory not found: {avg_m_dir}") sys.exit(1) print(f"\nClothing Reference Mesh Creator (v0.2 placeholder)") print(f" Bodies dir: {bodies_dir}") print(f" Output dir: {clothing_output_dir}") print(f" Reference: {REFERENCE_BODY}") print(f" Items: {len(CLOTHING_ITEMS)}") results = {} for item_id, config in CLOTHING_ITEMS.items(): item_output_dir = os.path.join(clothing_output_dir, item_id) success = create_reference_mesh(item_id, config, bodies_dir, item_output_dir) results[item_id] = success # Summary print(f"\n{'='*60}") print(f"=== Clothing reference creation complete ===") ok_items = [k for k, v in results.items() if v] fail_items = [k for k, v in results.items() if not v] for item_id in CLOTHING_ITEMS: status = "OK" if results[item_id] else "FAILED" print(f" {item_id:20s} {status}") print(f"\n OK: {len(ok_items)} FAILED: {len(fail_items)}") if fail_items: sys.exit(1)