Files
settled-reach/tooling/scripts/blender/blender_process_heads.py
T
jpmschweitzerandClaude Opus 5.5 c597ec9131 docs(tooling): T-1253 — sweep the live references to retired tool paths
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>
2026-09-23 20:13:58 +02:00

147 lines
5.5 KiB
Python

"""
blender_process_heads.py
Usage: reach blender run blender_process_heads <source_dir> <output_dir>
Processes the 4 Quaternius OnlyHead .blend files into the head template library.
Source .blends (from <source_dir>):
Regular_Female_OnlyHead.blend -> head_001.glb + head_001_mask.png
Regular_Male_OnlyHead.blend -> head_002.glb + head_002_mask.png
Teen_Female_OnlyHead.blend -> head_003.glb + head_003_mask.png
Teen_Male_OnlyHead.blend -> head_004.glb + head_004_mask.png
Output: <output_dir>/ (typically client/assets/characters/heads/templates/)
Requirements (D-161, architecture doc):
- GLB: BoneAttachment3D-compatible, no animation data, embedded textures
- Scale: 1 unit = 1 meter, Y-up, -Z forward (glTF standard)
- Mask PNG: greyscale, white = tintable skin tone region (entire head surface = skin)
"""
import sys
import os
import bpy
HEAD_MAPPING = [
("Regular_Female_OnlyHead.blend", "head_001"),
("Regular_Male_OnlyHead.blend", "head_002"),
("Teen_Female_OnlyHead.blend", "head_003"),
("Teen_Male_OnlyHead.blend", "head_004"),
]
MASK_RESOLUTION = 1024 # Default; overridden if texture found in .blend
def process_head(blend_path: str, output_dir: str, head_id: str) -> None:
glb_path = os.path.join(output_dir, head_id + ".glb")
mask_path = os.path.join(output_dir, head_id + "_mask.png")
print(f"\n--- Processing: {os.path.basename(blend_path)}")
print(f" GLB -> {glb_path}")
print(f" Mask -> {mask_path}")
# Load the .blend file
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.wm.open_mainfile(filepath=blend_path)
all_objects = list(bpy.context.scene.objects)
print(f" Imported {len(all_objects)} objects:")
for obj in all_objects:
print(f" {obj.name} ({obj.type})")
meshes = [obj for obj in all_objects if obj.type == 'MESH']
if not meshes:
print("ERROR: No mesh found in the .blend file")
sys.exit(1)
# Detect texture resolution for mask sizing.
# NOTE: This picks the first non-HDR texture found in bpy.data.images, which is
# fragile — iteration order is not guaranteed and multiple textures may be present.
# If no texture is found, falls back to MASK_RESOLUTION (512x512 default) so the
# mask is still generated at a sensible size rather than failing.
mask_w = mask_h = MASK_RESOLUTION
for img in bpy.data.images:
if img.size[0] > 0 and img.size[1] > 0 and not img.name.endswith('.hdr'):
print(f" Texture found: {img.name}{img.size[0]}x{img.size[1]}")
mask_w, mask_h = img.size[0], img.size[1]
break
else:
print(f" No texture found — using fallback mask size {mask_w}x{mask_h}")
# Strip all animation data (heads have no runtime animation — BoneAttachment3D moves them)
for obj in all_objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
# Note: WGT-* rig widget meshes are automatically excluded by the GLTF exporter
# (they have no materials/geometry that exports). No deletion needed.
# Select all objects for export
bpy.ops.object.select_all(action='SELECT')
# Export GLB — embedded textures, no animations
print(" Exporting GLB...")
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO', # embed textures (AUTO embeds for GLB)
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
)
print(f" GLB exported ({os.path.getsize(glb_path):,} bytes)")
# Generate mask PNG — solid white: entire head surface is skin-tone tintable
# Format: greyscale (R channel), white = tintable, black = preserve
# Using RGBA internally; Blender PNG export honours this correctly.
mask_img = bpy.data.images.new(
name=head_id + "_mask",
width=mask_w,
height=mask_h,
alpha=False,
float_buffer=False,
)
# Fill with solid white (RGBA 1.0 per channel)
mask_img.pixels = [1.0] * (mask_w * mask_h * 4)
mask_img.colorspace_settings.name = 'Non-Color'
mask_img.file_format = 'PNG'
mask_img.filepath_raw = mask_path
mask_img.save()
print(f" Mask saved ({mask_w}x{mask_h}, solid white = all-skin)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: reach blender run blender_process_heads <source_dir> <output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 2:
print("ERROR: Provide <source_dir> and <output_dir>")
sys.exit(1)
source_dir = args[0]
output_dir = args[1]
os.makedirs(output_dir, exist_ok=True)
for blend_filename, head_id in HEAD_MAPPING:
blend_path = os.path.join(source_dir, blend_filename)
if not os.path.exists(blend_path):
print(f"ERROR: Source file not found: {blend_path}")
sys.exit(1)
process_head(blend_path, output_dir, head_id)
print("\n=== All 4 head templates processed ===")
for _, head_id in HEAD_MAPPING:
glb = os.path.join(output_dir, head_id + ".glb")
mask = os.path.join(output_dir, head_id + "_mask.png")
print(f" {head_id}.glb ({os.path.getsize(glb):,} bytes)")
print(f" {head_id}_mask.png ({os.path.getsize(mask):,} bytes)")