Files
settled-reach/tooling/blender_create_clothing_refs.py
T
jpmschweitzerandClaude Opus 4.6 093b24e38e feat(assets): face-based segmentation pipeline + solidify for hair/clothing
Segmentation:
- Face-based exclusive assignment (no overlap, no caps needed)
- Hips split from torso (pelvis as own segment)
- Torso_upper independent (spine_03 + clavicle, not a variant)
- Clavicle moved from arm_upper to torso_upper

Hair pipeline:
- Solidify modifier (20mm) for scalp hair volume
- Normal recalculation before solidify for consistent direction
- Eyebrows/facial hair NOT solidified
- Fixed mask generation (was outputting black, now white)

Clothing pipeline:
- Solidify modifier (25mm) for clothing thickness
- Peasant shoes added from Fantasy pack

Tooling:
- convert_outfit.py with solidify support
- inspect_glb.py, check_hair_symmetry.py for debugging
- Segment reference distribution locked
- Q-063 footsteps VFX filed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 17:43:04 +01:00

293 lines
10 KiB
Python

"""
blender_create_clothing_refs.py
Usage:
tooling/blender --background --python tooling/blender_create_clothing_refs.py -- \\
<bodies_dir> <clothing_output_dir>
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 <clothing_output_dir>/<item_id>/:
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 -- "
"<bodies_dir> <clothing_output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <bodies_dir> and <clothing_output_dir>")
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)