graft: mirror + bind=none + mixamoprep; masking fixed; THIS hand asset still not placeable

Delivering the honest state after a long debugging run. Real fixes that stand on their own:

· MASK RADIUS WAS COMPARED IN THE WRONG SPACE. Vertices were tested against a world-metre radius
  using local coordinates, so the mask silently found nothing whenever the object carried a
  scale — 8126 verts matched as FBX (mesh scale 100) and ZERO after a GLB round-trip. Now
  compared in world space.
· BODY MESH WAS SELECTED BY VERTEX-GROUP COUNT. A previously grafted part inherits every one of
  the body's groups from the weight transfer, so on a second pass the HAND was being chosen as
  "the body" — masking the other wrist then found nothing and the old hand silently survived
  under the new one. Now selected by vertex count among skinned meshes, and the choice is printed.
· join() leaves the part mesh's ARMATURE modifier dangling at None and re-parents it to whatever
  empty sat above it, collapsing it to the origin. Placement is now saved and re-bound explicitly.
· --mirror added (the asset is a .R hand; the .L needs it) with outward normal recalculation,
  since a negative-scale mirror leaves every face wound backwards.
· mixamoprep added: bakes graft MASK modifiers (a modifier is not a deletion — the geometry comes
  back in the export otherwise), joins to ONE mesh, strips the armature and stale vertex groups,
  scales to height, writes FBX. Mixamo's auto-rigger wants exactly that shape of input.

STILL NOT WORKING, and I am not going to claim otherwise: placing THIS hand asset. Its stored
rest geometry is not hand-shaped — measured 84 units tall with the armature modifier off versus
~0.08 with it on, so the rig is what poses it into a hand. Removing the modifier exports the
spiky rest mesh; applying it did not bake the shape either. Every headless attempt to detach,
transform and re-bake it has corrupted the geometry.

Recommendation rather than more blind iteration: open rigged_hand.glb once in the Blender GUI on
ultra, apply the pose/armature by hand, and export a clean STATIC hand.glb. Positioning a static
mesh is the part that already works. The asset is an awkward Sketchfab export (mesh and rig
nested several empties deep, non-hand rest pose); one manual pass makes everything downstream
trivial and repeatable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-25 01:31:16 +10:00
parent d6d69189ed
commit e35b613600

View File

