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>
493 lines
18 KiB
Python
493 lines
18 KiB
Python
"""
|
|
blender_segment_body.py
|
|
Usage:
|
|
tooling/blender --background --python tooling/blender_segment_body.py -- \
|
|
<input.gltf> <output_dir> [--scale sx sy sz]
|
|
|
|
Segments a Quaternius FullBody GLTF into 18 production GLBs:
|
|
seg_head, seg_neck, seg_torso, seg_torso_upper,
|
|
seg_arm_upper_l/r, seg_arm_lower_l/r, seg_hand_l/r,
|
|
seg_leg_upper_l/r, seg_leg_lower_l/r, seg_foot_l/r,
|
|
seg_eyes, seg_eyebrows
|
|
|
|
Each segment contains the vertices primarily weighted to its bone group
|
|
plus 1-ring boundary overlap for seam-free deformation.
|
|
|
|
Optional --scale sx sy sz applies a vertex-level scale to the mesh before
|
|
segmentation (for fork body types: thin, heavy, child).
|
|
|
|
Outputs: <output_dir>/seg_{name}.glb (18 files total)
|
|
|
|
Design: D-160 (18 segments per body type), D-164 (Source .blends as starting point)
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import bpy
|
|
|
|
# --- Segment → bone vertex group mappings ---
|
|
# Each segment selects vertices with weight > 0 for ANY listed bone.
|
|
# 1-ring expansion adds boundary overlap for seam-free deformation (D-160).
|
|
|
|
SEGMENT_BONES = {
|
|
"seg_head": ["Head"],
|
|
"seg_neck": ["neck_01"],
|
|
"seg_torso": ["spine_01", "spine_02"],
|
|
"seg_torso_upper": ["spine_03", "clavicle_l", "clavicle_r"],
|
|
"seg_hips": ["pelvis"],
|
|
"seg_arm_upper_l": ["upperarm_l"],
|
|
"seg_arm_upper_r": ["upperarm_r"],
|
|
"seg_arm_lower_l": ["lowerarm_l"],
|
|
"seg_arm_lower_r": ["lowerarm_r"],
|
|
"seg_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",
|
|
],
|
|
"seg_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",
|
|
],
|
|
"seg_leg_upper_l": ["thigh_l"],
|
|
"seg_leg_upper_r": ["thigh_r"],
|
|
"seg_leg_lower_l": ["calf_l"],
|
|
"seg_leg_lower_r": ["calf_r"],
|
|
"seg_foot_l": ["foot_l", "ball_l", "ball_leaf_l"],
|
|
"seg_foot_r": ["foot_r", "ball_r", "ball_leaf_r"],
|
|
}
|
|
|
|
# Segments using a dedicated sub-object (not vertex-group-based segmentation)
|
|
OBJECT_SEGMENTS = {
|
|
"seg_eyes": "Eyes",
|
|
"seg_eyebrows": "Eyebrows",
|
|
}
|
|
|
|
# Ordered list for consistent output
|
|
SEGMENT_ORDER = [
|
|
"seg_head", "seg_neck",
|
|
"seg_torso_upper", "seg_torso", "seg_hips",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
"seg_hand_l", "seg_hand_r",
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
"seg_foot_l", "seg_foot_r",
|
|
"seg_eyes", "seg_eyebrows",
|
|
]
|
|
|
|
WEIGHT_THRESHOLD = 0.01 # Minimum weight to count as "belonging" to a bone
|
|
EXPAND_RINGS = 0 # No overlap — clean segment boundaries for hiding/amputation
|
|
|
|
|
|
def find_objects(scene):
|
|
"""Identify the main body mesh, eyes, eyebrows, and armature."""
|
|
armature = None
|
|
body_mesh = None
|
|
special = {}
|
|
|
|
all_meshes = [o for o in scene.objects if o.type == 'MESH']
|
|
|
|
for obj in scene.objects:
|
|
if obj.type == 'ARMATURE':
|
|
armature = obj
|
|
elif obj.type == 'MESH':
|
|
name_upper = obj.name.upper()
|
|
if 'EYES' in name_upper and 'BROW' not in name_upper:
|
|
special['Eyes'] = obj
|
|
elif 'BROW' in name_upper:
|
|
special['Eyebrows'] = obj
|
|
|
|
# Body mesh = largest mesh not in special set
|
|
special_objs = set(special.values())
|
|
candidates = [o for o in all_meshes if o not in special_objs]
|
|
if candidates:
|
|
body_mesh = max(candidates, key=lambda o: len(o.data.vertices))
|
|
|
|
return body_mesh, special, armature
|
|
|
|
|
|
def apply_scale(mesh_obj, sx, sy, sz):
|
|
"""Scale mesh vertices in-place (mesh-local space)."""
|
|
if sx == 1.0 and sy == 1.0 and sz == 1.0:
|
|
return
|
|
print(f" Applying mesh scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
|
|
for v in mesh_obj.data.vertices:
|
|
v.co.x *= sx
|
|
v.co.y *= sy
|
|
v.co.z *= sz
|
|
mesh_obj.data.update()
|
|
|
|
|
|
def apply_fork_scale(body_mesh, special_meshes, armature, sx, sy, sz):
|
|
"""
|
|
Scale a fork body (thin/heavy/child) so mesh AND armature stay CONSISTENT.
|
|
|
|
This is the load-bearing fix for fork body types (T-1090). The segment GLBs
|
|
are reparented onto a single SHARED skeleton at runtime (character_visual.gd
|
|
loads skeleton/armature.glb and drives all segments by bone name). A segment
|
|
composites coherently ONLY if its mesh matches its own embedded armature's
|
|
rest pose — the shared skeleton then relocates the whole segment as a rigid
|
|
unit (this is why the pristine-rig teen body composites fine despite a very
|
|
different rig; see the T-1090 report).
|
|
|
|
The previous behaviour scaled mesh vertices ALONE, leaving the armature at
|
|
source scale: the head mesh dropped ~0.35 m below the Head bone, limbs flung
|
|
apart on relocation — the "detached head / spider arms" misrender.
|
|
|
|
The scale must be BAKED by Blender via object transform_apply, NOT by poking
|
|
edit-bone head/tail directly: manual head/tail edits do not recompute bone
|
|
roll or honour connected-chain constraints, so long chains (arm→hand→fingers,
|
|
neck→head) accumulate error and still explode. transform_apply rebuilds the
|
|
bone matrices correctly.
|
|
|
|
Method: de-parent meshes (keep transform) so mesh and armature are
|
|
independent objects sharing the world origin, give each the SAME object
|
|
scale, then apply. Identical affine about the same origin → mesh verts and
|
|
bone rest move together; the Armature modifier + vertex groups re-derive a
|
|
consistent bind, which the glTF exporter bakes into the inverse-bind
|
|
matrices.
|
|
"""
|
|
if sx == 1.0 and sy == 1.0 and sz == 1.0:
|
|
return
|
|
print(f" Applying baked fork scale: ({sx:.3f}, {sy:.3f}, {sz:.3f})")
|
|
meshes = [body_mesh] + [m for m in special_meshes if m is not None]
|
|
|
|
if armature.mode != 'OBJECT':
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# De-parent meshes from the armature (keep world transform). The Armature
|
|
# MODIFIER and vertex groups are untouched — only the parenting relationship
|
|
# is cleared, so scaling each object about the origin is not double-applied.
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for m in meshes:
|
|
if m.parent is armature:
|
|
m.select_set(True)
|
|
if bpy.context.selected_objects:
|
|
bpy.context.view_layer.objects.active = meshes[0]
|
|
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
|
|
|
|
# Scale armature + all meshes by the same object scale, then bake.
|
|
objs = [armature] + meshes
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for o in objs:
|
|
o.select_set(True)
|
|
o.scale = (sx, sy, sz)
|
|
bpy.context.view_layer.objects.active = armature
|
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
|
|
|
|
|
def export_glb(objects, output_path):
|
|
"""Select the given objects and export as GLB."""
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for obj in objects:
|
|
obj.select_set(True)
|
|
if objects:
|
|
bpy.context.view_layer.objects.active = objects[0]
|
|
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=output_path,
|
|
use_selection=True,
|
|
export_format='GLB',
|
|
export_animations=False,
|
|
export_yup=True,
|
|
export_image_format='AUTO',
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_skins=True,
|
|
export_materials='EXPORT',
|
|
)
|
|
|
|
|
|
def segment_by_bones(body_mesh, armature, bone_names, output_path):
|
|
"""
|
|
Extract a segment from body_mesh based on bone weights.
|
|
Uses face-based assignment: each face belongs to the segment whose bones
|
|
have the highest total weight across the face's vertices. No vertices are
|
|
deleted — only faces that don't belong to this segment are removed.
|
|
This keeps all boundary vertices intact (shared with neighbors) so there
|
|
are no gaps, no holes, and no need for caps.
|
|
"""
|
|
import bmesh
|
|
|
|
# Duplicate the body mesh
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
body_mesh.select_set(True)
|
|
bpy.context.view_layer.objects.active = body_mesh
|
|
bpy.ops.object.duplicate(linked=False)
|
|
dup = bpy.context.active_object
|
|
|
|
if dup.mode != 'OBJECT':
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
# Get vertex group indices for target bones
|
|
vg_indices = set()
|
|
for name in bone_names:
|
|
vg = dup.vertex_groups.get(name)
|
|
if vg:
|
|
vg_indices.add(vg.index)
|
|
|
|
if not vg_indices:
|
|
print(f" WARNING: No vertex groups found for bones {bone_names}")
|
|
|
|
# Build BMesh
|
|
bm = bmesh.new()
|
|
bm.from_mesh(dup.data)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.faces.ensure_lookup_table()
|
|
|
|
deform_layer = bm.verts.layers.deform.verify()
|
|
|
|
# Each face belongs to exactly ONE segment — the one whose bones have
|
|
# the highest total weight across the face's vertices. This prevents
|
|
# any face from appearing in two segments.
|
|
#
|
|
# We compute per-face the sum of weights for EVERY segment's bone set,
|
|
# then assign the face to the segment with the highest sum. We only
|
|
# keep faces assigned to THIS segment.
|
|
|
|
# Build a map of ALL segments' bone group indices for comparison.
|
|
# Exclude swappable variants (torso_upper) — they are subsets of their
|
|
# parent segment and should not compete in exclusive face assignment.
|
|
# torso_upper gets the same faces as torso, filtered to its bone subset.
|
|
VARIANT_SEGMENTS = set() # no variants — all segments are independent
|
|
|
|
all_segment_vg_indices = {}
|
|
for seg_name_key, seg_bones in SEGMENT_BONES.items():
|
|
if seg_name_key in VARIANT_SEGMENTS:
|
|
continue # skip variants in competition
|
|
seg_vg = set()
|
|
for bname in seg_bones:
|
|
vg = dup.vertex_groups.get(bname)
|
|
if vg:
|
|
seg_vg.add(vg.index)
|
|
all_segment_vg_indices[seg_name_key] = seg_vg
|
|
|
|
# For the current segment, use the key from SEGMENT_BONES that matches our bone_names
|
|
current_seg_key = None
|
|
for seg_name_key, seg_bones in SEGMENT_BONES.items():
|
|
if set(seg_bones) == set(bone_names):
|
|
current_seg_key = seg_name_key
|
|
break
|
|
|
|
if current_seg_key is None:
|
|
# Fallback: match by vg_indices
|
|
for seg_name_key, seg_vg in all_segment_vg_indices.items():
|
|
if seg_vg == vg_indices:
|
|
current_seg_key = seg_name_key
|
|
break
|
|
|
|
is_variant = current_seg_key in VARIANT_SEGMENTS
|
|
|
|
keep_faces = set()
|
|
if is_variant:
|
|
# Variant segments (e.g. torso_upper) are subsets of a parent.
|
|
# Keep only faces where the dominant bone (highest weight vertex)
|
|
# is exclusively in this variant's bone set, not the parent's
|
|
# extra bones. For torso_upper (spine_02, spine_03): keep faces
|
|
# where spine_02/spine_03 outweigh spine_01.
|
|
for face in bm.faces:
|
|
variant_w = 0.0
|
|
total_w = 0.0
|
|
for v in face.verts:
|
|
weights = v[deform_layer]
|
|
for idx, w in weights.items():
|
|
total_w += w
|
|
if idx in vg_indices:
|
|
variant_w += w
|
|
# Face belongs to variant if variant bones are dominant
|
|
if total_w > 0 and variant_w / total_w > 0.5:
|
|
keep_faces.add(face)
|
|
else:
|
|
# Primary segments: exclusive assignment via competition
|
|
for face in bm.faces:
|
|
best_seg = None
|
|
best_weight = -1.0
|
|
for seg_name_key, seg_vg in all_segment_vg_indices.items():
|
|
total_w = 0.0
|
|
for v in face.verts:
|
|
weights = v[deform_layer]
|
|
for idx in seg_vg:
|
|
if idx in weights:
|
|
total_w += weights[idx]
|
|
if total_w > best_weight:
|
|
best_weight = total_w
|
|
best_seg = seg_name_key
|
|
if best_seg == current_seg_key:
|
|
keep_faces.add(face)
|
|
|
|
print(f" Faces to keep: {len(keep_faces)} / {len(bm.faces)}")
|
|
|
|
# Delete faces NOT in keep_faces
|
|
faces_to_delete = [f for f in bm.faces if f not in keep_faces]
|
|
bmesh.ops.delete(bm, geom=faces_to_delete, context='FACES')
|
|
|
|
# Clean up: remove vertices that have no faces left
|
|
bm.verts.ensure_lookup_table()
|
|
orphan_verts = [v for v in bm.verts if not v.link_faces]
|
|
if orphan_verts:
|
|
bmesh.ops.delete(bm, geom=orphan_verts, context='VERTS')
|
|
|
|
# Write back
|
|
bm.to_mesh(dup.data)
|
|
bm.free()
|
|
dup.data.update()
|
|
|
|
remaining = len(dup.data.vertices)
|
|
print(f" Segment vertices after trim: {remaining}")
|
|
|
|
# Export segment + armature
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
dup.select_set(True)
|
|
if armature:
|
|
armature.select_set(True)
|
|
bpy.context.view_layer.objects.active = dup
|
|
|
|
export_glb([dup] + ([armature] if armature else []), output_path)
|
|
|
|
size = os.path.getsize(output_path)
|
|
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
|
|
|
|
# Clean up duplicate
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
dup.select_set(True)
|
|
bpy.ops.object.delete()
|
|
|
|
|
|
def segment_special_object(special_obj, armature, output_path):
|
|
"""Export a special sub-object (Eyes / Eyebrows) as its own GLB segment."""
|
|
export_objs = [special_obj]
|
|
if armature:
|
|
export_objs.append(armature)
|
|
export_glb(export_objs, output_path)
|
|
size = os.path.getsize(output_path)
|
|
print(f" Exported: {os.path.basename(output_path)} ({size:,} bytes)")
|
|
|
|
|
|
def segment_body(gltf_path, output_dir, scale=(1.0, 1.0, 1.0)):
|
|
"""Main entry: load GLTF, apply optional scale, produce all 18 segment GLBs."""
|
|
print(f"\n Loading: {os.path.basename(gltf_path)}")
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.gltf(filepath=gltf_path)
|
|
|
|
body_mesh, special, armature = find_objects(bpy.context.scene)
|
|
|
|
if body_mesh is None:
|
|
print("ERROR: Could not find main body mesh")
|
|
sys.exit(1)
|
|
|
|
print(f" Body mesh: {body_mesh.name!r} ({len(body_mesh.data.vertices)} verts)")
|
|
print(f" Special: {list(special.keys())}")
|
|
print(f" Armature: {armature.name if armature else 'NONE'}")
|
|
|
|
# Remove utility objects (Icospheres, rig widgets, empties) that are not
|
|
# the body mesh, eyes, eyebrows, or armature. These can be children of the
|
|
# armature and would appear in all exported GLBs otherwise.
|
|
# Using bpy.data.objects.remove() (Python API) instead of ops — operators
|
|
# have context issues in headless Blender.
|
|
keepers = set(filter(None, [body_mesh, armature] + list(special.values())))
|
|
utility_objs = [
|
|
obj for obj in list(bpy.context.scene.objects)
|
|
if obj not in keepers
|
|
]
|
|
removed = 0
|
|
for obj in utility_objs:
|
|
# Clear parent relationship BEFORE removal so the armature stops
|
|
# treating it as a hierarchy child during GLTF export
|
|
if obj.parent is not None:
|
|
obj.parent = None
|
|
mesh_data = obj.data if obj.type == 'MESH' else None
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
if mesh_data and mesh_data.users == 0:
|
|
bpy.data.meshes.remove(mesh_data)
|
|
removed += 1
|
|
if removed:
|
|
remaining_names = [o.name for o in bpy.context.scene.objects]
|
|
print(f" Removed {removed} utility objects. Scene now: {remaining_names}")
|
|
|
|
# Apply optional scale transform (for fork body types). Mesh AND armature
|
|
# are scaled together and baked by Blender so the segment stays internally
|
|
# consistent when reparented onto the shared runtime skeleton (T-1090 fix —
|
|
# see apply_fork_scale).
|
|
sx, sy, sz = scale
|
|
if scale != (1.0, 1.0, 1.0):
|
|
apply_fork_scale(body_mesh, list(special.values()), armature, sx, sy, sz)
|
|
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
# Strip animation data
|
|
for obj in bpy.context.scene.objects:
|
|
if obj.animation_data:
|
|
obj.animation_data_clear()
|
|
for action in list(bpy.data.actions):
|
|
bpy.data.actions.remove(action)
|
|
|
|
exported = []
|
|
skipped = []
|
|
|
|
for seg_name in SEGMENT_ORDER:
|
|
output_path = os.path.join(output_dir, seg_name + ".glb")
|
|
print(f"\n [{seg_name}]")
|
|
|
|
if seg_name in OBJECT_SEGMENTS:
|
|
obj_key = OBJECT_SEGMENTS[seg_name]
|
|
special_obj = special.get(obj_key)
|
|
if special_obj is None:
|
|
print(f" SKIP: no {obj_key!r} object found in scene")
|
|
skipped.append(seg_name)
|
|
continue
|
|
segment_special_object(special_obj, armature, output_path)
|
|
exported.append(seg_name)
|
|
|
|
elif seg_name in SEGMENT_BONES:
|
|
bone_names = SEGMENT_BONES[seg_name]
|
|
segment_by_bones(body_mesh, armature, bone_names, output_path)
|
|
exported.append(seg_name)
|
|
|
|
else:
|
|
print(f" SKIP: unknown segment {seg_name!r}")
|
|
skipped.append(seg_name)
|
|
|
|
return exported, skipped
|
|
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv
|
|
if "--" not in argv:
|
|
print("Usage: tooling/blender --background --python tooling/blender_segment_body.py -- <input.gltf> <output_dir> [--scale sx sy sz]")
|
|
sys.exit(1)
|
|
|
|
args = argv[argv.index("--") + 1:]
|
|
if len(args) < 2:
|
|
print("ERROR: Provide <input.gltf> and <output_dir>")
|
|
sys.exit(1)
|
|
|
|
gltf_path = args[0]
|
|
output_dir = args[1]
|
|
|
|
# Optional scale argument
|
|
scale = (1.0, 1.0, 1.0)
|
|
if "--scale" in args:
|
|
idx = args.index("--scale")
|
|
try:
|
|
scale = (float(args[idx + 1]), float(args[idx + 2]), float(args[idx + 3]))
|
|
except (IndexError, ValueError):
|
|
print("ERROR: --scale requires three floats: sx sy sz")
|
|
sys.exit(1)
|
|
|
|
exported, skipped = segment_body(gltf_path, output_dir, scale)
|
|
|
|
print(f"\n=== Segmentation complete: {len(exported)} segments, {len(skipped)} skipped ===")
|
|
for seg in exported:
|
|
path = os.path.join(output_dir, seg + ".glb")
|
|
print(f" {seg}.glb ({os.path.getsize(path):,} bytes)")
|
|
if skipped:
|
|
print(f" Skipped: {skipped}")
|