refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user