graft: add an articulating bind mode; body-bind works, part-bind mesh placement does NOT yet

Tested against John's real assets: chubs-trelli-30k-sol-rigged.fbx (Mixamo, 33 bones, only 8
finger bones across BOTH hands) and whitgrl/base/rigged_hand.glb (69 bones, 65 of them fingers).

That gap is the whole point: transferring weights from the body gives detailed hand GEOMETRY
still driven by 4 wrist bones, so the fingers cannot move. Hence two modes:

  --bind=body (default, WORKING): mask the body's old hand, weight-transfer the new geometry onto
    the existing rig. Verified end to end on chubs — correct scale (auto-corrected a x11 gap),
    correct anchor, 9203 verts masked, renders correctly.
  --bind=part (rig merge WORKING, mesh placement NOT): joins the part's own skeleton and parents
    its root under the target bone. Verified: body rig 33 -> 102 bones, parented under
    mixamorig:RightHand, and it refuses to join if the two rigs share bone names (safe here only
    because the hand uses _rootJoint/thumb_base.R_03, not mixamorig).
    KNOWN BROKEN: the hand MESH does not land on the wrist — it stays near the origin as a stray
    sliver while the bones land correctly. The asset is a Sketchfab export nesting mesh and rig
    several empties deep (RootNode > Sketchfab_model > *.fbx > ...); transforming the top-most
    ancestor did NOT fix it, so my diagnosis of that step is still wrong. Do not trust
    --bind=part until this is resolved.

Two real bugs found and fixed along the way, both from holding Blender references across
mutations — the same class as the earlier ReferenceError:
· bpy.ops.object.join() REALLOCATES arm.data.bones, so a held Bone pointer silently refers to a
  different entry afterwards. A graft aimed at mixamorig:RightHand ended up parented under
  mixamorig:RightHandIndex4. Names are captured up front now.
· a part file is often a whole donor character, so --mesh= selects and the op prints what the
  file held and what it chose rather than silently grafting a torso.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 00:15:40 +10:00
parent a682bd871b
commit c55d7c5b8d

View File

