35 payloads move to tooling/scripts/blender/ and stay outside package scope. They run under Blender's bundled Python, which cannot see the repo venv, so they physically cannot import tooling.core — holding them to the D-263 contract would either fail the gate forever or force the contract to be weakened for everyone, and the second is how a gate stops meaning anything. Count verified by import rather than filename: 33 import bpy/bmesh directly, and the two that do not are still payloads per their own usage lines. garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290. The bash wrapper is retired rather than kept. Keeping it would have put the install-resolution logic in two places, which is the duplication T-1286 had just finished collapsing three copies of. domains/blender/service.py owns the decisions — resolve_blender (native beats flatpak, ordering preserved), resolve_payload, absolutise — and only run_payload performs. test_blender.py pins all of them without launching Blender, which matters here more than usual: the thing being launched is a 200 MB GUI application that writes GLBs. `reach blender run` takes a registered payload name OR a path to any script, because the wrapper served both — the spikes and the glb-gen skill hand it one-off scripts of their own. An unknown name enumerates all 35 and exits 2. The exclusion now defends itself. check_carve_out_stays_carved fails if `scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty exclusion proves nothing), or if an __init__.py appears there (which would make the payloads importable — the coupling the carve-out exists to prevent). All three arms mutation-proved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
6.8 KiB
Python
228 lines
6.8 KiB
Python
"""
|
|
SUPERSEDED: Head-only separation from a Quaternius body mesh.
|
|
|
|
This script splits the body into head vs body based on Head bone weights.
|
|
It has been superseded by segment_body.py, which performs full 15-region
|
|
segmentation (including the head as one of those regions).
|
|
|
|
Use segment_body.py for all new work. This file is preserved as documentation.
|
|
|
|
Run via:
|
|
reach blender run \\
|
|
spikes/quaternius-aesthetic/scripts/blender/separate_head.py \\
|
|
-- <input.gltf> <body_output.glb> <head_output.glb>
|
|
"""
|
|
|
|
import bpy
|
|
import bmesh
|
|
import sys
|
|
import os
|
|
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
|
|
if len(argv) < 3:
|
|
print("Usage: -- <input.gltf> <body_output.glb> <head_output.glb>")
|
|
sys.exit(1)
|
|
|
|
INPUT_PATH = argv[0]
|
|
BODY_OUTPUT = argv[1]
|
|
HEAD_OUTPUT = argv[2]
|
|
|
|
HEAD_BONE = "Head"
|
|
|
|
print("=== Separate head from body ===")
|
|
print(f" Input: {INPUT_PATH}")
|
|
print(f" Body: {BODY_OUTPUT}")
|
|
print(f" Head: {HEAD_OUTPUT}")
|
|
|
|
|
|
def clear_scene():
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
for c in list(bpy.data.collections):
|
|
bpy.data.collections.remove(c)
|
|
|
|
|
|
def get_dominant_bone(mesh_obj, vert_index):
|
|
"""Get the name of the bone with highest weight for a vertex."""
|
|
best_weight = 0.0
|
|
best_group = ""
|
|
for g in mesh_obj.data.vertices[vert_index].groups:
|
|
if g.weight > best_weight:
|
|
best_weight = g.weight
|
|
vg = mesh_obj.vertex_groups[g.group]
|
|
best_group = vg.name
|
|
return best_group
|
|
|
|
|
|
def separate_by_bone(mesh_obj, bone_name):
|
|
"""
|
|
Separate vertices weighted to bone_name into a new object.
|
|
Returns (body_obj, head_obj).
|
|
"""
|
|
# Find which vertices belong to the head
|
|
head_verts = set()
|
|
for v in mesh_obj.data.vertices:
|
|
dominant = get_dominant_bone(mesh_obj, v.index)
|
|
if dominant == bone_name:
|
|
head_verts.add(v.index)
|
|
|
|
print(f" Total verts: {len(mesh_obj.data.vertices)}")
|
|
print(f" Head verts: {len(head_verts)}")
|
|
print(f" Body verts: {len(mesh_obj.data.vertices) - len(head_verts)}")
|
|
|
|
if not head_verts:
|
|
print(" WARNING: no vertices found for head bone")
|
|
return mesh_obj, None
|
|
|
|
# Duplicate the mesh for the head version
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
mesh_obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = mesh_obj
|
|
bpy.ops.object.duplicate()
|
|
head_obj = bpy.context.active_object
|
|
head_obj.name = "Head_Mesh"
|
|
|
|
# Edit the BODY copy — remove head vertices
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
mesh_obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = mesh_obj
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
for v in mesh_obj.data.vertices:
|
|
v.select = v.index in head_verts
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.delete(type='VERT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Edit the HEAD copy — remove body vertices
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
head_obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = head_obj
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Head verts in the duplicate have the same indices
|
|
body_verts = set(range(len(head_obj.data.vertices))) - head_verts
|
|
for v in head_obj.data.vertices:
|
|
v.select = v.index in body_verts
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.delete(type='VERT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
print(f" Body mesh after split: {len(mesh_obj.data.vertices)} verts")
|
|
print(f" Head mesh after split: {len(head_obj.data.vertices)} verts")
|
|
|
|
return mesh_obj, head_obj
|
|
|
|
|
|
def export_glb(objects, path):
|
|
"""Export selected objects as GLB."""
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in objects:
|
|
obj.select_set(True)
|
|
obj.hide_set(False)
|
|
bpy.context.view_layer.objects.active = objects[0]
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=path,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_animations=False,
|
|
export_skins=True,
|
|
)
|
|
print(f" Exported: {path} ({os.path.getsize(path) // 1024} KB)")
|
|
|
|
|
|
# --- Main ---
|
|
|
|
clear_scene()
|
|
|
|
print("\nStep 1: Import...")
|
|
bpy.ops.import_scene.gltf(filepath=INPUT_PATH)
|
|
all_objects = list(bpy.data.objects)
|
|
|
|
armature = None
|
|
meshes = []
|
|
other = []
|
|
for obj in all_objects:
|
|
if obj.type == 'ARMATURE':
|
|
armature = obj
|
|
elif obj.type == 'MESH':
|
|
meshes.append(obj)
|
|
else:
|
|
other.append(obj)
|
|
|
|
if not armature:
|
|
print("ERROR: no armature found")
|
|
sys.exit(1)
|
|
|
|
print(f" Armature: {armature.name}")
|
|
for m in meshes:
|
|
print(f" Mesh: {m.name} ({len(m.data.vertices)} verts)")
|
|
|
|
# Find the main body mesh (largest)
|
|
body_mesh = max(meshes, key=lambda m: len(m.data.vertices))
|
|
small_meshes = [m for m in meshes if m != body_mesh] # eyes, eyebrows
|
|
|
|
print(f"\nStep 2: Separate head from {body_mesh.name}...")
|
|
body_only, head_only = separate_by_bone(body_mesh, HEAD_BONE)
|
|
|
|
if head_only is None:
|
|
print("ERROR: head separation failed")
|
|
sys.exit(1)
|
|
|
|
# Step 3: Export body (armature + body mesh + eyes/eyebrows — no head)
|
|
print("\nStep 3: Export body (without head)...")
|
|
export_objects = [armature, body_only] + small_meshes
|
|
# Hide head for body export
|
|
head_only.hide_set(True)
|
|
export_glb(export_objects, BODY_OUTPUT)
|
|
|
|
# Step 4: Export head as standalone mesh (no armature, centered at origin)
|
|
print("\nStep 4: Export head...")
|
|
body_only.hide_set(True)
|
|
for m in small_meshes:
|
|
m.hide_set(True)
|
|
head_only.hide_set(False)
|
|
|
|
# Remove armature modifier and unparent from skeleton
|
|
bpy.context.view_layer.objects.active = head_only
|
|
head_only.select_set(True)
|
|
for mod in list(head_only.modifiers):
|
|
if mod.type == 'ARMATURE':
|
|
bpy.ops.object.modifier_apply(modifier=mod.name)
|
|
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
|
|
|
# Find the Head bone position to use as the new origin
|
|
head_bone = armature.data.bones.get(HEAD_BONE)
|
|
if head_bone:
|
|
head_bone_pos = armature.matrix_world @ head_bone.head_local
|
|
print(f" Head bone position: {head_bone_pos}")
|
|
# Set origin to head bone position, then move mesh to world origin
|
|
bpy.context.scene.cursor.location = head_bone_pos
|
|
bpy.ops.object.origin_set(type='ORIGIN_CURSOR')
|
|
head_only.location = (0, 0, 0)
|
|
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
head_only.select_set(True)
|
|
bpy.context.view_layer.objects.active = head_only
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=HEAD_OUTPUT,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=True,
|
|
export_animations=False,
|
|
export_skins=False,
|
|
)
|
|
print(f" Exported: {HEAD_OUTPUT} ({os.path.getsize(HEAD_OUTPUT) // 1024} KB)")
|
|
|
|
print("\n=== Done ===")
|