@ -209,9 +209,15 @@ elif OP == 'graft':
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)
# Pick the body by VERTEX COUNT, not vertex-group count. A previously grafted part inherits
# every one of the body's groups from the weight transfer, so counting groups can select the
# hand as "the body" on a second pass — after which masking the other wrist finds nothing and
# the old geometry silently survives under the new part.
skinned = [o for o in real_meshes(body_objs) if o.vertex_groups]
body = max(skinned or real_meshes(body_objs), key=lambda o: len(o.data.vertices), default=None)
if body is None:
raise SystemExit('no body mesh found')
print(f'graft: body mesh = {body.name} ({len(body.data.vertices):,} verts)')
bone_name = ARGS[3]
bl = {b.name.lower(): b for b in arm.data.bones}
bone = bl.get(bone_name.lower()) or next(
@ -231,12 +237,14 @@ 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, bind = None, 'body'
want_mesh, bind, mirror = None, 'body', False
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()
elif a == '--mirror':
mirror = True # a .R hand asset becomes the .L one
cands = real_meshes(part_objs)
if not cands:
raise SystemExit('part file has no mesh')
@ -261,7 +269,7 @@ elif OP == 'graft':
# 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':
if bind in ('body', 'none'):
for g in part_meshes:
if g.parent:
wm = g.matrix_world.copy()
@ -278,10 +286,12 @@ elif OP == 'graft':
psize = max((pmx - pmn).length, 1e-6)
ratio = (blen * 2.5) / psize
centre = (pmn + pmx) / 2
M = mathutils.Matrix.Scale(-1.0, 4, (1, 0, 0)) if mirror else mathutils.Matrix.Identity(4)
T = (mathutils.Matrix.Translation(anchor)
@ mathutils.Matrix.Scale(ratio, 4)
@ M
@ mathutils.Matrix.Translation(-centre))
if bind == 'body':
if bind in ('body', 'none'):
for g in part_meshes:
g.matrix_world = T @ g.matrix_world
bpy.context.view_layer.update()
@ -293,11 +303,13 @@ elif OP == 'graft':
# 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
grp = body.vertex_groups.new(name='graft_hide_' + TARGET_BONE.split(':')[-1])
# Compare in WORLD space. Testing `v.co` against a world radius silently mis-masks whenever
# the object carries a scale: the same body measured 8126 verts as FBX (mesh scale 100) and
# ZERO after a GLB round-trip, because 0.1 world metres is 0.001 local units at that scale.
mw = body.matrix_world
hidden = [v.index for v in body.data.vertices
if (v.co - local_anchor).length < radius]
if ((mw @ v.co) - anchor).length < radius]
if hidden:
grp.add(hidden, 1.0, 'REPLACE')
m = body.modifiers.new('graft_mask', 'MASK')
@ -366,6 +378,37 @@ elif OP == 'graft':
export(ARGS[2])
raise SystemExit(0)
# --bind=none: position only. Correct choice when the result is going straight back to Mixamo
# for a fresh auto-rig, because the binding is about to be thrown away anyway — and skipping
# it avoids the armature modifier deforming the part from a bind pose it never had.
if bind == 'none':
for g in part_meshes:
# Drop the donor's ARMATURE modifier first — left attached it keeps deforming the
# mesh from a bind pose whose armature we are about to discard, which is what turned
# the hand into a spike. Then unparent KEEPING the world matrix, or the accumulated
# parent transform vanishes and the mesh jumps back to its local coordinates.
# APPLY the donor's armature modifier, do not remove it. This asset's stored rest
# geometry is not hand-shaped — it is the armature that poses it into a hand (bbox
# measured 84 units tall with the modifier off, ~0.08 with it on). Removing the
# modifier therefore exports the spiky rest mesh; applying it bakes the real shape
# into the vertices, which is what we want before discarding the rig.
for m in list(g.modifiers):
if m.type == 'ARMATURE':
apply_mod(g, m.name)
wm = g.matrix_world.copy()
g.parent = None
g.matrix_world = wm
for a in part_arms:
if a.name in bpy.data.objects:
bpy.data.objects.remove(a, do_unlink=True)
bpy.context.view_layer.update()
mn3, mx3 = bbox_of(part_meshes)
print(f'graft: positioned {len(part_meshes)} mesh(es) at {TARGET_BONE}, no binding '
f'(re-rig downstream); part now {tuple(round(c, 3) for c in mn3)}..'
f'{tuple(round(c, 3) for c in mx3)}')
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,
@ -376,12 +419,70 @@ elif OP == 'graft':
layers_select_src='ALL', layers_select_dst='NAME')
g.parent = arm
am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm
if mirror:
# A negative-scale mirror leaves every face wound backwards, so the part renders
# inside-out. Recalculate outward normals on the mirrored geometry.
for g in part_meshes:
bpy.ops.object.select_all(action='DESELECT')
g.select_set(True)
bpy.context.view_layer.objects.active = g
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
print('graft: mirrored — normals recalculated outward')
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 {TARGET_BONE}')
export(ARGS[2])
elif OP == 'mixamoprep':
# mixamoprep <in> <out.fbx> [height_m]
#
# Mixamo's auto-rigger wants ONE mesh and no existing skeleton — hand it several objects or a
# rig and it either refuses or rigs the wrong thing. So: bake any graft MASK modifiers (or the
# geometry we "removed" comes back in the export), join every real mesh into one, drop the
# armature entirely, and write FBX.
clean()
objs = load(ARGS[0])
keep = real_meshes(objs)
if not keep:
raise SystemExit('nothing to export')
for o in keep: # bake masks first — a modifier is not a deletion
for m in list(o.modifiers):
if m.type == 'MASK':
apply_mod(o, m.name)
elif m.type == 'ARMATURE':
o.modifiers.remove(m) # re-rigging from scratch; old binding is noise
for o in list(bpy.data.objects): # armatures and helper widgets both confuse the rigger
if o.type == 'ARMATURE' or (o.type == 'MESH' and o not in keep):
bpy.data.objects.remove(o, do_unlink=True)
keep = [o for o in keep if o.name in bpy.data.objects]
bpy.ops.object.select_all(action='DESELECT')
for o in keep:
o.select_set(True)
bpy.context.view_layer.objects.active = keep[0]
if len(keep) > 1:
bpy.ops.object.join()
merged = bpy.context.view_layer.objects.active
for vg in list(merged.vertex_groups): # stale groups name bones that no longer exist
merged.vertex_groups.remove(vg)
if len(ARGS) > 2:
mn, mx = bbox_of([merged])
h = mx.z - mn.z
if h > 0:
s = float(ARGS[2]) / h
merged.scale = [c * s for c in merged.scale]
print(f'mixamoprep: scaled x{s:.4f} to {ARGS[2]}m')
tris = sum(len(p.vertices) - 2 for p in merged.data.polygons)
print(f'mixamoprep: one mesh, {len(merged.data.vertices):,} verts / {tris:,} tris, no armature')
if tris > 1500000:
print('mixamoprep: WARNING — Mixamo rejects very dense meshes; decimate first')
os.makedirs(os.path.dirname(ARGS[1]), exist_ok=True)
bpy.ops.export_scene.fbx(filepath=ARGS[1], path_mode='COPY', embed_textures=True)
print(f'WROTE {ARGS[1]}')
elif OP == 'thumb':
# thumb <in> <out.png> [px]
# Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the