Files
settled-reach/spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py
T
jpmschweitzerandClaude Opus 5 201dabd19b 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>
2026-09-02 20:55:52 +02:00

212 lines
7.0 KiB
Python

"""
Fit Fantasy outfits to Quaternius Source tier body types.
Uses Surface Deform to refit clothing from Regular body (native fit)
to Superhero and Teen bodies. Regular gets a straight copy since
the outfits were authored for it.
Run via:
reach blender run \
spikes/quaternius-aesthetic/scripts/blender/fit_outfits_source.py \
-- <source_bodies_dir> <outfits_dir> <output_dir>
source_bodies_dir: contains Regular_Male_FullBody.gltf, Superhero_Male_FullBody.gltf, etc.
outfits_dir: contains Male_Peasant.gltf, Male_Ranger.gltf
output_dir: fitted outfits per body type
"""
import bpy
import sys
import os
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 3:
print("Usage: -- <source_bodies_dir> <outfits_dir> <output_dir>")
sys.exit(1)
BODIES_DIR = argv[0]
OUTFITS_DIR = argv[1]
OUTPUT_DIR = argv[2]
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Body type mapping: our name -> Quaternius filename
BODY_MAP = {
"regular": "Regular_Male_FullBody.gltf",
"superhero": "Superhero_Male_FullBody.gltf",
"teen": "Teen_Male_FullBody.gltf",
}
# Find outfits
OUTFITS = []
for f in sorted(os.listdir(OUTFITS_DIR)):
if f.endswith(".gltf") and f.startswith("Male_"):
OUTFITS.append(f)
print("=== Fit outfits to Source tier bodies ===")
print(f" Bodies: {BODIES_DIR}")
print(f" Outfits: {OUTFITS}")
print(f" Output: {OUTPUT_DIR}")
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
for c in list(bpy.data.collections):
bpy.data.collections.remove(c)
def import_gltf(path):
before = set(bpy.data.objects)
bpy.ops.import_scene.gltf(filepath=path)
return list(set(bpy.data.objects) - before)
def find_armature(objects):
for obj in objects:
if obj.type == 'ARMATURE':
return obj
return None
def find_meshes(objects):
return [obj for obj in objects if obj.type == 'MESH']
def find_largest_mesh(objects):
meshes = find_meshes(objects)
if not meshes:
return None
return max(meshes, key=lambda m: len(m.data.vertices))
for body_name, body_file in BODY_MAP.items():
body_path = os.path.join(BODIES_DIR, body_file)
if not os.path.exists(body_path):
print(f"\n SKIP {body_name}: {body_file} not found")
continue
out_dir = os.path.join(OUTPUT_DIR, body_name)
os.makedirs(out_dir, exist_ok=True)
for outfit_file in OUTFITS:
outfit_name = os.path.splitext(outfit_file)[0]
output_path = os.path.join(out_dir, f"{outfit_name}.glb")
print(f"\n {body_name} / {outfit_name}...")
clear_scene()
# Import body
body_objects = import_gltf(body_path)
body_armature = find_armature(body_objects)
body_mesh = find_largest_mesh(body_objects)
if not body_armature or not body_mesh:
print(f" ERROR: body import failed")
continue
print(f" Body: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
# Import outfit
outfit_path = os.path.join(OUTFITS_DIR, outfit_file)
outfit_objects = import_gltf(outfit_path)
outfit_armature = find_armature(outfit_objects)
outfit_meshes = find_meshes(outfit_objects)
if not outfit_meshes:
print(f" ERROR: no outfit meshes")
continue
print(f" Outfit meshes: {len(outfit_meshes)}")
# For regular body: outfits already fit, just re-export with this armature
# For other bodies: Surface Deform to refit
if body_name != "regular":
for mi in outfit_meshes:
# Apply Surface Deform to bind clothing to body surface
bpy.context.view_layer.objects.active = mi
mi.select_set(True)
sd = mi.modifiers.new(name="SurfaceDeform", type='SURFACE_DEFORM')
sd.target = body_mesh
sd.falloff = 4.0
try:
bpy.ops.object.surfacedeform_bind(modifier=sd.name)
if sd.is_bound:
bpy.ops.object.modifier_apply(modifier=sd.name)
print(f" Surface Deform applied: {mi.name}")
else:
print(f" WARNING: Surface Deform bind failed for {mi.name}")
mi.modifiers.remove(sd)
except Exception as e:
print(f" WARNING: Surface Deform error for {mi.name}: {e}")
if sd.name in [m.name for m in mi.modifiers]:
mi.modifiers.remove(sd)
mi.select_set(False)
# Reparent outfit meshes to body armature
for mi in outfit_meshes:
# Remove old armature modifier
for mod in list(mi.modifiers):
if mod.type == 'ARMATURE':
mi.modifiers.remove(mod)
# Parent to body armature
mi.parent = body_armature
mi.matrix_parent_inverse = body_armature.matrix_world.inverted()
arm_mod = mi.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = body_armature
# Re-transfer weights from body mesh
bpy.context.view_layer.objects.active = mi
mi.select_set(True)
dt = mi.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
dt.object = body_mesh
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)
mi.select_set(False)
# Hide body mesh, export outfit + armature
body_mesh.hide_set(True)
for obj in body_objects:
if obj.type == 'MESH':
obj.hide_set(True)
# Remove outfit's original armature
if outfit_armature:
outfit_armature.hide_set(True)
bpy.ops.object.select_all(action='DESELECT')
body_armature.select_set(True)
body_armature.hide_set(False)
for mi in outfit_meshes:
mi.select_set(True)
mi.hide_set(False)
bpy.context.view_layer.objects.active = body_armature
bpy.ops.export_scene.gltf(
filepath=output_path,
export_format='GLB',
use_selection=True,
export_apply=False,
export_animations=False,
export_skins=True,
)
size_kb = os.path.getsize(output_path) // 1024
print(f" Exported: {output_path} ({size_kb} KB)")
print("\n=== Done ===")
for body_name in BODY_MAP:
for outfit_file in OUTFITS:
outfit_name = os.path.splitext(outfit_file)[0]
path = os.path.join(OUTPUT_DIR, body_name, f"{outfit_name}.glb")
status = "[OK]" if os.path.exists(path) else "[MISSING]"
print(f" {status} {body_name}/{outfit_name}.glb")