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