From c55d7c5b8d4267edbd548a6f23ebb047612521ea Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 00:15:40 +1000 Subject: [PATCH] graft: add an articulating bind mode; body-bind works, part-bind mesh placement does NOT yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- blender_ops.py | 86 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 15 deletions(-) diff --git a/blender_ops.py b/blender_ops.py index 9858721..a768a1e 100644 --- a/blender_ops.py +++ b/blender_ops.py @@ -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':