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>
459 lines
16 KiB
Python
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)
|