Files
settled-reach/tooling/scripts/blender/blender_surface_deform_batch.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
A script scanned every tracked doc, rule, skill, agent, hook and source file
for tooling/ paths that no longer exist, skipping historical records (sprints,
discussions, workshops, governance, generated wiki pages). It found 62. The
ones that tell a reader what to RUN now name the reach verb:

- The atlas skill still sent agents to tooling/atlas, atlas-verify,
  atlas-update-field and atlas-commit-and-sync — about forty lines, all
  retired in T-1285. They now name the `reach atlas` verbs, and the skill
  records that commit-and-sync STAGES by default (--commit to commit) and
  takes --corridor as an option.
- The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si
  and clerk briefings sent those agents to the retired tooling/db/decision
  and sqlite-query CLIs and to decisions/*.md paths that moved to
  governance/ in the pql migration. They now name pql.
- The ticket-cli rule documented `pql decisions read`, which does not exist;
  `show` already includes the body.
- The culture authoring guide and the RON sources name
  `reach validate ron`, with the same arguments as before.
- The 41 Blender payloads' usage lines ran the retired tooling/blender
  wrapper, and the docstrings still cited pre-carve-out paths. They now read
  `reach blender run <payload>`.
- Doc comments in server/, client/, wiki TOMLs and the domain modules.

What is left is deliberate: "Formerly …" provenance, dated plans and findings
docs, the retired-pipeline doc, and a build-artefact path.

project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched
files are in the canvas-version registry (trait_catalog_reader.rs, since
T-1289, canvas_sources.py itself, and two client files). The gate is
path-based and has no override. The previous push was rejected on exactly
this.

Three of the edits are stamped ledger sources, so systems.db is regenerated
and the stamp is fresh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:13:58 +02:00

459 lines
16 KiB
Python

"""
blender_surface_deform_batch.py
Usage:
reach blender run blender_surface_deform_batch \\
<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: reach blender run blender_surface_deform_batch "
"<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)