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>
421 lines
15 KiB
Python
421 lines
15 KiB
Python
"""
|
|
Spike T-1089 / Synty Sidekick intake — step 4: 11-body batch fit.
|
|
|
|
Takes the transplanted torso garment (fitted to average_m, bound to our shared
|
|
armature = the average_m bind skeleton) through a multi-body refit, following
|
|
the fit_outfits_to_bodies.py Surface-Deform approach, adapted for the measured
|
|
reality that the 11 bodies do NOT share topology (03_recon_bodies.py:
|
|
every body is independently authored; thin_*/heavy_* even lack seg_hips).
|
|
|
|
Per target body:
|
|
1. Import the transplanted garment GLB; capture reference (average_m)
|
|
landmarks from its armature; detach + freeze the garment mesh.
|
|
2. Import the average_m reference segments, join -> ref_body.
|
|
3. Import the target body segments (skipping missing ones), keep the
|
|
seg_torso armature as the target bind skeleton, join -> tgt_body.
|
|
4. Landmark affine (same machinery as 02): scale XY by shoulder ratio,
|
|
scale Z by pelvis->upperarm span ratio, translate pelvis->pelvis.
|
|
Applied to BOTH the garment and ref_body.
|
|
5. BVH nearest-surface warp (replaces Surface Deform + Shrinkwrap —
|
|
first attempt showed SD driven by a shrinkwrapped driver mesh
|
|
spikes 14-28x on fold discontinuities, and SD bind itself is
|
|
unreliable against the multi-shell joined segment mesh):
|
|
for each garment vertex, find the nearest point on ref_body,
|
|
decompose the offset into (height along surface normal +
|
|
tangential residual), re-evaluate the same surface point on
|
|
tgt_body via its own nearest-surface lookup, and rebuild the
|
|
vertex at the target surface with the offset preserved. The
|
|
per-vertex displacement field is then Laplacian-smoothed over
|
|
the garment mesh connectivity to kill nearest-point-map
|
|
discontinuities while keeping rigid details (back device,
|
|
plating) coherent.
|
|
6. Wipe vgroups, Data Transfer POLYINTERP_NEAREST weights from tgt_body,
|
|
normalize, bind to the target body's embedded armature (same bind pose
|
|
as the body segments -> garment deforms identically to the body under
|
|
any runtime skeleton pose).
|
|
7. Export armature + garment GLB (export_skins=True) to
|
|
out/bodies/SK_SCFI_CIVL_09_10TORS_HU01_<body>.glb
|
|
8. Metrics (out/bodies_fit_log.json): zero-weight verts, AABB,
|
|
edge-length distortion vs post-affine baseline (collapse/fold
|
|
detector), signed-distance stats garment vs body surface
|
|
(penetration detector).
|
|
|
|
Run:
|
|
reach blender run \
|
|
spikes/synty-intake/scripts/04_batch_fit_bodies.py
|
|
"""
|
|
|
|
import bpy
|
|
import json
|
|
import math
|
|
import os
|
|
from mathutils import Matrix, Vector
|
|
from mathutils.bvhtree import BVHTree
|
|
|
|
REPO = "/var/mnt/data/projects/settled-reach"
|
|
GARMENT_GLB = os.path.join(REPO, "spikes/synty-intake/out",
|
|
"SK_SCFI_CIVL_09_10TORS_HU01_quaternius.glb")
|
|
BODIES_DIR = os.path.join(REPO, "client/assets/characters/bodies")
|
|
OUT_DIR = os.path.join(REPO, "spikes/synty-intake/out/bodies")
|
|
OUT_LOG = os.path.join(REPO, "spikes/synty-intake/out/bodies_fit_log.json")
|
|
|
|
BODY_TYPES = ["average_m", "average_f", "muscular_m", "muscular_f",
|
|
"thin_m", "thin_f", "heavy_m", "heavy_f",
|
|
"teen_m", "teen_f", "child"]
|
|
|
|
SEGS = ["seg_torso", "seg_hips", "seg_neck",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_leg_upper_l", "seg_leg_upper_r"]
|
|
|
|
PENETRATION_MM = 3.0 # garment vertex deeper than this inside the body -> flag
|
|
|
|
|
|
def deselect_all():
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
|
|
|
|
def set_active(obj):
|
|
bpy.context.view_layer.objects.active = obj
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete(use_global=False)
|
|
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.images,
|
|
bpy.data.materials):
|
|
for block in list(block_list):
|
|
if block.users == 0:
|
|
block_list.remove(block)
|
|
|
|
|
|
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).copy() if b else None
|
|
|
|
|
|
def delete_obj(obj):
|
|
deselect_all()
|
|
obj.select_set(True)
|
|
set_active(obj)
|
|
bpy.ops.object.delete(use_global=False)
|
|
|
|
|
|
def freeze(obj):
|
|
"""Unparent keeping transform, strip modifiers, bake transform to data."""
|
|
deselect_all()
|
|
set_active(obj)
|
|
obj.select_set(True)
|
|
mw = obj.matrix_world.copy()
|
|
obj.parent = None
|
|
obj.matrix_world = mw
|
|
for mod in list(obj.modifiers):
|
|
obj.modifiers.remove(mod)
|
|
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
|
|
|
|
|
def import_body(body, keep_armature):
|
|
"""Import + join a body's garment-adjacent segments.
|
|
|
|
Returns (joined_mesh, armature_or_None, segments_used).
|
|
"""
|
|
seg_meshes = []
|
|
kept_arm = None
|
|
used = []
|
|
for seg in SEGS:
|
|
path = os.path.join(BODIES_DIR, body, seg + ".glb")
|
|
if not os.path.exists(path):
|
|
continue
|
|
objs = import_new(bpy.ops.import_scene.gltf, path)
|
|
picked = None
|
|
for o in objs:
|
|
if o.type == 'MESH' and len(o.vertex_groups) > 0:
|
|
picked = o
|
|
elif o.type == 'MESH':
|
|
delete_obj(o) # debris (bounds helpers etc.)
|
|
arm = find_armature(objs)
|
|
if picked is not None:
|
|
pmw = picked.matrix_world.copy()
|
|
picked.parent = None
|
|
picked.matrix_world = pmw
|
|
if arm is not None:
|
|
if keep_armature and kept_arm is None:
|
|
kept_arm = arm
|
|
else:
|
|
delete_obj(arm)
|
|
if picked is None:
|
|
print(f" WARNING: no weighted mesh in {body}/{seg}")
|
|
continue
|
|
for mod in list(picked.modifiers):
|
|
picked.modifiers.remove(mod)
|
|
seg_meshes.append(picked)
|
|
used.append(seg)
|
|
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()
|
|
joined = bpy.context.view_layer.objects.active
|
|
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
|
return joined, kept_arm, used
|
|
|
|
|
|
def edge_lengths(mesh_obj):
|
|
verts = mesh_obj.data.vertices
|
|
return [(verts[e.vertices[0]].co - verts[e.vertices[1]].co).length
|
|
for e in mesh_obj.data.edges]
|
|
|
|
|
|
def aabb(mesh_obj):
|
|
xs = [v.co for v in mesh_obj.data.vertices]
|
|
lo = Vector((min(v.x for v in xs), min(v.y for v in xs),
|
|
min(v.z for v in xs)))
|
|
hi = Vector((max(v.x for v in xs), max(v.y for v in xs),
|
|
max(v.z for v in xs)))
|
|
return lo, hi
|
|
|
|
|
|
def warp_garment(garment, ref_body, tgt_body, depsgraph,
|
|
smooth_iterations=20, smooth_lambda=0.5):
|
|
"""Nearest-surface warp: move each garment vertex from its offset
|
|
relative to ref_body onto the equivalent offset relative to tgt_body,
|
|
then Laplacian-smooth the displacement field over the garment mesh."""
|
|
bvh_ref = BVHTree.FromObject(ref_body, depsgraph)
|
|
bvh_tgt = BVHTree.FromObject(tgt_body, depsgraph)
|
|
|
|
verts = garment.data.vertices
|
|
n = len(verts)
|
|
disp = [None] * n
|
|
for i, v in enumerate(verts):
|
|
co_r, n_r, _idx, _d = bvh_ref.find_nearest(v.co)
|
|
if co_r is None:
|
|
disp[i] = Vector((0, 0, 0))
|
|
continue
|
|
delta = v.co - co_r
|
|
h = delta.dot(n_r)
|
|
tang = delta - h * n_r
|
|
co_t, n_t, _idx2, _d2 = bvh_tgt.find_nearest(co_r)
|
|
if co_t is None:
|
|
disp[i] = Vector((0, 0, 0))
|
|
continue
|
|
new_co = co_t + h * n_t + tang
|
|
disp[i] = new_co - v.co
|
|
|
|
# adjacency from edges
|
|
adj = [[] for _ in range(n)]
|
|
for e in garment.data.edges:
|
|
a, b = e.vertices
|
|
adj[a].append(b)
|
|
adj[b].append(a)
|
|
|
|
# Laplacian smoothing of the displacement field (not the geometry)
|
|
for _ in range(smooth_iterations):
|
|
new_disp = [None] * n
|
|
for i in range(n):
|
|
if not adj[i]:
|
|
new_disp[i] = disp[i]
|
|
continue
|
|
avg = Vector((0, 0, 0))
|
|
for j in adj[i]:
|
|
avg += disp[j]
|
|
avg /= len(adj[i])
|
|
new_disp[i] = disp[i].lerp(avg, smooth_lambda)
|
|
disp = new_disp
|
|
|
|
max_disp = 0.0
|
|
for i, v in enumerate(verts):
|
|
v.co = v.co + disp[i]
|
|
if disp[i].length > max_disp:
|
|
max_disp = disp[i].length
|
|
garment.data.update()
|
|
return {"max_displacement_mm": round(max_disp * 1000, 1),
|
|
"smooth_iterations": smooth_iterations}
|
|
|
|
|
|
def signed_distance_stats(garment, body_obj, depsgraph):
|
|
"""Signed distance of each garment vertex to the body surface.
|
|
|
|
Positive = outside the body (along surface normal), negative = inside.
|
|
"""
|
|
bvh = BVHTree.FromObject(body_obj, depsgraph)
|
|
dists = []
|
|
for v in garment.data.vertices:
|
|
co, normal, _idx, _d = bvh.find_nearest(v.co)
|
|
if co is None:
|
|
continue
|
|
dists.append((v.co - co).dot(normal))
|
|
dists.sort()
|
|
n = len(dists)
|
|
inside = [d for d in dists if d < -PENETRATION_MM / 1000.0]
|
|
return {
|
|
"verts_sampled": n,
|
|
"min_signed_mm": round(dists[0] * 1000, 2),
|
|
"p05_signed_mm": round(dists[max(0, int(n * 0.05) - 1)] * 1000, 2),
|
|
"median_signed_mm": round(dists[n // 2] * 1000, 2),
|
|
"max_signed_mm": round(dists[-1] * 1000, 2),
|
|
f"verts_inside_gt_{PENETRATION_MM:g}mm": len(inside),
|
|
"pct_inside": round(100.0 * len(inside) / n, 2),
|
|
}
|
|
|
|
|
|
log = {"garment_glb": GARMENT_GLB, "bodies": {}}
|
|
os.makedirs(OUT_DIR, exist_ok=True)
|
|
|
|
for body in BODY_TYPES:
|
|
print(f"\n{'='*60}\n BODY: {body}\n{'='*60}")
|
|
entry = {}
|
|
clear_scene()
|
|
|
|
# --- 1. garment + reference landmarks ---
|
|
g_objs = import_new(bpy.ops.import_scene.gltf, GARMENT_GLB)
|
|
ref_arm = find_armature(g_objs)
|
|
# the GLB also carries a bounds icosphere — the garment is the skinned
|
|
# mesh (has vertex groups); the set-diff order is nondeterministic, so
|
|
# pick explicitly and delete the rest
|
|
g_meshes = [o for o in g_objs if o.type == 'MESH']
|
|
garment = max((m for m in g_meshes if len(m.vertex_groups) > 0),
|
|
key=lambda m: len(m.data.vertices))
|
|
ref_pelvis = bone_head_world(ref_arm, "pelvis")
|
|
ref_upperarm = bone_head_world(ref_arm, "upperarm_l")
|
|
freeze(garment)
|
|
for m in g_meshes:
|
|
if m is not garment:
|
|
delete_obj(m)
|
|
delete_obj(ref_arm)
|
|
entry["garment_verts"] = len(garment.data.vertices)
|
|
|
|
# --- 2. reference body (average_m) ---
|
|
ref_body, _, _ = import_body("average_m", keep_armature=False)
|
|
ref_body.name = "ref_body"
|
|
|
|
# --- 3. target body ---
|
|
tgt_body, tgt_arm, used = import_body(body, keep_armature=True)
|
|
tgt_body.name = "tgt_body"
|
|
entry["segments_used"] = used
|
|
if tgt_arm is None:
|
|
entry["result"] = "FAIL: no target armature"
|
|
log["bodies"][body] = entry
|
|
continue
|
|
tgt_pelvis = bone_head_world(tgt_arm, "pelvis")
|
|
tgt_upperarm = bone_head_world(tgt_arm, "upperarm_l")
|
|
|
|
# --- 4. landmark affine ref -> tgt ---
|
|
sxy = tgt_upperarm.x / ref_upperarm.x
|
|
sz = (tgt_upperarm.z - tgt_pelvis.z) / (ref_upperarm.z - ref_pelvis.z)
|
|
S = Matrix.Diagonal(Vector((sxy, sxy, sz, 1.0)))
|
|
scaled_pelvis = Vector((ref_pelvis.x * sxy, ref_pelvis.y * sxy,
|
|
ref_pelvis.z * sz))
|
|
t = tgt_pelvis - scaled_pelvis
|
|
M = Matrix.Translation(t) @ S
|
|
garment.data.transform(M)
|
|
garment.data.update()
|
|
ref_body.data.transform(M)
|
|
ref_body.data.update()
|
|
entry["affine"] = {"scale_xy": round(sxy, 5), "scale_z": round(sz, 5),
|
|
"translate": [round(v, 5) for v in t]}
|
|
|
|
# post-affine baseline for distortion metrics
|
|
base_edges = edge_lengths(garment)
|
|
lo, hi = aabb(garment)
|
|
entry["aabb_post_affine"] = {"min": [round(v, 4) for v in lo],
|
|
"max": [round(v, 4) for v in hi]}
|
|
|
|
# --- 5. BVH nearest-surface warp ref_body -> tgt_body ---
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
entry["warp"] = warp_garment(garment, ref_body, tgt_body, depsgraph)
|
|
|
|
delete_obj(ref_body)
|
|
|
|
# --- 7. weights from the target body ---
|
|
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 = tgt_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)
|
|
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')
|
|
|
|
zero = sum(1 for v in garment.data.vertices
|
|
if sum(g.weight for g in v.groups) < 1e-4)
|
|
entry["zero_weight_verts"] = zero
|
|
entry["vgroups"] = len(garment.vertex_groups)
|
|
|
|
# --- 8. metrics ---
|
|
post_edges = edge_lengths(garment)
|
|
ratios = [p / b for p, b in zip(post_edges, base_edges) if b > 1e-9]
|
|
entry["edge_distortion"] = {
|
|
"max_stretch": round(max(ratios), 3),
|
|
"max_shrink": round(min(ratios), 3),
|
|
"edges_gt_2x": sum(1 for r in ratios if r > 2.0),
|
|
"edges_lt_0.5x": sum(1 for r in ratios if r < 0.5),
|
|
"edges_total": len(ratios),
|
|
}
|
|
lo, hi = aabb(garment)
|
|
entry["aabb_post_fit"] = {"min": [round(v, 4) for v in lo],
|
|
"max": [round(v, 4) for v in hi]}
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
entry["signed_distance"] = signed_distance_stats(garment, tgt_body,
|
|
depsgraph)
|
|
|
|
# --- 9. bind + export ---
|
|
garment.parent = tgt_arm
|
|
garment.matrix_parent_inverse = tgt_arm.matrix_world.inverted()
|
|
am = garment.modifiers.new(name="Armature", type='ARMATURE')
|
|
am.object = tgt_arm
|
|
delete_obj(tgt_body)
|
|
|
|
out_glb = os.path.join(OUT_DIR,
|
|
f"SK_SCFI_CIVL_09_10TORS_HU01_{body}.glb")
|
|
deselect_all()
|
|
tgt_arm.select_set(True)
|
|
garment.select_set(True)
|
|
set_active(tgt_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,
|
|
)
|
|
entry["export"] = {"path": out_glb,
|
|
"size_kb": os.path.getsize(out_glb) // 1024}
|
|
entry["result"] = "OK"
|
|
log["bodies"][body] = entry
|
|
|
|
with open(OUT_LOG, "w") as f:
|
|
json.dump(log, f, indent=2)
|
|
|
|
print("\n=== BATCH FIT SUMMARY ===")
|
|
for body, e in log["bodies"].items():
|
|
sd_stats = e.get("signed_distance", {})
|
|
print(f" {body:12s} {e.get('result','?'):14s} "
|
|
f"zero_w={e.get('zero_weight_verts','-')} "
|
|
f"inside%={sd_stats.get('pct_inside','-')} "
|
|
f"stretch={e.get('edge_distortion',{}).get('max_stretch','-')}")
|