Files
settled-reach/tooling/scripts/blender/blender_batch_fit_skinned.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

445 lines
16 KiB
Python

"""
blender_batch_fit_skinned.py (T-1089 gap G1 — the biggest wardrobe gap)
Fit a reference clothing GLB (authored on average_m) to every body type and
export ANIMATABLE skinned variants. This supersedes
tooling/scripts/blender/blender_surface_deform_batch.py, whose output used export_skins=False
(:142) — those variants cannot animate on the shared skeleton, which is how the
runtime loads clothing (character_visual.gd:582-594).
It merges three proven codepaths that had never been combined:
* the 11-body loop + headless temp_override Surface-Deform bind + Shrinkwrap
fallback + pipeline_log.json — from blender_surface_deform_batch.py
* the weight flow — from
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_to_bodies.py:160-257
Surface Deform bind -> apply -> Data Transfer VGROUP_WEIGHTS
(POLYINTERP_NEAREST) from the fitted body -> normalize -> retarget the
armature modifier to the body's armature -> export_skins=True
* optional Solidify — from blender_convert_outfit.py (skipped by
default; offset-shell references are already solidified)
Per body type:
average_m (REFERENCE_BODY): direct copy of the reference GLB (already correct).
others: SD-bind the reference garment to the target body surface, bake the
deformed rest shape, transfer + normalise weights from that body, retarget the
armature, export a skinned GLB.
Run:
reach blender run blender_batch_fit_skinned \
<reference_glb> <bodies_dir> <output_dir> \
[--only average_m,average_f,...] [--solidify 0.0] [--self-check]
Output:
<output_dir>/<body_type>.glb one skinned variant per fitted body type
<output_dir>/pipeline_log.json per-variant method (surface_deform/shrinkwrap/
copy) + status, for the review gate
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
"""
import sys
import os
import json
import glob
import shutil
import bpy
BODY_TYPES = [
"thin_m", "thin_f", "average_m", "average_f", "muscular_m", "muscular_f",
"teen_m", "teen_f", "heavy_m", "heavy_f", "child",
]
REFERENCE_BODY = "average_m"
SHRINKWRAP_OFFSET = 0.002
SD_FALLOFF = 4.0 # generous — clothing sits proud of the body (fit_outfits :212)
def log(msg):
print(f"[batch-fit] {msg}")
# --------------------------------------------------------------------------
# Scene helpers
# --------------------------------------------------------------------------
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
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 is_body_mesh(obj):
"""Skinned body-segment mesh — excludes Icosphere debris that rides in GLBs."""
return (
obj.type == 'MESH'
and not obj.name.startswith("Icosphere")
and len(obj.vertex_groups) > 0
and len(obj.data.vertices) >= 50
)
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
# --------------------------------------------------------------------------
# Body surface (join of skinned segments) + one target armature
# --------------------------------------------------------------------------
def build_body(bodies_dir, body_type):
"""Import all seg_*.glb for a body; return (body_surface_mesh, armature).
The surface is a join of the skinned segment meshes (keeps merged vertex
groups so it can serve as both the Surface-Deform target and the weight
source). One armature is kept as the retarget destination; extras dropped.
"""
body_dir = os.path.join(bodies_dir, body_type)
if not os.path.isdir(body_dir):
return None, None
seg_paths = sorted(glob.glob(os.path.join(body_dir, "seg_*.glb")))
meshes = []
armature = None
for p in seg_paths:
for o in import_glb(p):
if o.type == 'ARMATURE' and armature is None:
armature = o
elif o.type == 'ARMATURE':
bpy.data.objects.remove(o, do_unlink=True)
elif is_body_mesh(o):
meshes.append(o)
elif o.type == 'MESH':
bpy.data.objects.remove(o, do_unlink=True)
if not meshes or armature is None:
return None, None
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
surface = bpy.context.active_object
surface.name = f"body_surface_{body_type}"
# Detach from armature so it is pure geometry for SD/weight source.
for mod in list(surface.modifiers):
if mod.type == 'ARMATURE':
surface.modifiers.remove(mod)
return surface, armature
# --------------------------------------------------------------------------
# Reference garment
# --------------------------------------------------------------------------
def import_reference_garment(reference_glb):
"""Import the reference garment; return its single skinned mesh + armature."""
objs = import_glb(reference_glb)
meshes = [o for o in objs if is_body_mesh(o)]
if not meshes:
# offset-shell garments always have vgroups; guard anyway
meshes = [o for o in objs if o.type == 'MESH' and len(o.data.vertices) >= 20]
armature = next((o for o in objs if o.type == 'ARMATURE'), None)
if len(meshes) > 1:
deselect_all()
for m in meshes:
m.select_set(True)
set_active(meshes[0])
bpy.ops.object.join()
return bpy.context.active_object, armature
return (meshes[0] if meshes else None), armature
# --------------------------------------------------------------------------
# Fitting (Surface Deform + Shrinkwrap fallback)
# --------------------------------------------------------------------------
def try_surface_deform(clothing, body_surface):
"""Bind + apply Surface Deform via a headless context override. Returns bool."""
clothing.select_set(True)
set_active(clothing)
mod = clothing.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
mod.target = body_surface
mod.falloff = SD_FALLOFF
mod_name = mod.name
try:
with bpy.context.temp_override(
active_object=clothing, object=clothing, selected_objects=[clothing]
):
bpy.ops.object.surfacedeform_bind(modifier=mod_name)
except Exception as exc:
log(f" SD bind EXCEPTION: {exc}")
clothing.modifiers.remove(mod)
return False
if not mod.is_bound:
log(" SD bind did not complete (is_bound=False)")
clothing.modifiers.remove(mod)
return False
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=mod_name)
return True
def shrinkwrap_fallback(clothing, body_surface):
log(" Shrinkwrap fallback (SD bind failed)")
sw = clothing.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
sw.target = body_surface
sw.wrap_method = 'NEAREST_SURFACEPOINT'
sw.wrap_mode = 'ON_SURFACE'
sw.offset = SHRINKWRAP_OFFSET
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.modifier_apply(modifier=sw.name)
def transfer_weights(clothing, body_surface):
"""Data Transfer VGROUP_WEIGHTS from the fitted body (fit_outfits :160-186)."""
deselect_all()
set_active(clothing)
clothing.select_set(True)
dt = clothing.modifiers.new("WeightTransfer", 'DATA_TRANSFER')
dt.object = body_surface
dt.use_vert_data = True
dt.data_types_verts = {'VGROUP_WEIGHTS'}
dt.vert_mapping = 'POLYINTERP_NEAREST'
dt.layers_vgroup_select_src = 'ALL'
dt.layers_vgroup_select_dst = 'NAME'
bpy.ops.object.datalayout_transfer(modifier=dt.name)
bpy.ops.object.modifier_apply(modifier=dt.name)
def normalize_weights(clothing):
deselect_all()
set_active(clothing)
clothing.select_set(True)
bpy.ops.object.mode_set(mode='WEIGHT_PAINT')
bpy.ops.object.vertex_group_normalize_all(lock_active=False)
bpy.ops.object.mode_set(mode='OBJECT')
def retarget_armature(clothing, new_armature):
has_arm = False
for mod in clothing.modifiers:
if mod.type == 'ARMATURE':
mod.object = new_armature
has_arm = True
if not has_arm:
mod = clothing.modifiers.new("Armature", 'ARMATURE')
mod.object = new_armature
clothing.parent = new_armature
clothing.matrix_parent_inverse = new_armature.matrix_world.inverted()
def apply_solidify(clothing, thickness):
if thickness <= 0.0:
return
deselect_all()
clothing.select_set(True)
set_active(clothing)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
sol = clothing.modifiers.new("Solidify", 'SOLIDIFY')
sol.thickness = thickness
sol.offset = 1.0
sol.use_rim = True
bpy.ops.object.modifier_apply(modifier=sol.name)
def export_variant(clothing, armature, out_path):
deselect_all()
clothing.select_set(True)
armature.select_set(True)
set_active(armature)
bpy.ops.export_scene.gltf(
filepath=out_path,
export_format='GLB',
use_selection=True,
export_apply=False, # keep armature modifier for skinning
export_animations=False,
export_skins=True, # <-- the whole point of G1
export_yup=True,
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
# --------------------------------------------------------------------------
# Per-body processing
# --------------------------------------------------------------------------
def process_body(body_type, reference_glb, bodies_dir, output_dir, solidify_mm):
out_path = os.path.join(output_dir, f"{body_type}.glb")
log(f"=== {body_type} ===")
if body_type == REFERENCE_BODY:
shutil.copy2(reference_glb, out_path)
log(" reference body — direct copy")
return {"body_type": body_type, "status": "ok", "method": "copy"}
clear_scene()
clothing, _ref_arm = import_reference_garment(reference_glb)
if clothing is None:
return {"body_type": body_type, "status": "error", "error": "no garment mesh"}
body_surface, armature = build_body(bodies_dir, body_type)
if body_surface is None:
return {"body_type": body_type, "status": "error", "error": "no body surface"}
if try_surface_deform(clothing, body_surface):
method = "surface_deform"
else:
shrinkwrap_fallback(clothing, body_surface)
method = "shrinkwrap"
transfer_weights(clothing, body_surface)
normalize_weights(clothing)
retarget_armature(clothing, armature)
apply_solidify(clothing, solidify_mm)
# Drop the body surface so only garment + armature export.
bpy.data.objects.remove(body_surface, do_unlink=True)
export_variant(clothing, armature, out_path)
log(f" exported [{method}] -> {os.path.basename(out_path)}")
return {"body_type": body_type, "status": "ok", "method": method}
# --------------------------------------------------------------------------
# Self-check (headless smoke test): refit peasant_tunic to average_f
# --------------------------------------------------------------------------
def self_check(bodies_dir):
"""Prove the SD bind + weight flow + skinned export path runs headless.
Builds average_f's body surface, binds a trivial one-quad plane to it, and
confirms the export produces JOINTS_0/WEIGHTS_0 accessors. Non-fatal probe.
"""
import struct
clear_scene()
bpy.ops.mesh.primitive_plane_add(size=0.3, location=(0, 0.1, 1.2))
plane = bpy.context.active_object
body_surface, armature = build_body(bodies_dir, "average_f")
if body_surface is None:
log("self-check: no average_f body — SKIP")
return
ok = try_surface_deform(plane, body_surface)
log(f"self-check: SD bind {'ok' if ok else 'fell back'}")
if not ok:
shrinkwrap_fallback(plane, body_surface)
transfer_weights(plane, body_surface)
normalize_weights(plane)
retarget_armature(plane, armature)
bpy.data.objects.remove(body_surface, do_unlink=True)
out = os.path.join(bpy.app.tempdir, "selfcheck.glb")
export_variant(plane, armature, out)
with open(out, 'rb') as f:
f.read(12)
clen = struct.unpack('<I', f.read(4))[0]
f.read(4)
j = json.loads(f.read(clen))
attrs = set()
for m in j.get("meshes", []):
for pr in m["primitives"]:
attrs |= set(pr["attributes"].keys())
has_skin = "JOINTS_0" in attrs and "WEIGHTS_0" in attrs
log(f"self-check: exported attrs={sorted(attrs)} skinned={has_skin}")
log(f"self-check: {'PASS' if has_skin else 'FAIL'}")
# --------------------------------------------------------------------------
# Entry
# --------------------------------------------------------------------------
def main():
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if "--self-check" in argv:
# <bodies_dir> is the first positional in self-check mode
pos = [a for a in argv if not a.startswith("--")]
bodies_dir = pos[0] if pos else "client/assets/characters/bodies"
self_check(bodies_dir)
return
if len(argv) < 3:
print("Usage: -- <reference_glb> <bodies_dir> <output_dir> "
"[--only a,b,c] [--solidify MM]")
sys.exit(1)
reference_glb, bodies_dir, output_dir = argv[0], argv[1], argv[2]
only = None
if "--only" in argv:
only = [s.strip() for s in argv[argv.index("--only") + 1].split(",")]
solidify_mm = 0.0
if "--solidify" in argv:
solidify_mm = float(argv[argv.index("--solidify") + 1])
if not os.path.isfile(reference_glb):
print(f"ERROR: reference not found: {reference_glb}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
types = only if only else BODY_TYPES
log(f"reference={reference_glb}")
log(f"bodies={bodies_dir}")
log(f"output={output_dir}")
log(f"types={types} solidify={solidify_mm*1000:.0f}mm")
results = []
for bt in types:
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt)):
results.append({"body_type": bt, "status": "skipped",
"error": "body dir missing"})
continue
try:
results.append(process_body(bt, reference_glb, bodies_dir,
output_dir, solidify_mm))
except Exception as exc:
log(f" ERROR {bt}: {exc}")
results.append({"body_type": bt, "status": "error", "error": str(exc)})
ok = [r for r in results if r["status"] == "ok"]
sd = len([r for r in ok if r.get("method") == "surface_deform"])
sw = len([r for r in ok if r.get("method") == "shrinkwrap"])
cp = len([r for r in ok if r.get("method") == "copy"])
log("=" * 50)
for r in results:
log(f" {r['body_type']:12s} {r['status'].upper():8s} "
f"{r.get('method', r.get('error', ''))}")
log(f"OK={len(ok)} (surface_deform={sd} shrinkwrap={sw} copy={cp})")
if sw:
log(f"WARNING: {sw} variant(s) used Shrinkwrap — verify extremities at zoom")
with open(os.path.join(output_dir, "pipeline_log.json"), 'w') as f:
json.dump({
"reference": os.path.basename(reference_glb),
"bodies_dir": bodies_dir,
"variants": {r["body_type"]: {
"status": r["status"],
"method": r.get("method"),
"error": r.get("error"),
} for r in results},
}, f, indent=2)
log("wrote pipeline_log.json")
if any(r["status"] == "error" for r in results):
sys.exit(1)
if __name__ == "__main__":
main()