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>
212 lines
7.0 KiB
Python
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")
|