- 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>
147 lines
5.5 KiB
Python
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)")
|