Warning fix: - Remove torso_upper from hides in 3 coverage.json files (coveralls_basic, jacket_utility, shirt_henley) and the generator script — redundant when torso_variant is "full" Suggestions addressed: - Add pants_cargo authoring note about foot segment distinction - Add pipeline_log.json provenance output to Surface Deform batch pipeline (tracks method per variant: surface_deform/shrinkwrap/copy) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""
|
|
blender_surface_deform_batch.py
|
|
|
|
Usage:
|
|
tooling/blender --background --python tooling/blender_surface_deform_batch.py -- \\
|
|
<reference_glb> <bodies_dir> <output_dir>
|
|
|
|
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:
|
|
<output_dir>/thin_m.glb
|
|
<output_dir>/thin_f.glb
|
|
<output_dir>/average_m.glb <- direct copy of reference_glb
|
|
<output_dir>/average_f.glb
|
|
<output_dir>/muscular_m.glb
|
|
<output_dir>/muscular_f.glb
|
|
<output_dir>/teen_m.glb
|
|
<output_dir>/teen_f.glb
|
|
<output_dir>/heavy_m.glb
|
|
<output_dir>/heavy_f.glb
|
|
<output_dir>/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(f" 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(f" 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 -- "
|
|
"<reference_glb> <bodies_dir> <output_dir>")
|
|
sys.exit(1)
|
|
|
|
args = argv[argv.index("--") + 1:]
|
|
if len(args) < 3:
|
|
print("ERROR: Provide <reference_glb>, <bodies_dir>, and <output_dir>")
|
|
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(f"\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(f"=== 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(f" Visually verify these variants at gameplay zoom — Shrinkwrap")
|
|
print(f" 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)
|