Files
settled-reach/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source_female.py
T
jpmschweitzerandClaude Opus 4.6 496953f69c feat(assets): add Quaternius Source tier bodies and fitted outfits
Source tier purchase provides 3 body types × 2 genders with .blend
source files: Regular (→Average), Superhero (→Muscular), Teen (→Thin).
Only Heavy body type still needs hand-authoring.

Surface Deform pipeline validated: clothing authored for Regular body
successfully fitted to Superhero and Teen bodies with zero bind
failures across all 12 outfit × body combinations.

Adds source-bodies/, outfits-fitted-source/, outfits-fitted-source-female/,
and Blender fitting scripts for both genders. Showcase updated with
8 body type entries and per-body-type fitted outfit scanning.

Resolves Q-062 (Source tier contents: Regular + Teen confirmed).
Partially resolves Q-060 (Surface Deform quality: binds succeed,
visual clipping is a compositor concern, not pipeline failure).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 14:22:26 +01:00

128 lines
5.2 KiB
Python

"""
Fit Fantasy outfits (Female) to Source tier female body types.
Same approach as fit_outfits_source.py but for Female_ outfits.
"""
import bpy, sys, os
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 3:
print("Usage: -- <source_bodies_dir> <outfits_dir> <output_dir>")
sys.exit(1)
BODIES_DIR, OUTFITS_DIR, OUTPUT_DIR = argv[0], argv[1], argv[2]
os.makedirs(OUTPUT_DIR, exist_ok=True)
BODY_MAP = {
"regular": "Regular_Female_FullBody.gltf",
"superhero": "Superhero_Female_FullBody.gltf",
"teen": "Teen_Female_FullBody.gltf",
}
OUTFITS = [f for f in sorted(os.listdir(OUTFITS_DIR)) if f.endswith(".gltf") and f.startswith("Female_")]
print(f"=== Fit female outfits ===\n Bodies: {BODIES_DIR}\n Outfits: {OUTFITS}\n 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)
return max(meshes, key=lambda m: len(m.data.vertices)) if meshes else None
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}: 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()
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(" ERROR: body import failed"); continue
outfit_objects = import_gltf(os.path.join(OUTFITS_DIR, outfit_file))
outfit_armature = find_armature(outfit_objects)
outfit_meshes = find_meshes(outfit_objects)
if not outfit_meshes:
print(" ERROR: no outfit meshes"); continue
if body_name != "regular":
for mi in outfit_meshes:
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: {mi.name}")
else:
print(f" WARNING: bind failed for {mi.name}")
mi.modifiers.remove(sd)
except Exception as e:
print(f" WARNING: {mi.name}: {e}")
if sd.name in [m.name for m in mi.modifiers]: mi.modifiers.remove(sd)
mi.select_set(False)
for mi in outfit_meshes:
for mod in list(mi.modifiers):
if mod.type == 'ARMATURE': mi.modifiers.remove(mod)
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
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)
for obj in body_objects:
if obj.type == 'MESH': obj.hide_set(True)
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)
print(f" Exported: {os.path.getsize(output_path) // 1024} KB")
print("\n=== Done ===")
for bn in BODY_MAP:
for of in OUTFITS:
p = os.path.join(OUTPUT_DIR, bn, os.path.splitext(of)[0] + ".glb")
print(f" {'[OK]' if os.path.exists(p) else '[MISSING]'} {bn}/{os.path.splitext(of)[0]}.glb")