"""
FAILED (Trellis clothing pipeline): Auto-rig a Trellis-generated clothing mesh
onto a Quaternius character skeleton.
The weight transfer algorithm works correctly, but the Trellis-generated clothing
meshes themselves are unsuitable for rigging: non-manifold geometry, inconsistent
vertex density, and topology that does not deform well under skinning. The
auto-rigging pipeline is technically sound but the INPUT meshes are the problem.
Conclusion: Trellis CANNOT generate riggable clothing. Use hand-authored or
Quaternius-pack clothing instead.
This script is preserved as documentation of the approach and the robust weight
transfer implementation (which may be reusable for other mesh sources).
Uses robust weight transfer with Laplacian inpainting for unmatched vertices,
based on the SIGGRAPH Asia 2023 algorithm (MIT reference implementation).
Run via:
reach blender run \\
spikes/quaternius-aesthetic/scripts/blender/auto_rig_clothing.py \\
-- body.gltf clothing.glb output.glb
Pipeline: Import -> Cleanup -> Scale/Position -> Weight Transfer -> Normalize -> Export
"""
import bpy
import site
import sys
import os
# Blender flatpak installs --user packages outside the default path
sys.path.insert(0, site.getusersitepackages())
import numpy as np
from mathutils import Vector
import igl
import scipy.sparse as sp
import robust_laplacian
# --- Parse args ---
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) < 3:
print("Usage: --
")
sys.exit(1)
body_path, clothing_path, output_path = argv[0], argv[1], argv[2]
print("=== Auto-rig clothing (robust weight transfer) ===")
print(f" Body: {body_path}")
print(f" Clothing: {clothing_path}")
print(f" Output: {output_path}")
# --- Helpers ---
def clear_scene():
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
for col in list(bpy.data.collections):
bpy.data.collections.remove(col)
def import_glb(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 get_bounds(obj):
corners = [obj.matrix_world @ Vector(c) for c in obj.bound_box]
mins = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
maxs = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
return mins, maxs
def mesh_to_numpy(obj):
"""Extract world-space vertices, faces, and normals as numpy arrays."""
mesh = obj.data
mesh.calc_loop_triangles()
mw = obj.matrix_world
verts = np.array([mw @ v.co for v in mesh.vertices], dtype=np.float64)
faces = np.array([[lt.vertices[i] for i in range(3)] for lt in mesh.loop_triangles], dtype=np.int64)
normals = np.array([mw.to_3x3() @ v.normal for v in mesh.vertices], dtype=np.float64)
return verts, faces, normals
def get_bone_weights(obj, bone_names):
"""Extract per-vertex bone weights as a (n_verts x n_bones) matrix."""
n_verts = len(obj.data.vertices)
n_bones = len(bone_names)
weights = np.zeros((n_verts, n_bones), dtype=np.float64)
# Map vertex group names to bone indices
vg_to_bone = {}
for vg in obj.vertex_groups:
if vg.name in bone_names:
vg_to_bone[vg.index] = bone_names.index(vg.name)
for v in obj.data.vertices:
for g in v.groups:
if g.group in vg_to_bone:
weights[v.index, vg_to_bone[g.group]] = g.weight
return weights
def set_bone_weights(obj, bone_names, weights):
"""Set per-vertex bone weights from a (n_verts x n_bones) matrix."""
# Create vertex groups
for name in bone_names:
if name not in obj.vertex_groups:
obj.vertex_groups.new(name=name)
vg_map = {name: obj.vertex_groups[name] for name in bone_names if name in obj.vertex_groups}
for vi in range(len(obj.data.vertices)):
for bi, name in enumerate(bone_names):
w = weights[vi, bi]
if w > 0.001:
vg_map[name].add([vi], w, 'REPLACE')
# --- Pipeline steps ---
def cleanup_trellis_mesh(obj):
"""Fix common Trellis output issues."""
print("\n Cleanup:")
verts_before = len(obj.data.vertices)
bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.remove_doubles(threshold=0.001)
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_loose()
bpy.ops.mesh.delete(type='VERT')
bpy.ops.object.mode_set(mode='OBJECT')
print(f" {verts_before} → {len(obj.data.vertices)} verts")
def scale_and_position(clothing_obj, body_obj, armature):
"""Scale clothing to match body and center on spine."""
print("\n Scale & position:")
body_mins, body_maxs = get_bounds(body_obj)
cloth_mins, cloth_maxs = get_bounds(clothing_obj)
body_size = body_maxs - body_mins
cloth_size = cloth_maxs - cloth_mins
# Uniform scale based on body width
torso_width = body_size.x * 0.85
scale = torso_width / max(cloth_size.x, 0.001)
clothing_obj.scale = (scale, scale, scale)
bpy.context.view_layer.update()
# Center on spine_02 bone
bone = armature.data.bones.get("spine_02")
if bone:
target = armature.matrix_world @ bone.head_local
else:
target = Vector(((body_mins.x + body_maxs.x) / 2, body_mins.y + body_size.y * 0.55, (body_mins.z + body_maxs.z) / 2))
cloth_mins2, cloth_maxs2 = get_bounds(clothing_obj)
cloth_center = (cloth_mins2 + cloth_maxs2) / 2
clothing_obj.location += target - cloth_center
# Apply transforms
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
cloth_mins3, cloth_maxs3 = get_bounds(clothing_obj)
print(f" Scale: {scale:.3f}, bounds: {cloth_mins3.y:.2f}–{cloth_maxs3.y:.2f}")
def robust_weight_transfer(body_obj, clothing_obj, bone_names,
dist_threshold=0.1, normal_threshold_deg=90.0):
"""
Transfer bone weights from body to clothing using closest-point matching
with Laplacian inpainting for unmatched vertices.
Based on: "Robust Skin Weights Transfer via Weight Inpainting"
(Abdrashitov et al., SIGGRAPH Asia 2023, MIT reference implementation)
"""
print("\n Robust weight transfer:")
# Extract mesh data
src_verts, src_faces, src_normals = mesh_to_numpy(body_obj)
tgt_verts, tgt_faces, tgt_normals = mesh_to_numpy(clothing_obj)
# Get source weights
src_weights = get_bone_weights(body_obj, bone_names)
print(f" Source: {len(src_verts)} verts, {len(src_faces)} faces")
print(f" Target: {len(tgt_verts)} verts, {len(tgt_faces)} faces")
print(f" Bones: {len(bone_names)}")
# Step 1: Find closest point on source surface for each target vertex
sqr_dist, face_idx, closest_pts = igl.point_mesh_squared_distance(
tgt_verts, src_verts, src_faces
)
distances = np.sqrt(sqr_dist)
# Step 2: Compute barycentric coordinates for interpolation
tgt_weights = np.zeros((len(tgt_verts), len(bone_names)), dtype=np.float64)
matched = np.zeros(len(tgt_verts), dtype=bool)
normal_threshold = np.cos(np.radians(normal_threshold_deg))
for vi in range(len(tgt_verts)):
if distances[vi] > dist_threshold:
continue
fi = face_idx[vi]
tri_verts = src_faces[fi]
# Check normal compatibility
src_normal = np.mean(src_normals[tri_verts], axis=0)
src_normal /= max(np.linalg.norm(src_normal), 1e-10)
tgt_normal = tgt_normals[vi]
tgt_normal /= max(np.linalg.norm(tgt_normal), 1e-10)
dot = np.dot(src_normal, tgt_normal)
if dot < normal_threshold:
continue
# Barycentric interpolation of weights
p = closest_pts[vi]
a, b, c = src_verts[tri_verts[0]], src_verts[tri_verts[1]], src_verts[tri_verts[2]]
# Compute barycentric coords
v0, v1, v2 = b - a, c - a, p - a
d00, d01, d11 = np.dot(v0, v0), np.dot(v0, v1), np.dot(v1, v1)
d20, d21 = np.dot(v2, v0), np.dot(v2, v1)
denom = d00 * d11 - d01 * d01
if abs(denom) < 1e-10:
continue
bary_v = (d11 * d20 - d01 * d21) / denom
bary_w = (d00 * d21 - d01 * d20) / denom
bary_u = 1.0 - bary_v - bary_w
# Interpolate source weights
tgt_weights[vi] = (bary_u * src_weights[tri_verts[0]] +
bary_v * src_weights[tri_verts[1]] +
bary_w * src_weights[tri_verts[2]])
matched[vi] = True
n_matched = np.sum(matched)
n_unmatched = len(tgt_verts) - n_matched
print(f" Matched: {n_matched}/{len(tgt_verts)} ({100*n_matched/max(len(tgt_verts),1):.0f}%)")
print(f" Unmatched: {n_unmatched} (will inpaint)")
# Step 3: Laplacian inpainting for unmatched vertices
if n_unmatched > 0 and n_matched > 0:
print(" Computing Laplacian inpainting...")
L, M = robust_laplacian.mesh_laplacian(tgt_verts, tgt_faces)
# Solve per bone: minimize ||L @ w||^2 subject to matched vertices = known values
unmatched_idx = np.where(~matched)[0]
matched_idx = np.where(matched)[0]
for bi in range(len(bone_names)):
known_weights = tgt_weights[matched_idx, bi]
# Build system: L[unmatched, unmatched] @ w_unknown = -L[unmatched, matched] @ w_known
L_uu = L[np.ix_(unmatched_idx, unmatched_idx)]
L_um = L[np.ix_(unmatched_idx, matched_idx)]
rhs = -L_um @ known_weights
if L_uu.shape[0] > 0:
try:
result = sp.linalg.spsolve(L_uu, rhs)
tgt_weights[unmatched_idx, bi] = np.clip(result, 0.0, 1.0)
except Exception:
# Fallback: use nearest matched vertex weight
for ui in unmatched_idx:
dists_to_matched = np.linalg.norm(tgt_verts[matched_idx] - tgt_verts[ui], axis=1)
nearest = matched_idx[np.argmin(dists_to_matched)]
tgt_weights[ui, bi] = tgt_weights[nearest, bi]
print(" Inpainting complete")
# Step 4: Normalize weights per vertex
row_sums = tgt_weights.sum(axis=1, keepdims=True)
row_sums[row_sums < 1e-10] = 1.0 # avoid division by zero
tgt_weights /= row_sums
# Step 5: Limit to 4 bones per vertex (game engine constraint)
for vi in range(len(tgt_verts)):
w = tgt_weights[vi]
if np.count_nonzero(w > 0.001) > 4:
top4 = np.argsort(w)[-4:]
mask = np.zeros_like(w)
mask[top4] = w[top4]
mask /= max(mask.sum(), 1e-10)
tgt_weights[vi] = mask
# Apply to clothing mesh
set_bone_weights(clothing_obj, bone_names, tgt_weights)
active_bones = np.sum(tgt_weights.max(axis=0) > 0.01)
print(f" Active bones: {active_bones}/{len(bone_names)}")
def export_result(clothing_obj, armature, path):
"""Export clothing + armature as GLB."""
print(f"\n Exporting to {path}...")
for obj in bpy.data.objects:
obj.hide_set(True)
clothing_obj.hide_set(False)
armature.hide_set(False)
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
armature.select_set(True)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=path,
export_format='GLB',
use_selection=True,
export_apply=False,
export_animations=False,
export_skins=True,
)
print(f" Size: {os.path.getsize(path)} bytes")
# --- Main ---
clear_scene()
print("\nStep 1: Import body...")
body_objects = import_glb(body_path)
armature = find_armature(body_objects)
body_meshes = find_meshes(body_objects)
body_mesh = max(body_meshes, key=lambda m: len(m.data.vertices))
bone_names = [b.name for b in armature.data.bones]
print(f" Armature: {armature.name} ({len(bone_names)} bones)")
print(f" Body mesh: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
print("\nStep 2: Import clothing...")
clothing_objects = import_glb(clothing_path)
clothing_meshes = find_meshes(clothing_objects)
if len(clothing_meshes) > 1:
bpy.ops.object.select_all(action='DESELECT')
for m in clothing_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = clothing_meshes[0]
bpy.ops.object.join()
clothing_mesh = clothing_meshes[0]
print(f" Clothing mesh: {clothing_mesh.name} ({len(clothing_mesh.data.vertices)} verts)")
print("\nStep 3: Cleanup...")
cleanup_trellis_mesh(clothing_mesh)
print("\nStep 4: Scale & position...")
scale_and_position(clothing_mesh, body_mesh, armature)
print("\nStep 5: Robust weight transfer...")
# Parent to armature first
clothing_mesh.parent = armature
clothing_mesh.matrix_parent_inverse = armature.matrix_world.inverted()
arm_mod = clothing_mesh.modifiers.new(name="Armature", type='ARMATURE')
arm_mod.object = armature
robust_weight_transfer(body_mesh, clothing_mesh, bone_names,
dist_threshold=0.15, normal_threshold_deg=120.0)
print("\nStep 6: Export...")
export_result(clothing_mesh, armature, output_path)
print("\n=== Done ===")