Files
settled-reach/spikes/synty-intake/scripts/02_transplant_garment.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

264 lines
8.7 KiB
Python

"""
Spike T-1089 / Synty Sidekick intake — step 2: transplant.
Route decision (from 01_recon_rest_pose.py, out/recon_rest_pose.json):
Route 1 (rename-and-rebind, keep Sidekick weights verbatim) REJECTED on
measured evidence: spine_02/spine_03 joint heads sit 0.160/0.168 m lower in
the Sidekick rig than ours, shoulders 0.082 m lower, clavicles 0.150 m off.
Rest pose would render fine (inverse binds absorb it) but every spine/shoulder
rotation would pivot the garment around joints up to 17 cm away from where the
body underneath pivots -> swim/clip under animation.
Route 2 (proven pipeline): affine-align garment onto our average_m body,
then Data Transfer POLYINTERP_NEAREST weights from OUR body mesh, normalize,
bind to OUR armature.
Alignment transform (documented, measured from bone landmarks):
scale XY by ours_shoulder_x / sk_shoulder_x
scale Z by (ours_upperarm_z - ours_pelvis_z) / (sk_upperarm_z - sk_pelvis_z)
then translate so the pelvis landmarks coincide.
Run:
reach blender run \
spikes/synty-intake/scripts/02_transplant_garment.py
"""
import bpy
import json
import os
from mathutils import Matrix, Vector
REPO = "/var/mnt/data/projects/settled-reach"
GARMENT_FBX = os.path.join(
REPO, "spikes/synty-intake/raw/Assets/Synty/SidekickCharacters/Resources",
"Meshes/Outfits/Starter/SK_SCFI_CIVL_09_10TORS_HU01.fbx")
OUR_ARMATURE_GLB = os.path.join(REPO, "client/assets/characters/skeleton/armature.glb")
BODY_DIR = os.path.join(REPO, "client/assets/characters/bodies/average_m")
# Segments covered by / adjacent to the torso garment — the weight-transfer source.
BODY_SEGS = ["seg_torso", "seg_hips", "seg_neck",
"seg_arm_upper_l", "seg_arm_upper_r",
"seg_leg_upper_l", "seg_leg_upper_r"]
OUT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out", "transplant_log.json")
log = {}
def import_new(op, path, **kw):
before = set(bpy.data.objects)
op(filepath=path, **kw)
return list(set(bpy.data.objects) - before)
def find_armature(objs):
for o in objs:
if o.type == 'ARMATURE':
return o
return None
def bone_head_world(arm, name):
b = arm.data.bones.get(name)
return (arm.matrix_world @ b.head_local) if b else None
def deselect_all():
bpy.ops.object.select_all(action='DESELECT')
def set_active(obj):
bpy.context.view_layer.objects.active = obj
# --- fresh scene ---
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
# --- 1. import garment ---
g_objs = import_new(bpy.ops.import_scene.fbx, GARMENT_FBX)
g_arm = find_armature(g_objs)
garment = next(o for o in g_objs if o.type == 'MESH')
log["garment"] = {"verts": len(garment.data.vertices),
"sidekick_vgroups": [vg.name for vg in garment.vertex_groups]}
# landmarks in Sidekick space (world)
sk_pelvis = bone_head_world(g_arm, "pelvis").copy()
sk_upperarm = bone_head_world(g_arm, "upperarm_l").copy()
# --- 2. import our armature ---
o_objs = import_new(bpy.ops.import_scene.gltf, OUR_ARMATURE_GLB)
our_arm = find_armature(o_objs)
our_pelvis = bone_head_world(our_arm, "pelvis").copy()
our_upperarm = bone_head_world(our_arm, "upperarm_l").copy()
# --- 3. detach garment from Sidekick armature, freeze world coords ---
deselect_all()
set_active(garment)
garment.select_set(True)
mw = garment.matrix_world.copy()
garment.parent = None
garment.matrix_world = mw
for mod in list(garment.modifiers):
garment.modifiers.remove(mod)
# drop Sidekick body-blend shape keys (not part of our body-type system)
if garment.data.shape_keys:
set_active(garment)
bpy.ops.object.shape_key_remove(all=True)
# bake object transform into mesh data so object space == world space
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# --- 4. affine alignment garment -> our proportions ---
sxy = our_upperarm.x / sk_upperarm.x
sz = (our_upperarm.z - our_pelvis.z) / (sk_upperarm.z - sk_pelvis.z)
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
scaled_pelvis = Vector((sk_pelvis.x * sxy, sk_pelvis.y * sxy, sk_pelvis.z * sz))
t = our_pelvis - scaled_pelvis
M = Matrix.Translation(t) @ S
garment.data.transform(M)
garment.data.update()
log["alignment"] = {
"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
"translate": [round(v, 5) for v in t],
"sk_pelvis": [round(v, 5) for v in sk_pelvis],
"our_pelvis": [round(v, 5) for v in our_pelvis],
"sk_upperarm_l": [round(v, 5) for v in sk_upperarm],
"our_upperarm_l": [round(v, 5) for v in our_upperarm],
}
# residual landmark error after affine, for all 12 bound bones
residuals = {}
for bn in ["pelvis", "thigh_l", "thigh_r", "spine_01", "spine_02", "spine_03",
"neck_01", "head", "clavicle_l", "upperarm_l", "clavicle_r",
"upperarm_r"]:
sk = bone_head_world(g_arm, bn)
ours = bone_head_world(our_arm, bn if bn != "head" else "Head")
sk_t = M @ sk
d = sk_t - ours
residuals[bn] = {"residual_m": round(d.length, 5),
"delta": [round(v, 5) for v in d]}
log["post_affine_bone_residuals"] = residuals
# --- 5. delete Sidekick armature ---
deselect_all()
g_arm.select_set(True)
set_active(g_arm)
bpy.ops.object.delete(use_global=False)
# --- 6. import + join our body segments as the weight-transfer source ---
seg_meshes = []
for seg in BODY_SEGS:
objs = import_new(bpy.ops.import_scene.gltf,
os.path.join(BODY_DIR, seg + ".glb"))
picked = None
for o in objs:
if o.type == 'MESH' and len(o.vertex_groups) > 0:
picked = o
elif o.type == 'MESH':
# debris (bounds icosphere etc.) — delete
deselect_all()
o.select_set(True)
set_active(o)
bpy.ops.object.delete(use_global=False)
# delete the segment's own armature copy
arm = find_armature(objs)
if arm and picked is not None:
# unparent mesh first, keep transform
pmw = picked.matrix_world.copy()
picked.parent = None
picked.matrix_world = pmw
if arm:
deselect_all()
arm.select_set(True)
set_active(arm)
bpy.ops.object.delete(use_global=False)
if picked is None:
print(f"WARNING: no weighted mesh in {seg}")
continue
for mod in list(picked.modifiers):
picked.modifiers.remove(mod)
seg_meshes.append(picked)
log["weight_source_segments"] = [
{"name": m.name, "verts": len(m.data.vertices)} for m in seg_meshes]
deselect_all()
for m in seg_meshes:
m.select_set(True)
set_active(seg_meshes[0])
if len(seg_meshes) > 1:
bpy.ops.object.join()
body = bpy.context.view_layer.objects.active
body.name = "weight_source_body"
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
# --- 7. weight transfer: our body -> garment ---
# wipe Sidekick vertex groups first so only OUR weights remain
for vg in list(garment.vertex_groups):
garment.vertex_groups.remove(vg)
deselect_all()
set_active(garment)
garment.select_set(True)
dt = garment.modifiers.new(name="WeightTransfer", type='DATA_TRANSFER')
dt.object = body
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)
# normalize
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')
log["transferred_vgroups"] = [vg.name for vg in garment.vertex_groups]
# sanity: count verts with (near-)zero total weight
zero = 0
for v in garment.data.vertices:
tot = sum(g.weight for g in v.groups)
if tot < 1e-4:
zero += 1
log["zero_weight_verts"] = zero
# --- 8. bind garment to OUR armature ---
garment.parent = our_arm
garment.matrix_parent_inverse = our_arm.matrix_world.inverted()
am = garment.modifiers.new(name="Armature", type='ARMATURE')
am.object = our_arm
# --- 9. delete the body source, export armature + garment ---
deselect_all()
body.select_set(True)
set_active(body)
bpy.ops.object.delete(use_global=False)
os.makedirs(os.path.dirname(OUT_GLB), exist_ok=True)
deselect_all()
our_arm.select_set(True)
garment.select_set(True)
set_active(our_arm)
bpy.ops.export_scene.gltf(
filepath=OUT_GLB,
export_format='GLB',
use_selection=True,
export_apply=False,
export_animations=False,
export_skins=True,
export_morph=False,
export_extras=False,
)
log["export"] = {"path": OUT_GLB, "size_kb": os.path.getsize(OUT_GLB) // 1024}
with open(OUT_LOG, "w") as f:
json.dump(log, f, indent=2)
print("\n=== TRANSPLANT SUMMARY ===")
print(json.dumps(log, indent=2))