diff --git a/blender_ops.py b/blender_ops.py index 9898137..83cc2bc 100644 --- a/blender_ops.py +++ b/blender_ops.py @@ -10,13 +10,18 @@ ops: weight-transfer the body's skinning onto the garment (nearest-face interpolated), parent to the body's armature. mode=merge → one GLB (dressed body); mode=garment → garment+skeleton only (a wardrobe item, reusable in assemble) + harvest [keep_skin_regex] + split a garment off an ALREADY-RIGGED clothed character by material. Blender copies + the vertex groups onto the separated mesh, so the garment lands already skinned to + the donor's skeleton — no fit, no weight transfer, and it bends correctly because it + was authored to. Trade-off: it inherits the donor's proportions. assemble ... dressed combo: garments must have been fitted against the same skeleton (bone names match) 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, sys, os +import bpy, re, sys, os argv = sys.argv[sys.argv.index('--') + 1:] OP, ARGS = argv[0], argv[1:] @@ -141,6 +146,70 @@ 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 == 'harvest': + # harvest [keep_skin_regex] + # + # The trick this whole tier rests on: bpy.ops.mesh.separate(type='MATERIAL') carries the + # vertex groups onto the separated piece. So a shirt split off a mixamorig donor arrives + # ALREADY skinned to the exact skeleton every clip in the bank drives — no weight transfer, + # no fitting, no shrinkwrap. It deforms correctly at the elbow because it was authored to. + # + # Cost, stated honestly: the garment is captive to that donor's proportions. On a slimmer + # body it will clip or float; there is no cloth sim here to save it. + clean() + objs = load(ARGS[0]) + want = ARGS[2].lower() + skin_re = re.compile(ARGS[3] if len(ARGS) > 3 else r'skin|body|face|eye|hair|teeth|tongue|lash|brow|nail|mouth|head|hand|beard', re.I) + arms = armatures(objs) + if not arms: + raise SystemExit('donor has no armature — it cannot donate a skinned garment') + arm = arms[0] + kept = [] + for m in list(meshes(objs)): + names = [ms.name for ms in m.data.materials if ms] + if not any(want in n.lower() for n in names): + continue + if len(names) > 1: # split this mesh along its material seams + bpy.ops.object.select_all(action='DESELECT') + m.select_set(True) + bpy.context.view_layer.objects.active = m + bpy.ops.object.mode_set(mode='EDIT') + bpy.ops.mesh.select_all(action='SELECT') + bpy.ops.mesh.separate(type='MATERIAL') + bpy.ops.object.mode_set(mode='OBJECT') + # after separating, keep only pieces whose material matches and which aren't skin + for m in list(meshes(list(bpy.data.objects))): + names = [ms.name for ms in m.data.materials if ms] + hit = any(want in n.lower() for n in names) + skin = any(skin_re.search(n) for n in names) + if hit and not skin: + kept.append(m) + if not kept: + raise SystemExit(f'no garment mesh matched material {ARGS[2]!r} ' + f'(donor may bake skin+cloth onto one atlas — nothing to separate)') + # purge everything else, keeping the armature so the weights stay meaningful + for o in list(bpy.data.objects): + if o.type == 'MESH' and o not in kept: + bpy.data.objects.remove(o, do_unlink=True) + elif o.type == 'ARMATURE' and o is not arm: + bpy.data.objects.remove(o, do_unlink=True) + for g in kept: + g.parent = arm + for mod in list(g.modifiers): + if mod.type == 'ARMATURE': + mod.object = arm + if not any(mod.type == 'ARMATURE' for mod in g.modifiers): + am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm + # drop vertex groups the garment no longer uses — a separated sleeve keeps the whole + # donor's group list, which bloats the GLB and confuses later merges + used = {gi.group for v in g.data.vertices for gi in v.groups if gi.weight > 0.0001} + for vg in list(g.vertex_groups): + if vg.index not in used: + g.vertex_groups.remove(vg) + print(f'harvested {len(kept)} mesh(es) for {ARGS[2]!r}: ' + f'{total_tris(kept):,} tris, {len(kept[0].vertex_groups)} bones driving') + export(ARGS[1]) + elif OP == 'assemble': clean() body_objs = load(ARGS[0]) @@ -148,17 +217,82 @@ elif OP == 'assemble': if not arms: raise SystemExit('body has no armature') arm = arms[0] + def bare(n): + """'mixamorig:LeftArm.001' -> 'leftarm'. The fleet is NOT uniform: character_kit and + NPCFACTORY rigs use the 'mixamorig:' prefix, Ready Player Me donors use bare 'Hips', + and Blender suffixes '.001' on any import collision. Matching on the bare name is what + actually makes a garment harvested off one rig drive on another.""" + n = re.sub(r'\.\d{3}$', '', n) + return re.sub(r'^.*[:_]', '', n).lower() + + body_bones = {b.name for b in arm.data.bones} + bare_map = {bare(b.name): b.name for b in arm.data.bones} for gpath in ARGS[2:]: gobjs = load(gpath) + # Both rigs are mixamorig, so importing the garment collides on EVERY bone name and + # Blender silently suffixes the newcomer '.001'. Re-parenting then leaves the garment's + # vertex groups pointing at bones that no longer exist, and the result is a mesh frozen + # in bind pose while the body animates — no error, just a garment that refuses to move. + # Map each group back onto the body's real bone name before dropping the dupe armature. + # Donors are not authored at a common scale — character_kit rigs are ~0.06m tall while a + # Ready Player Me donor is ~1.5m, so a harvested garment can arrive 25x oversized and + # swallow the whole scene. Rescale by the ratio of a shared bone chain (hips->head) + # before parenting, the same trick character_kit's assemble.fit_part uses. + garm_arms = armatures(gobjs) + + def span(a): + bb = {bare(b.name): b for b in a.data.bones} + h, d = bb.get('hips'), bb.get('head') + if h and d: + return (a.matrix_world @ d.head_local - a.matrix_world @ h.head_local).length + zs = [(a.matrix_world @ b.head_local).z for b in a.data.bones] + return (max(zs) - min(zs)) if zs else 0.0 + + ratio = 1.0 + if garm_arms: + gs, bs = span(garm_arms[0]), span(arm) + if gs > 1e-9 and bs > 1e-9: + ratio = bs / gs + if abs(ratio - 1.0) > 0.02: + print(f' rescaling garment x{ratio:.4f} (donor rig {1 / ratio:.2f}x the body rig)') + for g in meshes(gobjs): + for vg in g.vertex_groups: + if vg.name in body_bones: + continue + tgt = bare_map.get(bare(vg.name)) + if tgt: + vg.name = tgt + else: + print(f' WARN: {g.name} group {vg.name!r} has no bone on the body rig') + if abs(ratio - 1.0) > 1e-6: + g.scale = [c * ratio for c in g.scale] + with bpy.context.temp_override(object=g, active_object=g, + selected_editable_objects=[g]): + bpy.ops.object.transform_apply(location=True, rotation=False, scale=True) g.parent = arm + g.matrix_parent_inverse = arm.matrix_world.inverted() for m in g.modifiers: if m.type == 'ARMATURE': m.object = arm if not any(m.type == 'ARMATURE' for m in g.modifiers): am = g.modifiers.new('arm', 'ARMATURE'); am.object = arm - for a in armatures(gobjs): # bone names match by contract — drop the dupe + for a in armatures(gobjs): bpy.data.objects.remove(a, do_unlink=True) + # the body armature may itself have been renamed by a collision — put it back, or the + # exported clips reference a node name that no longer exists + for b in arm.data.bones: + base = re.sub(r'\.\d{3}$', '', b.name) + if base != b.name and base not in {x.name for x in arm.data.bones}: + b.name = base + # Drop bone-widget / marker meshes: no material and a handful of verts. These ride along in + # character_kit rigs (a 42-vert 'Icosphere'), contribute nothing visually, and wreck the + # scene bbox — which drives both the HUD height and the viewer's auto-framing, so one stray + # widget makes the camera frame the widget and the character render as a speck. + for o in list(bpy.data.objects): + if o.type == 'MESH' and not [m for m in o.data.materials if m] and len(o.data.vertices) < 100: + print(f' dropped helper mesh {o.name} ({len(o.data.vertices)} verts, no material)') + bpy.data.objects.remove(o, do_unlink=True) print(f'assembled body + {len(ARGS) - 2} garment file(s)') export(ARGS[1])