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>
309 lines
9.9 KiB
Python
309 lines
9.9 KiB
Python
"""
|
|
VALIDATED: Segment a Quaternius body mesh into bone-group regions.
|
|
|
|
Each segment becomes a separate mesh, skinned to the same skeleton,
|
|
exportable independently. Segments map to clothing coverage zones
|
|
and can be selectively hidden at runtime.
|
|
|
|
Segments:
|
|
head — Head bone
|
|
neck — neck_01
|
|
torso — spine_01, spine_02, spine_03, pelvis
|
|
arm_upper_l — clavicle_l, upperarm_l
|
|
arm_upper_r — clavicle_r, upperarm_r
|
|
arm_lower_l — lowerarm_l
|
|
arm_lower_r — lowerarm_r
|
|
hand_l — hand_l, all finger bones _l
|
|
hand_r — hand_r, all finger bones _r
|
|
leg_upper_l — thigh_l
|
|
leg_upper_r — thigh_r
|
|
leg_lower_l — calf_l
|
|
leg_lower_r — calf_r
|
|
foot_l — foot_l, ball_l, ball_leaf_l
|
|
foot_r — foot_r, ball_r, ball_leaf_r
|
|
|
|
Run via:
|
|
reach blender run \
|
|
spikes/quaternius-aesthetic/scripts/blender/segment_body.py \
|
|
-- <input.gltf> <output_dir/>
|
|
|
|
Output:
|
|
<output_dir>/seg_head.glb
|
|
<output_dir>/seg_neck.glb
|
|
<output_dir>/seg_torso.glb
|
|
... etc
|
|
<output_dir>/armature.glb (armature only, no body mesh)
|
|
"""
|
|
|
|
import bpy
|
|
import sys
|
|
import os
|
|
|
|
argv = sys.argv
|
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
|
|
|
if len(argv) < 2:
|
|
print("Usage: -- <input.gltf> <output_dir/>")
|
|
sys.exit(1)
|
|
|
|
INPUT_PATH = argv[0]
|
|
OUTPUT_DIR = argv[1]
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
|
|
# Bone-to-segment mapping. Each segment is a list of bone names.
|
|
# A vertex belongs to the segment whose bones have the highest combined weight.
|
|
SEGMENTS = {
|
|
"head": ["Head"],
|
|
"neck": ["neck_01"],
|
|
"torso": ["spine_01", "spine_02", "spine_03", "pelvis", "root"],
|
|
"arm_upper_l": ["clavicle_l", "upperarm_l"],
|
|
"arm_upper_r": ["clavicle_r", "upperarm_r"],
|
|
"arm_lower_l": ["lowerarm_l"],
|
|
"arm_lower_r": ["lowerarm_r"],
|
|
"hand_l": ["hand_l", "index_01_l", "index_02_l", "index_03_l", "index_04_leaf_l",
|
|
"middle_01_l", "middle_02_l", "middle_03_l", "middle_04_leaf_l",
|
|
"pinky_01_l", "pinky_02_l", "pinky_03_l", "pinky_04_leaf_l",
|
|
"ring_01_l", "ring_02_l", "ring_03_l", "ring_04_leaf_l",
|
|
"thumb_01_l", "thumb_02_l", "thumb_03_l", "thumb_04_leaf_l"],
|
|
"hand_r": ["hand_r", "index_01_r", "index_02_r", "index_03_r", "index_04_leaf_r",
|
|
"middle_01_r", "middle_02_r", "middle_03_r", "middle_04_leaf_r",
|
|
"pinky_01_r", "pinky_02_r", "pinky_03_r", "pinky_04_leaf_r",
|
|
"ring_01_r", "ring_02_r", "ring_03_r", "ring_04_leaf_r",
|
|
"thumb_01_r", "thumb_02_r", "thumb_03_r", "thumb_04_leaf_r"],
|
|
"leg_upper_l": ["thigh_l"],
|
|
"leg_upper_r": ["thigh_r"],
|
|
"leg_lower_l": ["calf_l"],
|
|
"leg_lower_r": ["calf_r"],
|
|
"foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
|
|
"foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
|
|
}
|
|
|
|
print("=== Segment body mesh ===")
|
|
print(f" Input: {INPUT_PATH}")
|
|
print(f" Output: {OUTPUT_DIR}")
|
|
print(f" Segments: {len(SEGMENTS)}")
|
|
|
|
|
|
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_vertex_segment(mesh_obj, vert_index, vg_name_to_segment):
|
|
"""Determine which segment a vertex belongs to based on highest combined bone weight."""
|
|
segment_weights = {}
|
|
for g in mesh_obj.data.vertices[vert_index].groups:
|
|
vg = mesh_obj.vertex_groups[g.group]
|
|
seg = vg_name_to_segment.get(vg.name)
|
|
if seg:
|
|
segment_weights[seg] = segment_weights.get(seg, 0.0) + g.weight
|
|
|
|
if not segment_weights:
|
|
return "torso" # fallback for unweighted verts
|
|
return max(segment_weights, key=segment_weights.get)
|
|
|
|
|
|
def get_neighbor_verts(mesh_obj, vert_indices):
|
|
"""Find all vertices connected to the given set by edges (1-ring border)."""
|
|
idx_set = set(vert_indices)
|
|
neighbors = set()
|
|
for edge in mesh_obj.data.edges:
|
|
v0, v1 = edge.vertices[0], edge.vertices[1]
|
|
if v0 in idx_set and v1 not in idx_set:
|
|
neighbors.add(v1)
|
|
elif v1 in idx_set and v0 not in idx_set:
|
|
neighbors.add(v0)
|
|
return neighbors
|
|
|
|
|
|
def extract_segment(mesh_obj, vert_indices, segment_name, armature):
|
|
"""
|
|
Create a new mesh from a subset of vertices plus a 1-ring border overlap.
|
|
The overlap ensures adjacent segments share boundary geometry, eliminating
|
|
visible seams at segment boundaries.
|
|
"""
|
|
if not vert_indices:
|
|
return None
|
|
|
|
# Add 1-ring neighbor vertices as overlap border
|
|
border = get_neighbor_verts(mesh_obj, vert_indices)
|
|
keep_set = set(vert_indices) | border
|
|
|
|
# Duplicate the full mesh
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
mesh_obj.select_set(True)
|
|
mesh_obj.hide_set(False)
|
|
bpy.context.view_layer.objects.active = mesh_obj
|
|
bpy.ops.object.duplicate()
|
|
seg_obj = bpy.context.active_object
|
|
seg_obj.name = "seg_%s" % segment_name
|
|
|
|
# Select only the vertices NOT in this segment (+ border) and delete them
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='DESELECT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
for v in seg_obj.data.vertices:
|
|
v.select = v.index not in keep_set
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.delete(type='VERT')
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
remaining = len(seg_obj.data.vertices)
|
|
if remaining == 0:
|
|
bpy.data.objects.remove(seg_obj)
|
|
return None
|
|
|
|
return seg_obj
|
|
|
|
|
|
# --- Main ---
|
|
|
|
clear_scene()
|
|
|
|
print("\nStep 1: Import...")
|
|
bpy.ops.import_scene.gltf(filepath=INPUT_PATH)
|
|
all_objects = list(bpy.data.objects)
|
|
|
|
armature = None
|
|
meshes = []
|
|
for obj in all_objects:
|
|
if obj.type == 'ARMATURE':
|
|
armature = obj
|
|
elif obj.type == 'MESH':
|
|
meshes.append(obj)
|
|
|
|
if not armature:
|
|
print("ERROR: no armature found")
|
|
sys.exit(1)
|
|
|
|
body_mesh = max(meshes, key=lambda m: len(m.data.vertices))
|
|
small_meshes = [m for m in meshes if m != body_mesh]
|
|
|
|
print(f" Armature: {armature.name} ({len(armature.data.bones)} bones)")
|
|
print(f" Body mesh: {body_mesh.name} ({len(body_mesh.data.vertices)} verts)")
|
|
for m in small_meshes:
|
|
print(f" Extra mesh: {m.name} ({len(m.data.vertices)} verts)")
|
|
|
|
# Build reverse map: bone name -> segment name
|
|
vg_name_to_segment = {}
|
|
for seg_name, bones in SEGMENTS.items():
|
|
for bone in bones:
|
|
vg_name_to_segment[bone] = seg_name
|
|
|
|
# Classify every vertex
|
|
print("\nStep 2: Classifying vertices...")
|
|
vert_segments = {} # segment_name -> [vert_indices]
|
|
for v in body_mesh.data.vertices:
|
|
seg = get_vertex_segment(body_mesh, v.index, vg_name_to_segment)
|
|
if seg not in vert_segments:
|
|
vert_segments[seg] = []
|
|
vert_segments[seg].append(v.index)
|
|
|
|
for seg_name in sorted(vert_segments.keys()):
|
|
print(f" {seg_name}: {len(vert_segments[seg_name])} verts")
|
|
|
|
# Extract each segment
|
|
print("\nStep 3: Extracting segments...")
|
|
segment_objects = {}
|
|
|
|
for seg_name in SEGMENTS:
|
|
indices = vert_segments.get(seg_name, [])
|
|
if not indices:
|
|
print(f" {seg_name}: SKIP (no vertices)")
|
|
continue
|
|
|
|
seg_obj = extract_segment(body_mesh, indices, seg_name, armature)
|
|
if seg_obj:
|
|
segment_objects[seg_name] = seg_obj
|
|
print(f" {seg_name}: {len(seg_obj.data.vertices)} verts")
|
|
else:
|
|
print(f" {seg_name}: FAILED")
|
|
|
|
# Hide the original body mesh
|
|
body_mesh.hide_set(True)
|
|
|
|
# Export each segment with the armature
|
|
print("\nStep 4: Exporting segments...")
|
|
for seg_name, seg_obj in segment_objects.items():
|
|
# Hide all segments except this one
|
|
for other_name, other_obj in segment_objects.items():
|
|
other_obj.hide_set(other_name != seg_name)
|
|
for m in small_meshes:
|
|
m.hide_set(True)
|
|
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
armature.select_set(True)
|
|
seg_obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = armature
|
|
|
|
output_path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % seg_name)
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=output_path,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_animations=False,
|
|
export_skins=True,
|
|
)
|
|
size_kb = os.path.getsize(output_path) // 1024
|
|
print(f" seg_{seg_name}.glb ({size_kb} KB)")
|
|
|
|
# Export eyes/eyebrows as separate segments too
|
|
print("\nStep 5: Exporting extra meshes...")
|
|
for m in small_meshes:
|
|
for other_obj in segment_objects.values():
|
|
other_obj.hide_set(True)
|
|
for other_m in small_meshes:
|
|
other_m.hide_set(other_m != m)
|
|
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
armature.select_set(True)
|
|
m.select_set(True)
|
|
m.hide_set(False)
|
|
bpy.context.view_layer.objects.active = armature
|
|
|
|
clean_name = m.name.lower().replace(" ", "_")
|
|
output_path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % clean_name)
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=output_path,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_animations=False,
|
|
export_skins=True,
|
|
)
|
|
size_kb = os.path.getsize(output_path) // 1024
|
|
print(f" seg_{clean_name}.glb ({size_kb} KB)")
|
|
|
|
# Export the armature alone (for animation loading)
|
|
print("\nStep 6: Exporting armature...")
|
|
for obj in segment_objects.values():
|
|
obj.hide_set(True)
|
|
for m in small_meshes:
|
|
m.hide_set(True)
|
|
body_mesh.hide_set(True)
|
|
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
armature.select_set(True)
|
|
bpy.context.view_layer.objects.active = armature
|
|
armature_path = os.path.join(OUTPUT_DIR, "armature.glb")
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=armature_path,
|
|
export_format='GLB',
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_animations=False,
|
|
export_skins=True,
|
|
)
|
|
print(f" armature.glb ({os.path.getsize(armature_path) // 1024} KB)")
|
|
|
|
print("\n=== Done ===")
|
|
for seg_name in SEGMENTS:
|
|
path = os.path.join(OUTPUT_DIR, "seg_%s.glb" % seg_name)
|
|
status = "[OK]" if os.path.exists(path) else "[MISSING]"
|
|
print(f" {status} seg_{seg_name}.glb")
|