Files
settled-reach/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.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

212 lines
7.0 KiB
Python

"""
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:
tooling/blender --background --python \
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py \
-- <source_bodies_dir> <outfits_dir> <output_dir>
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: -- <source_bodies_dir> <outfits_dir> <output_dir>")
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")