diff --git a/blender_ops.py b/blender_ops.py
index bcc0f8e..9858721 100644
--- a/blender_ops.py
+++ b/blender_ops.py
@@ -21,7 +21,7 @@ ops:
House rules: never fit against an unrigged body (we abort); rigged meshes are never sent to
the farm /finish (this file is the safe local path for them).
"""
-import bpy, re, sys, os
+import bpy, mathutils, re, sys, os
argv = sys.argv[sys.argv.index('--') + 1:]
OP, ARGS = argv[0], argv[1:]
@@ -188,6 +188,129 @@ elif OP == 'fit':
print(f'fitted {len(meshes(garm_objs))} garment mesh(es), mode={mode}, inflate={inflate * 1000:.0f}mm')
export(ARGS[2])
+elif OP == 'graft':
+ # graft
[radius_m] [--keep]
+ #
+ # Swap a crude AI-generated body part (hands, feet, head) for a detailed one, WITHOUT the
+ # cut-and-bridge workflow every tutorial recommends. Bridge Edge Loops needs matching vertex
+ # counts on both boundary loops; a TRELLIS cut edge is arbitrary unstructured soup, so that
+ # step is where the evening goes. Instead:
+ # · never cut — MASK the body's geometry near the target bone (a vertex group + MASK
+ # modifier), so the old hand simply stops being drawn and is reversible
+ # · align the part to the bone, normalising the cross-rig scale
+ # · transfer weights from the body to the part (the same data_transfer `fit` uses) rather
+ # than Ctrl+P Automatic Weights, which would re-weight the WHOLE character and destroy
+ # the skinning Mixamo just produced
+ # · parent to the body's existing armature; never join two mixamorig armatures, because
+ # the name collision silently rewrites the bones the weights point at
+ clean()
+ body_objs = load(ARGS[0])
+ arms = armatures(body_objs)
+ if not arms:
+ raise SystemExit('body has no armature — rig it first, then graft')
+ arm = arms[0]
+ body = max(real_meshes(body_objs), key=lambda o: len(o.vertex_groups), default=None)
+ if body is None:
+ raise SystemExit('no body mesh found')
+ bone_name = ARGS[3]
+ bl = {b.name.lower(): b for b in arm.data.bones}
+ bone = bl.get(bone_name.lower()) or next(
+ (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]}…')
+ radius = float(ARGS[4]) if len(ARGS) > 4 else 0.12
+
+ import mathutils
+ anchor = arm.matrix_world @ bone.head_local
+
+ part_objs = load(ARGS[1])
+ # 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
+ for a in ARGS[5:]:
+ if a.startswith('--mesh='):
+ want_mesh = a.split('=', 1)[1].lower()
+ cands = real_meshes(part_objs)
+ if not cands:
+ raise SystemExit('part file has no mesh')
+ if want_mesh:
+ part_meshes = [o for o in cands if want_mesh in o.name.lower()]
+ if not part_meshes:
+ raise SystemExit(f'no part mesh matching {want_mesh!r}; have '
+ f'{[o.name for o in cands]}')
+ else:
+ part_meshes = [max(cands, key=lambda o: len(o.data.vertices))]
+ print(f'graft: part file holds {[o.name for o in cands]}; using '
+ f'{[o.name for o in part_meshes]}')
+ drop = [o for o in cands if o not in part_meshes]
+ part_arms = armatures(part_objs) # capture BEFORE removing, or the list goes stale
+ # Resolve NAMES up front: after bpy.data.objects.remove(), even reading o.name on a dropped
+ # object raises ReferenceError: StructRNA has been removed.
+ keep_names = [o.name for o in part_objs if o not in drop]
+ for o in drop: # drop the meshes we are not grafting
+ 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()
+
+ # 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'
+ # .location instead compounds with whatever the donor's armature/empty already carried.
+ blen = (bone.tail_local - bone.head_local).length * (arm.matrix_world.to_scale().x or 1.0)
+ blen = blen or 0.1
+ pmn, pmx = bbox_of(part_meshes)
+ psize = max((pmx - pmn).length, 1e-6)
+ ratio = (blen * 2.5) / psize
+ centre = (pmn + pmx) / 2
+ 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()
+ 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)} '
+ f'around anchor {tuple(round(c, 3) for c in anchor)}')
+
+ # MASK the body's old geometry around the bone instead of deleting it — reversible, and it
+ # sidesteps needing a clean boundary loop entirely.
+ grp = body.vertex_groups.new(name='graft_hide_' + bone.name.split(':')[-1])
+ inv = body.matrix_world.inverted()
+ local_anchor = inv @ anchor
+ hidden = [v.index for v in body.data.vertices
+ if (v.co - local_anchor).length < radius]
+ if hidden:
+ grp.add(hidden, 1.0, 'REPLACE')
+ 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}')
+
+ # Weights from the body, not Automatic Weights on the merged result.
+ for g in part_meshes:
+ with bpy.context.temp_override(object=body, active_object=body,
+ selected_editable_objects=[g, body],
+ selected_objects=[g, body]):
+ bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_create=True,
+ vert_mapping='POLYINTERP_NEAREST',
+ layers_select_src='ALL', layers_select_dst='NAME')
+ g.parent = arm
+ am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
+ 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}')
+ export(ARGS[2])
+
elif OP == 'thumb':
# thumb [px]
# Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the