""" blender_surface_deform_batch.py Usage: tooling/blender --background --python tooling/blender_surface_deform_batch.py -- \\ Fits a reference clothing GLB (authored on average_m) to all 11 body types via Blender's Surface Deform modifier and writes fitted GLBs to the output directory. Arguments: reference_glb Path to the reference clothing mesh (modeled on average_m). bodies_dir Directory containing one subdirectory per body type, each with seg_*.glb segment files (e.g. client/assets/characters/bodies/). output_dir Directory to write fitted variants. Created if it does not exist. Output per run: /thin_m.glb /thin_f.glb /average_m.glb <- direct copy of reference_glb /average_f.glb /muscular_m.glb /muscular_f.glb /teen_m.glb /teen_f.glb /heavy_m.glb /heavy_f.glb /child.glb Headless Surface Deform reliability: MEDIUM. The modifier bind operator requires an active object context. This script applies a context override (bpy.context.temp_override) to satisfy the operator. If the bind fails (is_bound == False after the attempt), the script falls back to a Shrinkwrap ON_SURFACE projection and logs a WARNING. The Shrinkwrap fallback produces acceptable results for most garments but may cause pinching at extremities on extreme body types (heavy_m, heavy_f). Visually verify all output variants at gameplay zoom before treating this pipeline as production-ready. See VERDICT.md open question: Surface Deform visual quality at extreme body types. UV layout: Surface Deform only moves vertex positions -- UVs are not altered. Shrinkwrap fallback also preserves UV layout. Decisions: D-162 (clothing pre-baked per body type via Surface Deform) """ import sys import os import json import shutil import bpy # ------------------------------------------------------------------------- # Constants # ------------------------------------------------------------------------- BODY_TYPES = [ "thin_m", "thin_f", "average_m", # reference copy -- no deform "average_f", "muscular_m", "muscular_f", "teen_m", "teen_f", "heavy_m", "heavy_f", "child", ] REFERENCE_BODY = "average_m" # Body segments to import for the deform surface. # Eyes and eyebrows are excluded -- they are tiny facial sub-objects that # do not affect clothing deformation. DEFORM_SEGMENTS = [ "seg_head", "seg_neck", "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", ] SHRINKWRAP_OFFSET = 0.002 # Small offset to avoid z-fighting (metres) # ------------------------------------------------------------------------- # Scene helpers # ------------------------------------------------------------------------- def clear_scene(): """Remove all objects from the current scene.""" bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() # Also purge orphan mesh/material data to avoid memory bloat in batch runs bpy.ops.outliner.orphans_purge(do_recursive=True) def import_glb(path): """Import a GLB file. Returns newly created 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 get_meshes(objects): """Filter a list of objects to mesh-type only.""" return [o for o in objects if o.type == 'MESH'] def export_glb(obj, output_path): """ Export a single mesh object as GLB. Preserves UVs, normals, and materials. No armature or animations. """ bpy.ops.object.select_all(action='DESELECT') obj.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=False, export_materials='EXPORT', ) # ------------------------------------------------------------------------- # Body surface construction # ------------------------------------------------------------------------- def build_body_surface(bodies_dir, body_type): """ Import and join body segments for the given body type into a single mesh object suitable for Surface Deform binding. Returns the joined mesh object, or None if the body type directory is missing or contains no usable segments. """ body_dir = os.path.join(bodies_dir, body_type) if not os.path.isdir(body_dir): print(f" WARNING: Body type directory not found: {body_dir}") return None imported = [] missing = [] for seg_name in DEFORM_SEGMENTS: seg_path = os.path.join(body_dir, f"{seg_name}.glb") if not os.path.isfile(seg_path): missing.append(seg_name) continue objs = import_glb(seg_path) imported.extend(get_meshes(objs)) if missing: print(f" NOTE: {len(missing)} segments missing for {body_type} " f"(non-fatal): {', '.join(missing)}") if not imported: print(f" ERROR: No segment meshes imported for {body_type}") return None print(f" Imported {len(imported)} segments for {body_type}") # Select all imported meshes and join them into one bpy.ops.object.select_all(action='DESELECT') for obj in imported: obj.select_set(True) bpy.context.view_layer.objects.active = imported[0] bpy.ops.object.join() body_surface = bpy.context.active_object body_surface.name = f"body_surface_{body_type}" return body_surface # ------------------------------------------------------------------------- # Surface Deform fitting # ------------------------------------------------------------------------- def try_surface_deform_bind(clothing_obj, body_surface, mod_name): """ Attempt to bind the Surface Deform modifier using a context override. Returns True if bind succeeded, False otherwise. Headless workaround: bpy.ops.object.surfacedeform_bind() requires an active viewport window context. In --background mode, we construct a temporary override that satisfies the operator's active_object requirement. Blender 3.6+ supports this via bpy.context.temp_override(). """ clothing_obj.select_set(True) bpy.context.view_layer.objects.active = clothing_obj try: with bpy.context.temp_override( active_object=clothing_obj, object=clothing_obj, selected_objects=[clothing_obj], ): result = bpy.ops.object.surfacedeform_bind(modifier=mod_name) mod = clothing_obj.modifiers.get(mod_name) if mod and mod.is_bound: print(" Surface Deform bind: SUCCESS") return True else: print(f" Surface Deform bind: operator returned {result}, " f"is_bound=False — bind did not complete") return False except Exception as exc: print(f" Surface Deform bind: EXCEPTION — {exc}") return False def apply_shrinkwrap_fallback(clothing_obj, body_surface): """ Fallback fitting via Shrinkwrap ON_SURFACE projection. Less accurate than Surface Deform (no barycentric interpolation) but reliable in headless mode. May cause pinching at extremities. UV layout is preserved. """ print(" Using Shrinkwrap fallback (Surface Deform bind failed)") sw = clothing_obj.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP') sw.target = body_surface sw.wrap_method = 'NEAREST_SURFACEPOINT' sw.wrap_mode = 'ON_SURFACE' sw.offset = SHRINKWRAP_OFFSET bpy.ops.object.select_all(action='DESELECT') clothing_obj.select_set(True) bpy.context.view_layer.objects.active = clothing_obj bpy.ops.object.modifier_apply(modifier=sw.name) def fit_clothing_to_body(clothing_obj, body_surface): """ Fit clothing_obj to body_surface using Surface Deform, with Shrinkwrap fallback. Returns the method used ('surface_deform' or 'shrinkwrap'). """ # Add Surface Deform modifier mod = clothing_obj.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM') mod.target = body_surface mod_name = mod.name bound = try_surface_deform_bind(clothing_obj, body_surface, mod_name) if bound: # Apply the Surface Deform modifier bpy.ops.object.select_all(action='DESELECT') clothing_obj.select_set(True) bpy.context.view_layer.objects.active = clothing_obj bpy.ops.object.modifier_apply(modifier=mod_name) return 'surface_deform' else: # Remove the failed Surface Deform modifier before falling back clothing_obj.modifiers.remove(mod) apply_shrinkwrap_fallback(clothing_obj, body_surface) return 'shrinkwrap' # ------------------------------------------------------------------------- # Main per-body-type processing # ------------------------------------------------------------------------- def process_body_type(body_type, reference_glb, bodies_dir, output_dir): """ Process a single body type. Returns a result dict with status info. """ output_path = os.path.join(output_dir, f"{body_type}.glb") print(f"\n{'='*60}") print(f" Body type: {body_type}") # average_m is a direct copy -- no deform required if body_type == REFERENCE_BODY: print(" Reference body type -- copying reference directly") shutil.copy2(reference_glb, output_path) return {"body_type": body_type, "status": "ok", "method": "copy"} clear_scene() # --- Import reference clothing --- print(f" Importing reference clothing: {os.path.basename(reference_glb)}") clothing_objs = import_glb(reference_glb) clothing_meshes = get_meshes(clothing_objs) if not clothing_meshes: return {"body_type": body_type, "status": "error", "error": "No mesh found in reference GLB"} # If reference GLB contains multiple meshes, join them if len(clothing_meshes) > 1: bpy.ops.object.select_all(action='DESELECT') for m in clothing_meshes: m.select_set(True) bpy.context.view_layer.objects.active = clothing_meshes[0] bpy.ops.object.join() clothing_obj = bpy.context.active_object clothing_obj.name = "clothing_ref" print(f" Clothing mesh: {len(clothing_obj.data.vertices)} vertices") # --- Build body surface --- print(" Building body surface from segments...") body_surface = build_body_surface(bodies_dir, body_type) if body_surface is None: return {"body_type": body_type, "status": "error", "error": "Could not construct body surface"} print(f" Body surface: {len(body_surface.data.vertices)} vertices") # --- Fit clothing to body --- method = fit_clothing_to_body(clothing_obj, body_surface) # --- Export fitted clothing --- # Delete body surface first so only clothing exports bpy.data.objects.remove(body_surface, do_unlink=True) # Re-get clothing obj (still active after modifier apply) clothing_obj = bpy.context.active_object print(f" Exporting to {os.path.basename(output_path)}") export_glb(clothing_obj, output_path) return {"body_type": body_type, "status": "ok", "method": method} # ------------------------------------------------------------------------- # Entry point # ------------------------------------------------------------------------- if __name__ == "__main__": argv = sys.argv if "--" not in argv: print("Usage: tooling/blender --background --python " "tooling/blender_surface_deform_batch.py -- " " ") sys.exit(1) args = argv[argv.index("--") + 1:] if len(args) < 3: print("ERROR: Provide , , and ") sys.exit(1) reference_glb = args[0] bodies_dir = args[1] output_dir = args[2] # Preflight checks if not os.path.isfile(reference_glb): print(f"ERROR: Reference GLB not found: {reference_glb}") sys.exit(1) if not os.path.isdir(bodies_dir): print(f"ERROR: Bodies directory not found: {bodies_dir}") sys.exit(1) os.makedirs(output_dir, exist_ok=True) # Validate that body type directories are present missing_types = [ bt for bt in BODY_TYPES if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt)) ] if missing_types: print(f"\nWARNING: Missing body type directories (will skip): " f"{', '.join(missing_types)}") print("\nSurface Deform Batch Pipeline") print(f" Reference: {reference_glb}") print(f" Bodies: {bodies_dir}") print(f" Output: {output_dir}") print(f" Types: {len(BODY_TYPES)}") results = [] for body_type in BODY_TYPES: if body_type != REFERENCE_BODY and \ not os.path.isdir(os.path.join(bodies_dir, body_type)): results.append({"body_type": body_type, "status": "skipped", "error": "body type directory missing"}) continue result = process_body_type(body_type, reference_glb, bodies_dir, output_dir) results.append(result) # Summary print(f"\n{'='*60}") print("=== Surface Deform batch complete ===") ok = [r for r in results if r["status"] == "ok"] errors = [r for r in results if r["status"] == "error"] skipped = [r for r in results if r["status"] == "skipped"] sd_count = len([r for r in ok if r.get("method") == "surface_deform"]) sw_count = len([r for r in ok if r.get("method") == "shrinkwrap"]) copy_count = len([r for r in ok if r.get("method") == "copy"]) for r in results: status = r["status"].upper() method = r.get("method", "") error = r.get("error", "") if method: print(f" {r['body_type']:15s} {status:8s} [{method}]") elif error: print(f" {r['body_type']:15s} {status:8s} {error}") else: print(f" {r['body_type']:15s} {status:8s}") print(f"\n OK: {len(ok)} " f"(surface_deform={sd_count}, shrinkwrap={sw_count}, copy={copy_count})") if errors: print(f" ERRORS: {len(errors)}") for r in errors: print(f" {r['body_type']}: {r.get('error', '?')}") if skipped: print(f" SKIPPED: {len(skipped)}") if sw_count > 0: print(f"\n WARNING: {sw_count} body type(s) used Shrinkwrap fallback.") print(" Visually verify these variants at gameplay zoom — Shrinkwrap") print(" may produce pinching at extremities on extreme body types.") # Write pipeline_log.json for provenance tracking log_path = os.path.join(output_dir, "pipeline_log.json") log_data = { "reference": os.path.basename(reference_glb), "bodies_dir": bodies_dir, "variants": { r["body_type"]: { "status": r["status"], "method": r.get("method", None), "error": r.get("error", None), } for r in results }, } with open(log_path, 'w') as f: json.dump(log_data, f, indent=2) print(f"\n Pipeline log written to {log_path}") if errors: sys.exit(1)