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>
249 lines
9.7 KiB
Python
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)
|