Files
settled-reach/tooling/blender_process_hair.py
T
jpmschweitzerandClaude Opus 4.6 a60493c2f7 chore(assets): add Blender headless pipeline scripts for character assets
Adds 8 scripts to tooling/ for the Sprint 28 character asset pipeline:

- blender_segment_body.py: segments a FullBody GLTF into 18 body-part
  GLBs using BMesh vertex-weight selection + 1-ring boundary expansion
- blender_process_bodies.py: batch driver for all 11 body types (6 direct
  + 5 auto-scaled forks: thin, heavy, child)
- blender_process_heads.py: processes 4 OnlyHead .blend files into GLBs
  + 1024x1024 solid-white mask PNGs (D-159, D-160)
- blender_process_hair.py: converts 20 GLTF hairstyle/eyebrow exports
  to accessory GLBs + mask PNGs, creates bald.glb placeholder
- blender_inspect_body.py: diagnostic — lists mesh objects + vertex counts
- blender_inspect_vgroups.py: diagnostic — lists vertex groups on skinned mesh
- glb_strip_utility_nodes.py: post-process GLBs to strip utility nodes
  (Icosphere, WGT-*, DEF-*, ORG-* meshes) from the GLTF scene graph
- check_icosphere.py: diagnostic — reads GLB JSON chunk to verify node list

All Blender scripts require flatpak Blender via tooling/blender wrapper.
Scripts must live in project directories (flatpak sandbox blocks /tmp/).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 15:07:55 +01:00

208 lines
7.6 KiB
Python

