#!/usr/bin/env python3 """graft_limb.py — graft a rigged limb GLB onto a rigged body GLB (headless Blender). blender -b -P scripts/graft_limb.py -- \ --body body.glb --limb hand.glb --bone mixamorig:RightHand --out grafted.glb blender -b -P scripts/graft_limb.py -- --selftest # builds synthetic fixtures What it does (C-server.md §C6): import both; align the limb's root bone head to the target bone's world head; join the two armatures; parent the limb root under the target bone keeping its offset; join the meshes (vertex-group weights survive because the limb's bone names now live in the merged armature); export GLB. Refusal: if the body armature ALREADY has the bones the limb provides, this is a mitten (the body is skinned there already) — exit 2 with a re-skin message instead of duplicating bones. Rig contract: the grafted output NEVER goes through a join/decimate "finish" path — that spawns phantom joint meshes and mauls the armature. Export as-is. """ import sys import bpy from mathutils import Vector class GraftRefused(Exception): """Body already carries the limb's bones — re-skin, don't graft.""" def _clear(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete() for coll in (bpy.data.meshes, bpy.data.armatures): for d in list(coll): if d.users == 0: coll.remove(d) def _import(path): """Import a GLB; return (armature_obj, [skinned mesh_objs]). Meshes not bound to the armature are dropped — the Blender 5 glTF importer injects a stray unparented icosphere, and a graft only cares about rig-deformed geometry.""" before = set(bpy.data.objects) bpy.ops.import_scene.gltf(filepath=path) new = [o for o in bpy.data.objects if o not in before] arms = [o for o in new if o.type == "ARMATURE"] if not arms: sys.exit(f"[graft] no armature in {path}") arm = arms[0] meshes = [o for o in new if o.type == "MESH" and ( o.parent is arm or o.vertex_groups or any(m.type == "ARMATURE" for m in o.modifiers))] for o in new: # remove strays (phantom icosphere, lights/cameras aren't MESH) if o.type == "MESH" and o not in meshes: bpy.data.objects.remove(o, do_unlink=True) return arm, meshes def _root_bone(arm): for b in arm.data.bones: if b.parent is None: return b.name return arm.data.bones[0].name def _world_head(arm, bone_name): return arm.matrix_world @ arm.data.bones[bone_name].head_local def graft(body_path, limb_path, target_bone, out_path): _clear() body_arm, body_meshes = _import(body_path) limb_arm, limb_meshes = _import(limb_path) if target_bone not in body_arm.data.bones: sys.exit(f"[graft] target bone {target_bone!r} not in body armature " f"(have: {[b.name for b in body_arm.data.bones][:20]})") limb_root = _root_bone(limb_arm) body_bones = {b.name for b in body_arm.data.bones} provides = {b.name for b in limb_arm.data.bones} - {limb_root} clash = provides & body_bones if clash: raise GraftRefused(f"bones exist; mitten weights — re-skin, don't graft " f"(overlap: {sorted(clash)})") # 1. align limb root head -> target bone head (world space) delta = _world_head(body_arm, target_bone) - _world_head(limb_arm, limb_root) limb_arm.matrix_world.translation += delta bpy.context.view_layer.update() # 2. remember which meshes the limb armature deforms, then join armatures for m in limb_meshes: for mod in m.modifiers: if mod.type == "ARMATURE": mod.object = None # detach before join deletes limb_arm; re-point below bpy.ops.object.select_all(action="DESELECT") limb_arm.select_set(True) body_arm.select_set(True) bpy.context.view_layer.objects.active = body_arm bpy.ops.object.join() # limb bones now live inside body_arm; limb_arm object gone # 3. re-point limb meshes at the merged armature + parent limb root under target for m in limb_meshes: had = any(mod.type == "ARMATURE" for mod in m.modifiers) for mod in m.modifiers: if mod.type == "ARMATURE": mod.object = body_arm if not had: mod = m.modifiers.new("Armature", "ARMATURE") mod.object = body_arm m.parent = body_arm bpy.context.view_layer.objects.active = body_arm bpy.ops.object.mode_set(mode="EDIT") eb = body_arm.data.edit_bones eb[limb_root].parent = eb[target_bone] eb[limb_root].use_connect = False # keep offset bpy.ops.object.mode_set(mode="OBJECT") # 4. join meshes (vertex groups merge by bone name -> weights survive) all_meshes = body_meshes + limb_meshes if len(all_meshes) > 1: bpy.ops.object.select_all(action="DESELECT") for m in all_meshes: m.select_set(True) bpy.context.view_layer.objects.active = all_meshes[0] bpy.ops.object.join() # 5. export as-is — NO finish/decimate (rig contract) bpy.ops.object.select_all(action="SELECT") bpy.ops.export_scene.gltf(filepath=out_path, export_format="GLB", use_selection=True) print(f"[graft] wrote {out_path}") # --- selftest: build synthetic fixtures and exercise both paths ------------- def _make_rig(name, bones, out): """bones = [(name, head, tail, parent_or_None)]; builds a skinned cube, exports GLB.""" _clear() bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0)) arm = bpy.context.object arm.name = name eb = arm.data.edit_bones eb.remove(eb[0]) # drop the default bone for bn, head, tail, parent in bones: b = eb.new(bn) b.head, b.tail = Vector(head), Vector(tail) if parent: b.parent = eb[parent] bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.mesh.primitive_cube_add(size=0.4, location=tuple(bones[-1][1])) cube = bpy.context.object bpy.ops.object.select_all(action="DESELECT") cube.select_set(True) arm.select_set(True) bpy.context.view_layer.objects.active = arm bpy.ops.object.parent_set(type="ARMATURE_AUTO") bpy.ops.object.select_all(action="SELECT") bpy.ops.export_scene.gltf(filepath=out, export_format="GLB", use_selection=True) def selftest(): import os import tempfile d = tempfile.mkdtemp(prefix="graft_selftest_") body = os.path.join(d, "body.glb") limb_ok = os.path.join(d, "limb_ok.glb") limb_bad = os.path.join(d, "limb_bad.glb") out = os.path.join(d, "grafted.glb") # full-rig body: root -> RightHand -> RightHandIndex1 _make_rig("Body", [ ("root", (0, 0, 0), (0, 0, 1), None), ("mixamorig:RightHand", (0, 0, 1), (0, 0, 1.3), "root"), ("mixamorig:RightHandIndex1", (0, 0, 1.3), (0, 0, 1.5), "mixamorig:RightHand"), ], body) # good limb: custom bone names, no overlap with body _make_rig("LimbOK", [ ("nub_root", (0, 0, 0), (0, 0, 0.3), None), ("nub_tip", (0, 0, 0.3), (0, 0, 0.5), "nub_root"), ], limb_ok) # bad limb: provides a bone the body already has -> mitten _make_rig("LimbBad", [ ("bad_root", (0, 0, 0), (0, 0, 0.2), None), ("mixamorig:RightHandIndex1", (0, 0, 0.2), (0, 0, 0.4), "bad_root"), ], limb_bad) # refusal path try: graft(body, limb_bad, "mixamorig:RightHand", out) sys.exit("[selftest] FAIL: expected GraftRefused for overlapping bones") except GraftRefused as e: print(f"[selftest] refusal OK: {e}") # graft path graft(body, limb_ok, "mixamorig:RightHand", out) assert os.path.isfile(out) and os.path.getsize(out) > 0, "output GLB missing" _clear() arm, meshes = _import(out) names = {b.name for b in arm.data.bones} assert {"mixamorig:RightHand", "nub_root", "nub_tip"} <= names, f"merged bones wrong: {names}" assert arm.data.bones["nub_root"].parent.name == "mixamorig:RightHand", "root not parented" assert len(meshes) == 1, f"meshes not joined: {len(meshes)}" print("[selftest] graft OK: merged armature + single skinned mesh") def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] if "--selftest" in argv: selftest() return def opt(flag): return argv[argv.index(flag) + 1] if flag in argv else None body, limb, bone, out = opt("--body"), opt("--limb"), opt("--bone"), opt("--out") if not all((body, limb, bone, out)): sys.exit("usage: -- --body B.glb --limb L.glb --bone NAME --out O.glb") try: graft(body, limb, bone, out) except GraftRefused as e: print(f"[graft] REFUSED: {e}", file=sys.stderr) sys.exit(2) if __name__ == "__main__": main()