Files
settled-reach/tooling/blender_surface_deform_batch.py
T
jpmschweitzerandClaude Fable 5 346d87df7a chore(meta): docs/build sweep + tooling test gate (T-1069, T-1066)
- make test-tooling: planet-gen determinism guard + import_economics
  --dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
  E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
  documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
  (import_economics sole generator since #951/D-223); dead check-protocol
  target deleted; DEVOPS hook/config sections rewritten from the actual
  hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
  refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
  cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
  relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:55 +02:00

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(" 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(" 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("\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("=== 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(" Visually verify these variants at gameplay zoom — Shrinkwrap")
print(" 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)