""" Fit Fantasy outfits to Quaternius Source tier body types. Uses Surface Deform to refit clothing from Regular body (native fit) to Superhero and Teen bodies. Regular gets a straight copy since the outfits were authored for it. Run via: reach blender run \ spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py \ -- source_bodies_dir: contains Regular_Male_FullBody.gltf, Superhero_Male_FullBody.gltf, etc. outfits_dir: contains Male_Peasant.gltf, Male_Ranger.gltf output_dir: fitted outfits per body type """ import bpy import sys import os argv = sys.argv argv = argv[argv.index("--") + 1:] if "--" in argv else [] if len(argv) < 3: print("Usage: -- ") sys.exit(1) BODIES_DIR = argv[0] OUTFITS_DIR = argv[1] OUTPUT_DIR = argv[2] os.makedirs(OUTPUT_DIR, exist_ok=True) # Body type mapping: our name -> Quaternius filename BODY_MAP = { "regular": "Regular_Male_FullBody.gltf", "superhero": "Superhero_Male_FullBody.gltf", "teen": "Teen_Male_FullBody.gltf", } # Find outfits OUTFITS = [] for f in sorted(os.listdir(OUTFITS_DIR)): if f.endswith(".gltf") and f.startswith("Male_"): OUTFITS.append(f) print("=== Fit outfits to Source tier bodies ===") print(f" Bodies: {BODIES_DIR}") print(f" Outfits: {OUTFITS}") print(f" Output: {OUTPUT_DIR}") def clear_scene(): bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() for c in list(bpy.data.collections): bpy.data.collections.remove(c) def import_gltf(path): before = set(bpy.data.objects) bpy.ops.import_scene.gltf(filepath=path) return list(set(bpy.data.objects) - before) def find_armature(objects): for obj in objects: if obj.type == 'ARMATURE': return obj return None def find_meshes(objects): return [obj for obj in objects if obj.type == 'MESH'] def find_largest_mesh(objects): meshes = find_meshes(objects) if not meshes: return None return max(meshes, key=lambda m: len(m.data.vertices)) for body_name, body_file in BODY_MAP.items(): body_path = os.path.join(BODIES_DIR, body_file) if not os.path.exists(body_path): print(f"\n SKIP {body_name}: {body_file} not found") continue out_dir = os.path.join(OUTPUT_DIR, body_name) os.makedirs(out_dir, exist_ok=True) for outfit_file in OUTFITS: outfit_name = os.path.splitext(outfit_file)[0] output_path = os.path.join(out_dir, f"{outfit_name}.glb") print(f"\n {body_name} / {outfit_name}...") clear_scene() # Import body body_objects = import_gltf(body_path) body_armature = find_armature(body_objects) body_mesh = find_largest_mesh(body_objects) if not body_armature or not body_mesh: print(f" ERROR: body import failed") continue print(f" Body: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)") # Import outfit outfit_path = os.path.join(OUTFITS_DIR, outfit_file) outfit_objects = import_gltf(outfit_path) outfit_armature = find_armature(outfit_objects) outfit_meshes = find_meshes(outfit_objects) if not outfit_meshes: print(f" ERROR: no outfit meshes") continue print(f" Outfit meshes: {len(outfit_meshes)}") # For regular body: outfits already fit, just re-export with this armature # For other bodies: Surface Deform to refit if body_name != "regular": for mi in outfit_meshes: # Apply Surface Deform to bind clothing to body surface bpy.context.view_layer.objects.active = mi mi.select_set(True) sd = mi.modifiers.new(name="SurfaceDeform", type='SURFACE_DEFORM') sd.target = body_mesh sd.falloff = 4.0 try: bpy.ops.object.surfacedeform_bind(modifier=sd.name) if sd.is_bound: bpy.ops.object.modifier_apply(modifier=sd.name) print(f" Surface Deform applied: {mi.name}") else: print(f" WARNING: Surface Deform bind failed for {mi.name}") mi.modifiers.remove(sd) except Exception as e: print(f" WARNING: Surface Deform error for {mi.name}: {e}") if sd.name in [m.name for m in mi.modifiers]: mi.modifiers.remove(sd) mi.select_set(False) # Reparent outfit meshes to body armature for mi in outfit_meshes: # Remove old armature modifier for mod in list(mi.modifiers): if mod.type == 'ARMATURE': mi.modifiers.remove(mod) # Parent to body armature mi.parent = body_armature mi.matrix_parent_inverse = body_armature.matrix_world.inverted() arm_mod = mi.modifiers.new(name="Armature", type='ARMATURE') arm_mod.object = body_armature # Re-transfer weights from body mesh bpy.context.view_layer.objects.active = mi mi.select_set(True) dt = mi.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER') dt.object = body_mesh dt.use_vert_data = True dt.data_types_verts = {'VGROUP_WEIGHTS'} dt.vert_mapping = 'POLYINTERP_NEAREST' dt.layers_vgroup_select_src = 'ALL' dt.layers_vgroup_select_dst = 'NAME' bpy.ops.object.datalayout_transfer(modifier=dt.name) bpy.ops.object.modifier_apply(modifier=dt.name) mi.select_set(False) # Hide body mesh, export outfit + armature body_mesh.hide_set(True) for obj in body_objects: if obj.type == 'MESH': obj.hide_set(True) # Remove outfit's original armature if outfit_armature: outfit_armature.hide_set(True) bpy.ops.object.select_all(action='DESELECT') body_armature.select_set(True) body_armature.hide_set(False) for mi in outfit_meshes: mi.select_set(True) mi.hide_set(False) bpy.context.view_layer.objects.active = body_armature bpy.ops.export_scene.gltf( filepath=output_path, export_format='GLB', use_selection=True, export_apply=False, export_animations=False, export_skins=True, ) size_kb = os.path.getsize(output_path) // 1024 print(f" Exported: {output_path} ({size_kb} KB)") print("\n=== Done ===") for body_name in BODY_MAP: for outfit_file in OUTFITS: outfit_name = os.path.splitext(outfit_file)[0] path = os.path.join(OUTPUT_DIR, body_name, f"{outfit_name}.glb") status = "[OK]" if os.path.exists(path) else "[MISSING]" print(f" {status} {body_name}/{outfit_name}.glb")