graft op: swap crude AI body parts for detailed ones, without the cut-and-bridge dead end

John wants to attach better hands/feet/faces to Mixamo-rigged TRELLIS characters and asked
whether it can be automated. It can, but not the way the standard tutorial workflow says.

Measured on his actual file (chubs-trelli-30k-sol-rigged.fbx): 240,615 verts, 33 bones, and only
EIGHT finger bones across both hands — Mixamo's reduced skeleton, which is exactly why the hands
look crude. Armature scale 0.01 against mesh scale 100.0, the classic cm/m mismatch.

Three things in the usual advice are wrong for these meshes specifically:
· "Bridge Edge Loops" needs matching vertex counts on both boundary loops. A TRELLIS cut edge is
  arbitrary unstructured soup, so bridging to a clean hand needs manual retopo — that step is
  where the evening goes.
· "Ctrl+P > With Automatic Weights" on the joined mesh re-weights the WHOLE character, destroying
  the skinning Mixamo just produced.
· "Ctrl+J the two armatures" collides every mixamorig bone name; Blender suffixes the newcomer
  .001 and the weights silently point at bones that no longer exist. That is the same failure
  that left a harvested garment frozen in bind pose earlier today.

So graft never cuts. It MASKS the body geometry near the target bone (vertex group + inverted
MASK modifier — reversible, and no boundary loop required), aligns the part with a single matrix
that scales about its own bbox centre onto the bone, transfers weights from the body with the
same data_transfer `fit` uses, and parents to the EXISTING armature.

Two bugs found while testing, both mine:
· scaling the part's root objects compounded with the donor's own armature/empty transforms and
  flung the part far enough to blow out the scene bbox (×111 instead of ×11). Now one matrix
  applied to the meshes themselves, after detaching them from the donor hierarchy world-position-safe.
· after bpy.data.objects.remove(), even reading o.name on a dropped object raises
  ReferenceError: StructRNA has been removed. Names are now resolved before removal.

Also: a "part" file is often a whole donor character — character_kit/rigged/hand1.glb holds a
prop, a full body AND a widget — so --mesh=<substr> picks the mesh and the op PRINTS what the
file contained and what it chose, rather than silently grafting a torso.

Runs on ultra per John's steer; this laptop's Blender is crash-prone (it segfaulted on me earlier
in this session).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-24 23:30:27 +10:00
parent 9423cf8f64
commit a682bd871b

View File

@ -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 <body> <part> <out.glb> <bone> [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 <in> <out.png> [px]
# Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the