Bridge-C evidence spike: unitypackage extraction, rest-pose recon, garment transplant onto the Quaternius rig (Data Transfer POLYINTERP_NEAREST), 11-body batch fit, Godot QA scenes. Technical PASS; route nonetheless REJECTED by product call (cost, baked body-segment modularity, style) — full trail on T-1089. Salvage: source-agnostic transplant/batch-fit scripts (the G1-family pipeline for any donor mesh) and the T-1090 discovery (5 body types misrender bare). Extracted vendor payload (spikes/**/raw/) now gitignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
264 lines
8.7 KiB
Python
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:
|
|
tooling/blender --background --python \
|
|
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))
|