From e35b6136000cda6c5328231b9408164f0d40caa5 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 01:31:16 +1000 Subject: [PATCH] graft: mirror + bind=none + mixamoprep; masking fixed; THIS hand asset still not placeable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- blender_ops.py | 117 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/blender_ops.py b/blender_ops.py index b75cbdf..6e76aa0 100644 --- a/blender_ops.py +++ b/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 [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 [px] # Fixed front ortho camera on the real mesh (helpers excluded, or a bone widget decides the