Files
settled-reach/spikes/quaternius-aesthetic/scripts/blender/segment_body.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
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>
2026-09-02 20:55:52 +02:00

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