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:
parent
9423cf8f64
commit
a682bd871b
125
blender_ops.py
125
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 <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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user