"""
blender_process_hair.py
Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>
Converts Quaternius Source tier hairstyle GLTF files (rigged to Head bone) into
production GLBs with mask PNG sidecars.
Source base dir: .../Hairstyles/Rigged to Head Bone/glTF (Godot -Unreal)/
Output:
hair/ {key}.glb + {key}_mask.png (hair styles)
facial_hair/ {key}.glb + {key}_mask.png (beard, moustache, mutton_chops)
eyebrows/ {key}.glb (no mask — used as-is, tinted by shader)
Naming conventions from architecture doc (D-164):
Quaternius name -> file key
Hair_Bob -> bob
Hair_Buns -> buns
Hair_BuzzedFemale -> buzzed_female
Hair_Long -> long
Hair_LongDreads -> long_dreads
Hair_Ponytail_2 -> ponytail_f
Hair_Balding -> balding
Hair_Buzzed -> buzzed
Hair_Dreads -> dreads
Hair_Mohawk -> mohawk
Hair_Ponytail -> ponytail
Hair_SimpleParted -> simple_parted
Hair_SlickBack -> slick_back
Hair_Beard -> beard [facial_hair/]
Hair_Moustache -> moustache [facial_hair/]
Hair_MuttonChops -> mutton_chops [facial_hair/]
Eyebrows_Female -> female [eyebrows/]
Eyebrows_Regular -> regular [eyebrows/]
Eyebrows_Teen -> teen [eyebrows/]
Eyebrows_Thick -> thick [eyebrows/]
Also produces bald.glb (minimal empty mesh placeholder for 'no hair' slot).
"""
import sys
import os
import bpy
# (source_gltf_relative_to_base, output_key, output_dir_tag)
# dir_tag: "hair", "facial_hair", "eyebrows"
HAIR_MANIFEST = [
# --- Female hairstyles ---
("Female/Hair_Bob.gltf", "bob", "hair"),
("Female/Hair_Buns.gltf", "buns", "hair"),
("Female/Hair_BuzzedFemale.gltf", "buzzed_female", "hair"),
("Female/Hair_Long.gltf", "long", "hair"),
("Female/Hair_LongDreads.gltf", "long_dreads", "hair"),
("Female/Hair_Ponytail_2.gltf", "ponytail_f", "hair"),
# --- Male hairstyles ---
("Male/Hair_Balding.gltf", "balding", "hair"),
("Male/Hair_Buzzed.gltf", "buzzed", "hair"),
("Male/Hair_Dreads.gltf", "dreads", "hair"),
("Male/Hair_Mohawk.gltf", "mohawk", "hair"),
("Male/Hair_Ponytail.gltf", "ponytail", "hair"),
("Male/Hair_SimpleParted.gltf", "simple_parted", "hair"),
("Male/Hair_SlickBack.gltf", "slick_back", "hair"),
# --- Facial hair ---
("Male/Hair_Beard.gltf", "beard", "facial_hair"),
("Male/Hair_Moustache.gltf", "moustache", "facial_hair"),
("Male/Hair_MuttonChops.gltf", "mutton_chops", "facial_hair"),
# --- Eyebrows ---
("Female/Eyebrows_Female.gltf", "female", "eyebrows"),
("Female/Eyebrows_Teen.gltf", "teen", "eyebrows"),
("Male/Eyebrows_Regular.gltf", "regular", "eyebrows"),
("Male/Eyebrows_Thick.gltf", "thick", "eyebrows"),
]
def convert_gltf_to_glb(gltf_path: str, glb_path: str) -> None:
"""Import a GLTF file and re-export as GLB with embedded textures."""
bpy.ops.wm.read_factory_settings(use_empty=True)
print(f" Loading: {os.path.basename(gltf_path)}")
bpy.ops.import_scene.gltf(filepath=gltf_path)
objects = list(bpy.context.scene.objects)
meshes = [o for o in objects if o.type == 'MESH']
arms = [o for o in objects if o.type == 'ARMATURE']
print(f" Objects: {len(meshes)} meshes, {len(arms)} armatures")
# Strip animation data (hair has no runtime animation)
for obj in objects:
if obj.animation_data:
obj.animation_data_clear()
for action in list(bpy.data.actions):
bpy.data.actions.remove(action)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_texcoords=True,
export_normals=True,
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" GLB exported: {os.path.basename(glb_path)} ({size:,} bytes)")
def make_mask_png(mask_path: str, width: int = 1024, height: int = 1024) -> None:
"""Generate a solid-white greyscale mask PNG (entire mesh = tintable region)."""
mask_img = bpy.data.images.new(
name="hair_mask",
width=width,
height=height,
alpha=False,
float_buffer=False,
)
mask_img.pixels = [1.0] * (width * height * 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: {os.path.basename(mask_path)}")
def make_bald_placeholder(glb_path: str) -> None:
"""Create a minimal empty GLB for the 'no hair' slot."""
bpy.ops.wm.read_factory_settings(use_empty=True)
# Create a single-vertex mesh with no faces (zero-poly placeholder)
mesh_data = bpy.data.meshes.new("bald")
mesh_data.from_pydata([(0.0, 0.0, 0.0)], [], [])
mesh_data.update()
obj = bpy.data.objects.new("bald", mesh_data)
bpy.context.collection.objects.link(obj)
bpy.ops.object.select_all(action='SELECT')
bpy.ops.export_scene.gltf(
filepath=glb_path,
use_selection=True,
export_format='GLB',
export_animations=False,
export_yup=True,
export_image_format='AUTO',
export_materials='EXPORT',
)
size = os.path.getsize(glb_path)
print(f" bald.glb placeholder ({size:,} bytes)")
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python tooling/blender_process_hair.py -- <source_base_dir> <hair_out_dir> <facial_hair_out_dir> <eyebrows_out_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 4:
print("ERROR: Provide source_base_dir, hair_out_dir, facial_hair_out_dir, eyebrows_out_dir")
sys.exit(1)
source_base = args[0]
hair_out = args[1]
fhair_out = args[2]
eyebrows_out = args[3]
out_dirs = {"hair": hair_out, "facial_hair": fhair_out, "eyebrows": eyebrows_out}
for d in out_dirs.values():
os.makedirs(d, exist_ok=True)
results = []
errors = []
for (gltf_rel, key, tag) in HAIR_MANIFEST:
gltf_path = os.path.join(source_base, gltf_rel)
out_dir = out_dirs[tag]
if not os.path.exists(gltf_path):
errors.append(f" MISSING: {gltf_path}")
continue
print(f"\n[{tag}] {key}")
glb_path = os.path.join(out_dir, key + ".glb")
convert_gltf_to_glb(gltf_path, glb_path)
# Eyebrows: no mask (tinted directly by shader, no recolor mask needed)
if tag != "eyebrows":
mask_path = os.path.join(out_dir, key + "_mask.png")
make_mask_png(mask_path)
results.append((tag, key, os.path.getsize(glb_path)))
# bald.glb placeholder
print(f"\n[hair] bald (placeholder)")
bald_path = os.path.join(hair_out, "bald.glb")
make_bald_placeholder(bald_path)
results.append(("hair", "bald", os.path.getsize(bald_path)))
print("\n=== Hairstyle import complete ===")
for tag, key, size in results:
print(f" [{tag}] {key}.glb ({size:,} bytes)")
if errors:
print("\nMISSING FILES:")
for e in errors:
print(e)
sys.exit(1)