- 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>
293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""
|
|
blender_create_clothing_refs.py
|
|
|
|
Usage:
|
|
tooling/blender --background --python tooling/blender_create_clothing_refs.py -- \\
|
|
<bodies_dir> <clothing_output_dir>
|
|
|
|
Creates v0.2 placeholder reference clothing meshes for all 5 initial clothing items.
|
|
Meshes are authored on average_m by importing the relevant body segments, joining
|
|
them, and offsetting vertices outward along normals to simulate clothing thickness.
|
|
|
|
This produces placeholder-quality geometry only — not final art. The geometry reads
|
|
correctly at gameplay zoom and is sufficient to validate the Surface Deform pipeline
|
|
and compositor integration.
|
|
|
|
Arguments:
|
|
bodies_dir Directory with one subdir per body type (e.g. client/assets/characters/bodies/)
|
|
clothing_output_dir Root directory for clothing output (e.g. client/assets/characters/clothing/)
|
|
|
|
Output per item in <clothing_output_dir>/<item_id>/:
|
|
reference.glb -- placeholder clothing mesh on average_m geometry
|
|
|
|
Items produced:
|
|
coveralls_basic -- full-body work suit
|
|
jacket_utility -- upper body outerwear
|
|
pants_cargo -- lower body
|
|
shirt_henley -- upper body inner
|
|
boots_work -- foot slot
|
|
|
|
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import bpy
|
|
import bmesh
|
|
|
|
REFERENCE_BODY = "average_m"
|
|
|
|
# Each item defines:
|
|
# segments -- body segment GLBs (from average_m) to import and join
|
|
# thickness -- outward vertex offset in metres (clothing thickness simulation)
|
|
CLOTHING_ITEMS = {
|
|
"coveralls_basic": {
|
|
"description": "Full-body work suit",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
"seg_hand_l", "seg_hand_r",
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
"seg_foot_l", "seg_foot_r",
|
|
],
|
|
"thickness": 0.006,
|
|
},
|
|
"jacket_utility": {
|
|
"description": "Upper body outerwear",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
],
|
|
"thickness": 0.008, # slightly thicker for outerwear
|
|
},
|
|
"pants_cargo": {
|
|
"description": "Lower body cargo trousers",
|
|
"segments": [
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
],
|
|
"thickness": 0.006,
|
|
},
|
|
"shirt_henley": {
|
|
"description": "Upper body inner shirt",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
],
|
|
"thickness": 0.004, # thinner for inner layer
|
|
},
|
|
"boots_work": {
|
|
"description": "Work boots (foot slot)",
|
|
"segments": [
|
|
"seg_foot_l", "seg_foot_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r", # boot shaft reaches up the lower leg
|
|
],
|
|
"thickness": 0.010, # thicker for boots
|
|
},
|
|
}
|
|
|
|
|
|
def clear_scene():
|
|
"""Remove all objects and purge orphan data."""
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
|
|
|
|
|
def import_glb(path):
|
|
"""Import a GLB file. Returns newly added objects."""
|
|
before = set(bpy.context.scene.objects)
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
return [o for o in bpy.context.scene.objects if o not in before]
|
|
|
|
|
|
def offset_vertices_along_normals(obj, thickness):
|
|
"""
|
|
Move each vertex outward along its computed normal by `thickness` metres.
|
|
Operates directly on mesh data -- no operators, reliable in headless mode.
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(obj.data)
|
|
bm.verts.ensure_lookup_table()
|
|
|
|
# Ensure normals are up to date
|
|
bm.normal_update()
|
|
|
|
for v in bm.verts:
|
|
v.co += v.normal * thickness
|
|
|
|
bm.to_mesh(obj.data)
|
|
bm.free()
|
|
obj.data.update()
|
|
|
|
|
|
def export_glb(obj, output_path, armature=None):
|
|
"""Export a mesh (and optionally its armature) as GLB with skinning data."""
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
obj.select_set(True)
|
|
if armature:
|
|
armature.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=output_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=armature is not None,
|
|
export_materials='EXPORT',
|
|
)
|
|
|
|
|
|
def create_reference_mesh(item_id, config, bodies_dir, output_dir):
|
|
"""
|
|
Create the reference.glb for one clothing item on average_m geometry.
|
|
Preserves bone weights from body segments so clothing deforms with the skeleton.
|
|
Returns True on success, False on error.
|
|
"""
|
|
print(f"\n{'='*60}")
|
|
print(f" Item: {item_id} — {config['description']}")
|
|
|
|
clear_scene()
|
|
|
|
average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
|
imported_meshes = []
|
|
imported_armatures = []
|
|
missing_segments = []
|
|
|
|
for seg_name in config["segments"]:
|
|
seg_path = os.path.join(average_m_dir, f"{seg_name}.glb")
|
|
if not os.path.isfile(seg_path):
|
|
missing_segments.append(seg_name)
|
|
continue
|
|
objs = import_glb(seg_path)
|
|
meshes = [o for o in objs if o.type == 'MESH']
|
|
armatures = [o for o in objs if o.type == 'ARMATURE']
|
|
imported_meshes.extend(meshes)
|
|
imported_armatures.extend(armatures)
|
|
|
|
if missing_segments:
|
|
print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}")
|
|
|
|
if not imported_meshes:
|
|
print(f" ERROR: No segment meshes could be imported for {item_id}")
|
|
return False
|
|
|
|
print(f" Imported {len(imported_meshes)} segments, "
|
|
f"{len(imported_armatures)} armatures "
|
|
f"({len(missing_segments)} missing)")
|
|
|
|
# Use the first armature as the canonical one — all segments share the same rig
|
|
canonical_armature = imported_armatures[0] if imported_armatures else None
|
|
|
|
# Re-parent all meshes to the canonical armature (preserving bone weights)
|
|
if canonical_armature:
|
|
for m in imported_meshes:
|
|
# Clear any existing parent
|
|
m.parent = None
|
|
m.matrix_world = m.matrix_world # preserve world transform
|
|
# Set parent to canonical armature with Armature modifier
|
|
m.parent = canonical_armature
|
|
m.parent_type = 'OBJECT'
|
|
# Ensure Armature modifier exists pointing to canonical armature
|
|
has_armature_mod = False
|
|
for mod in m.modifiers:
|
|
if mod.type == 'ARMATURE':
|
|
mod.object = canonical_armature
|
|
has_armature_mod = True
|
|
if not has_armature_mod:
|
|
mod = m.modifiers.new(name="Armature", type='ARMATURE')
|
|
mod.object = canonical_armature
|
|
|
|
# Remove duplicate armatures (keep only canonical)
|
|
for arm in imported_armatures[1:]:
|
|
bpy.data.objects.remove(arm, do_unlink=True)
|
|
|
|
# Join all segments into one mesh (vertex groups / bone weights are preserved by join)
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for m in imported_meshes:
|
|
if m.name in bpy.data.objects:
|
|
m.select_set(True)
|
|
bpy.context.view_layer.objects.active = imported_meshes[0]
|
|
if len(imported_meshes) > 1:
|
|
bpy.ops.object.join()
|
|
|
|
clothing_obj = bpy.context.active_object
|
|
clothing_obj.name = f"ref_{item_id}"
|
|
|
|
vertex_count_before = len(clothing_obj.data.vertices)
|
|
print(f" Mesh vertices: {vertex_count_before}")
|
|
print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}")
|
|
|
|
# Offset vertices outward to simulate clothing thickness
|
|
thickness = config["thickness"]
|
|
offset_vertices_along_normals(clothing_obj, thickness)
|
|
print(f" Applied {thickness*1000:.1f}mm outward offset")
|
|
|
|
# Export with armature so clothing is skinned
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
output_path = os.path.join(output_dir, "reference.glb")
|
|
export_glb(clothing_obj, output_path, armature=canonical_armature)
|
|
print(f" Exported: {output_path}")
|
|
|
|
return True
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Entry point
|
|
# -------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv
|
|
if "--" not in argv:
|
|
print("Usage: tooling/blender --background --python "
|
|
"tooling/blender_create_clothing_refs.py -- "
|
|
"<bodies_dir> <clothing_output_dir>")
|
|
sys.exit(1)
|
|
|
|
args = argv[argv.index("--") + 1:]
|
|
if len(args) < 2:
|
|
print("ERROR: Provide <bodies_dir> and <clothing_output_dir>")
|
|
sys.exit(1)
|
|
|
|
bodies_dir = args[0]
|
|
clothing_output_dir = args[1]
|
|
|
|
# Preflight: check average_m dir exists
|
|
avg_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
|
if not os.path.isdir(avg_m_dir):
|
|
print(f"ERROR: Reference body directory not found: {avg_m_dir}")
|
|
sys.exit(1)
|
|
|
|
print("\nClothing Reference Mesh Creator (v0.2 placeholder)")
|
|
print(f" Bodies dir: {bodies_dir}")
|
|
print(f" Output dir: {clothing_output_dir}")
|
|
print(f" Reference: {REFERENCE_BODY}")
|
|
print(f" Items: {len(CLOTHING_ITEMS)}")
|
|
|
|
results = {}
|
|
for item_id, config in CLOTHING_ITEMS.items():
|
|
item_output_dir = os.path.join(clothing_output_dir, item_id)
|
|
success = create_reference_mesh(item_id, config, bodies_dir, item_output_dir)
|
|
results[item_id] = success
|
|
|
|
# Summary
|
|
print(f"\n{'='*60}")
|
|
print("=== Clothing reference creation complete ===")
|
|
ok_items = [k for k, v in results.items() if v]
|
|
fail_items = [k for k, v in results.items() if not v]
|
|
for item_id in CLOTHING_ITEMS:
|
|
status = "OK" if results[item_id] else "FAILED"
|
|
print(f" {item_id:20s} {status}")
|
|
print(f"\n OK: {len(ok_items)} FAILED: {len(fail_items)}")
|
|
|
|
if fail_items:
|
|
sys.exit(1)
|