Files
settled-reach/tooling/scripts/blender/blender_process_hair.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

249 lines
9.7 KiB
Python

"""
blender_process_hair.py
Usage: reach blender run blender_process_hair <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, solidify: bool = False) -> 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)
# Solidify hair meshes — gives flat hair cards 3D thickness so they don't
# clip through the scalp. Makes hair look like a hair-shaped shell.
# Only applied to scalp hair, not eyebrows or facial hair.
HAIR_THICKNESS = 0.020 # 20mm outward thickness
if solidify:
for m in meshes:
# Skip icospheres and non-hair utility objects
if m.name.startswith("Icosphere") or len(m.data.vertices) < 50:
continue
bpy.context.view_layer.objects.active = m
m.select_set(True)
# Recalculate normals to ensure consistent outward direction
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode='OBJECT')
# Apply solidify
sol_mod = m.modifiers.new(name="Solidify", type='SOLIDIFY')
sol_mod.thickness = HAIR_THICKNESS
sol_mod.offset = 1.0 # grow outward only
sol_mod.use_rim = True # fill edges for closed shell
bpy.ops.object.modifier_apply(modifier=sol_mod.name)
m.select_set(False)
print(f" Solidified: {m.name} ({HAIR_THICKNESS*1000:.0f}mm outward)")
# Keep armature parenting and skin data intact — the Godot compositor
# will load the skinned mesh and add it to the shared skeleton at runtime.
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_skins=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 = 64, height: int = 64) -> None:
"""Generate a solid-white mask PNG (entire mesh = tintable region).
Solid white = fully tintable. Small size is fine — the shader just samples
white everywhere. The mask format supports greyscale bands for multi-region
recoloring when needed in future.
"""
import struct
import zlib
# Build a minimal white PNG manually — no Blender image API issues
def make_png(w, h):
def chunk(ctype, data):
c = ctype + data
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
header = b'\x89PNG\r\n\x1a\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 0, 0, 0, 0)) # 8-bit greyscale
raw = b''
for _ in range(h):
raw += b'\x00' + b'\xff' * w # filter byte + white pixels
idat = chunk(b'IDAT', zlib.compress(raw))
iend = chunk(b'IEND', b'')
return header + ihdr + idat + iend
with open(mask_path, 'wb') as f:
f.write(make_png(width, height))
print(f" Mask saved: {os.path.basename(mask_path)} ({width}x{height} white)")
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: reach blender run blender_process_hair <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")
# Only solidify scalp hair — not facial hair or eyebrows
convert_gltf_to_glb(gltf_path, glb_path, solidify=(tag == "hair"))
# 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("\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)