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:
parent
d6d69189ed
commit
e35b613600
117
blender_ops.py
117
blender_ops.py
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user