@ -218,6 +218,10 @@ elif OP == 'graft':
(b for n, b in bl.items() if bone_name.lower() in n), None)
if bone is None:
raise SystemExit(f'no bone matching {bone_name!r}. have: {sorted(bl)[:12]}')
# Hold the NAME, not the Bone. bpy.ops.object.join() reallocates arm.data.bones, after which
# a held Bone pointer silently refers to a different entry — that is how a graft aimed at
# mixamorig:RightHand ended up parented under mixamorig:RightHandIndex4.
TARGET_BONE = bone.name
radius = float(ARGS[4]) if len(ARGS) > 4 else 0.12
import mathutils
@ -227,10 +231,12 @@ elif OP == 'graft':
# A "part" file is often a whole donor character (character_kit's rigged/hand1.glb carries a
# prop, a full body AND a widget). Let the caller name the mesh; otherwise take the largest
# real mesh and SAY which one was chosen rather than silently grafting a torso.
want_mesh = None
want_mesh, bind = None, 'body'
for a in ARGS[5:]:
if a.startswith('--mesh='):
want_mesh = a.split('=', 1)[1].lower()
elif a.startswith('--bind='):
bind = a.split('=', 1)[1].lower()
cands = real_meshes(part_objs)
if not cands:
raise SystemExit('part file has no mesh')
@ -252,14 +258,16 @@ elif OP == 'graft':
bpy.data.objects.remove(o, do_unlink=True)
part_objs = [bpy.data.objects[n] for n in keep_names if n in bpy.data.objects]
# Detach from the donor's own hierarchy first, keeping world position, so the transform below
# is not fighting a parent armature's scale (the donor is often 100x ours).
for g in part_meshes:
if g.parent:
wm = g.matrix_world.copy()
g.parent = None
g.matrix_world = wm
bpy.context.view_layer.update()
# bind=body discards the donor rig, so detach first (keeping world position) or the transform
# below fights the donor armature's scale. bind=body only — under bind=part the mesh must stay
# bound to its own rig, and we move that rig instead so the two never double-apply.
if bind == 'body':
for g in part_meshes:
if g.parent:
wm = g.matrix_world.copy()
g.parent = None
g.matrix_world = wm
bpy.context.view_layer.update()
# One transform, applied to the meshes themselves: scale about the part's own bbox centre to
# match the bone's length, then translate that centre onto the bone. Nudging root objects'
@ -273,9 +281,11 @@ elif OP == 'graft':
T = (mathutils.Matrix.Translation(anchor)
@ mathutils.Matrix.Scale(ratio, 4)
@ mathutils.Matrix.Translation(-centre))
for g in part_meshes:
g.matrix_world = T @ g.matrix_world
bpy.context.view_layer.update()
if bind == 'body':
for g in part_meshes:
g.matrix_world = T @ g.matrix_world
bpy.context.view_layer.update()
# under bind=part the armature carries the transform (applied below) and the mesh rides along
pmn2, pmx2 = bbox_of(part_meshes)
print(f'graft: scaled x{ratio:.4f}, part bbox now '
f'{tuple(round(c, 3) for c in pmn2)}..{tuple(round(c, 3) for c in pmx2)} '
@ -293,9 +303,55 @@ elif OP == 'graft':
m = body.modifiers.new('graft_mask', 'MASK')
m.vertex_group = grp.name
m.invert_vertex_group = True # keep everything EXCEPT the masked region
print(f'graft: masked {len(hidden)} body verts within {radius}m of {bone.name}')
print(f'graft: masked {len(hidden)} body verts within {radius}m of {TARGET_BONE}')
# Weights from the body, not Automatic Weights on the merged result.
# --bind=part: bring the part's OWN skeleton in and hang it off the target bone. This is the
# mode that actually articulates — a detailed hand carries ~65 finger bones where Mixamo's
# reduced rig has 8 across both hands, so binding to the body's wrist gives nice geometry that
# cannot move its fingers. Joining is only safe because the part's bones are NOT mixamorig-
# named (_rootJoint / thumb_base.R_03 …); two mixamorig rigs would collide and silently
# repoint every weight, which is why `assemble` refuses to join and remaps instead.
if bind == 'part' and part_arms:
parm = part_arms[0]
# Transform the TOP-MOST ancestor of everything we keep. Sketchfab-style exports nest the
# rig and mesh several empties deep (RootNode > Sketchfab_model > *.fbx > ...), so
# "objects with no parent" finds the wrong node and the mesh stays at the origin — a
# stray sliver on the floor while the bones land correctly on the wrist.
def top(o):
while o.parent:
o = o.parent
return o
for r in {top(o).name for o in list(part_meshes) + [parm]}:
obj = bpy.data.objects[r]
obj.matrix_world = T @ obj.matrix_world
bpy.context.view_layer.update()
root_bones = [b.name for b in parm.data.bones if b.parent is None]
clash = {b.name for b in parm.data.bones} & {b.name for b in arm.data.bones}
if clash:
raise SystemExit(f'part rig shares {len(clash)} bone name(s) with the body '
f'({sorted(clash)[:3]}…). Joining would silently repoint weights — '
f'use --bind=body instead.')
bpy.ops.object.select_all(action='DESELECT')
parm.select_set(True); arm.select_set(True)
bpy.context.view_layer.objects.active = arm # active survives the join
bpy.ops.object.join()
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode='EDIT')
eb = arm.data.edit_bones
tgt = eb.get(TARGET_BONE)
for rb in root_bones:
if rb in eb and tgt:
eb[rb].parent = tgt
eb[rb].use_connect = False # keep offset; connecting would snap it
bpy.ops.object.mode_set(mode='OBJECT')
print(f'graft: joined {len(root_bones)} part-rig root(s) under {TARGET_BONE} '
f'— body rig now {len(arm.data.bones)} bones, fingers articulate')
export(ARGS[2])
raise SystemExit(0)
# --bind=body (default): weights from the body, NOT Automatic Weights on a merged mesh.
for g in part_meshes:
with bpy.context.temp_override(object=body, active_object=body,
selected_editable_objects=[g, body],
@ -308,7 +364,7 @@ elif OP == 'graft':
for a in part_arms: # never join two mixamorig rigs
if a.name in bpy.data.objects:
bpy.data.objects.remove(a, do_unlink=True)
print(f'grafted {len(part_meshes)} mesh(es) onto {bone.name}')
print(f'grafted {len(part_meshes)} mesh(es) onto {TARGET_BONE}')
export(ARGS[2])
elif OP == 'thumb':