A script scanned every tracked doc, rule, skill, agent, hook and source file for tooling/ paths that no longer exist, skipping historical records (sprints, discussions, workshops, governance, generated wiki pages). It found 62. The ones that tell a reader what to RUN now name the reach verb: - The atlas skill still sent agents to tooling/atlas, atlas-verify, atlas-update-field and atlas-commit-and-sync — about forty lines, all retired in T-1285. They now name the `reach atlas` verbs, and the skill records that commit-and-sync STAGES by default (--commit to commit) and takes --corridor as an option. - The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si and clerk briefings sent those agents to the retired tooling/db/decision and sqlite-query CLIs and to decisions/*.md paths that moved to governance/ in the pql migration. They now name pql. - The ticket-cli rule documented `pql decisions read`, which does not exist; `show` already includes the body. - The culture authoring guide and the RON sources name `reach validate ron`, with the same arguments as before. - The 41 Blender payloads' usage lines ran the retired tooling/blender wrapper, and the docstrings still cited pre-carve-out paths. They now read `reach blender run <payload>`. - Doc comments in server/, client/, wiki TOMLs and the domain modules. What is left is deliberate: "Formerly …" provenance, dated plans and findings docs, the retired-pipeline doc, and a build-artefact path. project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched files are in the canvas-version registry (trait_catalog_reader.rs, since T-1289, canvas_sources.py itself, and two client files). The gate is path-based and has no override. The previous push was rejected on exactly this. Three of the edits are stamped ledger sources, so systems.db is regenerated and the stamp is fresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
493 lines
18 KiB
Python
493 lines
18 KiB
Python
"""
|
|
blender_segment_body.py
|
|
Usage:
|
|
reach blender run blender_segment_body \
|
|
<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: reach blender run blender_segment_body <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}")
|