- render/end muxes scene audio[] (adelay+volume+amix, aac, -shortest); audio paths path-guarded; render.js finalRender passes scene audio - scripts/graft_limb.py: headless Blender limb graft (align root->target bone, join armatures, parent-keep-offset, join meshes; refusal exit 2 on mitten overlap; drops phantom importer icosphere; no finish/decimate). --selftest builds synthetic fixtures + verifies both paths under Blender - /mb/submit MODELBEAST proxy (SCENEGOD_MB=1 gated, token from ~/Documents/ backnforth/.env at request time never logged, /api/assets->/api/jobs) - test_server.py: +audio-stream, +MB gating & two-step contract (mock MB); 8 groups green Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
8.6 KiB
Python
224 lines
8.6 KiB
Python
#!/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()
|