SCENEGOD/scripts/graft_limb.py
type-two be65355f6b [laneC] M4: audio mux, graft_limb.py, MODELBEAST proxy
- 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>
2026-07-18 20:47:31 +10:00

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()