Files
settled-reach/tooling/blender_process_heads.py
T
jpmschweitzerandClaude Fable 5 346d87df7a chore(meta): docs/build sweep + tooling test gate (T-1069, T-1066)
- make test-tooling: planet-gen determinism guard + import_economics
  --dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
  E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
  documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
  (import_economics sole generator since #951/D-223); dead check-protocol
  target deleted; DEVOPS hook/config sections rewritten from the actual
  hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
  refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
  cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
  relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:55 +02:00

147 lines
5.5 KiB
Python

"""
blender_process_heads.py
Usage: tooling/blender --background --python tooling/blender_process_heads.py -- <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: tooling/blender --background --python tooling/blender_process_heads.py -- <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)")