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:
parent
a682bd871b
commit
c55d7c5b8d
@ -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':
|
||||
|
||||
Loading…
Reference in New Issue
Block a user