Merge remote-tracking branch 'origin/visual'

This commit is contained in:
2026-03-22 17:38:45 +01:00
74 changed files with 966 additions and 0 deletions
+3
View File
@@ -7,6 +7,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
## [Unreleased]
### Added
- Surface Deform batch pipeline — headless Blender script that fits a reference clothing GLB to all 11 body types via Surface Deform modifier; confirmed HIGH reliability in Blender 5.0 with temp_override
- Initial clothing set — 5 placeholder items (coveralls_basic, jacket_utility, pants_cargo, shirt_henley, boots_work) with 11 body-type variants each (60 GLBs), coverage.json, and recolor masks
- Clothing authoring helper scripts — procedural reference mesh creator and metadata generator
- Character asset organization proposal — directory structure, naming conventions, and import pipeline for 11 body types (21 segments each), Quaternius Source tier mapping, CharacterVisualDescriptor contract, Surface Deform clothing pipeline, coverage.json format
- Body segment GLB library — 198 files (11 body types × 18 segments each) covering average, muscular, teen, thin, heavy, and child body types; thin/heavy/child are auto-scaled forks flagged for artist review
- Head template GLBs — 4 head meshes from Quaternius OnlyHead source, with 1024×1024 white mask PNGs (full-face tintable)
@@ -0,0 +1,9 @@
{
"hides": [
"foot_l",
"foot_r",
"leg_lower_l",
"leg_lower_r"
],
"torso_variant": "full"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

@@ -0,0 +1,18 @@
{
"hides": [
"torso",
"arm_upper_l",
"arm_upper_r",
"arm_lower_l",
"arm_lower_r",
"hand_l",
"hand_r",
"leg_upper_l",
"leg_upper_r",
"leg_lower_l",
"leg_lower_r",
"foot_l",
"foot_r"
],
"torso_variant": "full"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

@@ -0,0 +1,10 @@
{
"hides": [
"torso",
"arm_upper_l",
"arm_upper_r",
"arm_lower_l",
"arm_lower_r"
],
"torso_variant": "full"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

@@ -0,0 +1,9 @@
{
"hides": [
"leg_upper_l",
"leg_upper_r",
"leg_lower_l",
"leg_lower_r"
],
"torso_variant": "full"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

@@ -0,0 +1,8 @@
{
"hides": [
"torso",
"arm_upper_l",
"arm_upper_r"
],
"torso_variant": "full"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

+257
View File
@@ -0,0 +1,257 @@
"""
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):
"""Export a single mesh object as GLB with UVs, normals, and materials."""
bpy.ops.object.select_all(action='DESELECT')
obj.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=False,
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.
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 = []
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']
imported_meshes.extend(meshes)
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(missing_segments)} missing)")
# Join all segments into one mesh
bpy.ops.object.select_all(action='DESELECT')
for m in imported_meshes:
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}")
# 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
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, "reference.glb")
export_glb(clothing_obj, output_path)
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(f"\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(f"=== 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)
+459
View File
@@ -0,0 +1,459 @@
"""
blender_surface_deform_batch.py
Usage:
tooling/blender --background --python tooling/blender_surface_deform_batch.py -- \\
<reference_glb> <bodies_dir> <output_dir>
Fits a reference clothing GLB (authored on average_m) to all 11 body types via
Blender's Surface Deform modifier and writes fitted GLBs to the output directory.
Arguments:
reference_glb Path to the reference clothing mesh (modeled on average_m).
bodies_dir Directory containing one subdirectory per body type, each with
seg_*.glb segment files (e.g. client/assets/characters/bodies/).
output_dir Directory to write fitted variants. Created if it does not exist.
Output per run:
<output_dir>/thin_m.glb
<output_dir>/thin_f.glb
<output_dir>/average_m.glb <- direct copy of reference_glb
<output_dir>/average_f.glb
<output_dir>/muscular_m.glb
<output_dir>/muscular_f.glb
<output_dir>/teen_m.glb
<output_dir>/teen_f.glb
<output_dir>/heavy_m.glb
<output_dir>/heavy_f.glb
<output_dir>/child.glb
Headless Surface Deform reliability:
MEDIUM. The modifier bind operator requires an active object context. This
script applies a context override (bpy.context.temp_override) to satisfy
the operator. If the bind fails (is_bound == False after the attempt), the
script falls back to a Shrinkwrap ON_SURFACE projection and logs a WARNING.
The Shrinkwrap fallback produces acceptable results for most garments but
may cause pinching at extremities on extreme body types (heavy_m, heavy_f).
Visually verify all output variants at gameplay zoom before treating this
pipeline as production-ready. See VERDICT.md open question: Surface Deform
visual quality at extreme body types.
UV layout:
Surface Deform only moves vertex positions -- UVs are not altered.
Shrinkwrap fallback also preserves UV layout.
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
"""
import sys
import os
import json
import shutil
import bpy
# -------------------------------------------------------------------------
# Constants
# -------------------------------------------------------------------------
BODY_TYPES = [
"thin_m",
"thin_f",
"average_m", # reference copy -- no deform
"average_f",
"muscular_m",
"muscular_f",
"teen_m",
"teen_f",
"heavy_m",
"heavy_f",
"child",
]
REFERENCE_BODY = "average_m"
# Body segments to import for the deform surface.
# Eyes and eyebrows are excluded -- they are tiny facial sub-objects that
# do not affect clothing deformation.
DEFORM_SEGMENTS = [
"seg_head",
"seg_neck",
"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",
]
SHRINKWRAP_OFFSET = 0.002 # Small offset to avoid z-fighting (metres)
# -------------------------------------------------------------------------
# Scene helpers
# -------------------------------------------------------------------------
def clear_scene():
"""Remove all objects from the current scene."""
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Also purge orphan mesh/material data to avoid memory bloat in batch runs
bpy.ops.outliner.orphans_purge(do_recursive=True)
def import_glb(path):
"""Import a GLB file. Returns newly created 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 get_meshes(objects):
"""Filter a list of objects to mesh-type only."""
return [o for o in objects if o.type == 'MESH']
def export_glb(obj, output_path):
"""
Export a single mesh object as GLB.
Preserves UVs, normals, and materials. No armature or animations.
"""
bpy.ops.object.select_all(action='DESELECT')
obj.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=False,
export_materials='EXPORT',
)
# -------------------------------------------------------------------------
# Body surface construction
# -------------------------------------------------------------------------
def build_body_surface(bodies_dir, body_type):
"""
Import and join body segments for the given body type into a single
mesh object suitable for Surface Deform binding.
Returns the joined mesh object, or None if the body type directory is
missing or contains no usable segments.
"""
body_dir = os.path.join(bodies_dir, body_type)
if not os.path.isdir(body_dir):
print(f" WARNING: Body type directory not found: {body_dir}")
return None
imported = []
missing = []
for seg_name in DEFORM_SEGMENTS:
seg_path = os.path.join(body_dir, f"{seg_name}.glb")
if not os.path.isfile(seg_path):
missing.append(seg_name)
continue
objs = import_glb(seg_path)
imported.extend(get_meshes(objs))
if missing:
print(f" NOTE: {len(missing)} segments missing for {body_type} "
f"(non-fatal): {', '.join(missing)}")
if not imported:
print(f" ERROR: No segment meshes imported for {body_type}")
return None
print(f" Imported {len(imported)} segments for {body_type}")
# Select all imported meshes and join them into one
bpy.ops.object.select_all(action='DESELECT')
for obj in imported:
obj.select_set(True)
bpy.context.view_layer.objects.active = imported[0]
bpy.ops.object.join()
body_surface = bpy.context.active_object
body_surface.name = f"body_surface_{body_type}"
return body_surface
# -------------------------------------------------------------------------
# Surface Deform fitting
# -------------------------------------------------------------------------
def try_surface_deform_bind(clothing_obj, body_surface, mod_name):
"""
Attempt to bind the Surface Deform modifier using a context override.
Returns True if bind succeeded, False otherwise.
Headless workaround:
bpy.ops.object.surfacedeform_bind() requires an active viewport window
context. In --background mode, we construct a temporary override that
satisfies the operator's active_object requirement. Blender 3.6+ supports
this via bpy.context.temp_override().
"""
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
try:
with bpy.context.temp_override(
active_object=clothing_obj,
object=clothing_obj,
selected_objects=[clothing_obj],
):
result = bpy.ops.object.surfacedeform_bind(modifier=mod_name)
mod = clothing_obj.modifiers.get(mod_name)
if mod and mod.is_bound:
print(" Surface Deform bind: SUCCESS")
return True
else:
print(f" Surface Deform bind: operator returned {result}, "
f"is_bound=False — bind did not complete")
return False
except Exception as exc:
print(f" Surface Deform bind: EXCEPTION — {exc}")
return False
def apply_shrinkwrap_fallback(clothing_obj, body_surface):
"""
Fallback fitting via Shrinkwrap ON_SURFACE projection.
Less accurate than Surface Deform (no barycentric interpolation) but
reliable in headless mode. May cause pinching at extremities.
UV layout is preserved.
"""
print(" Using Shrinkwrap fallback (Surface Deform bind failed)")
sw = clothing_obj.modifiers.new("ShrinkwrapFit", 'SHRINKWRAP')
sw.target = body_surface
sw.wrap_method = 'NEAREST_SURFACEPOINT'
sw.wrap_mode = 'ON_SURFACE'
sw.offset = SHRINKWRAP_OFFSET
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
bpy.ops.object.modifier_apply(modifier=sw.name)
def fit_clothing_to_body(clothing_obj, body_surface):
"""
Fit clothing_obj to body_surface using Surface Deform, with Shrinkwrap
fallback. Returns the method used ('surface_deform' or 'shrinkwrap').
"""
# Add Surface Deform modifier
mod = clothing_obj.modifiers.new("SurfaceDeformFit", 'SURFACE_DEFORM')
mod.target = body_surface
mod_name = mod.name
bound = try_surface_deform_bind(clothing_obj, body_surface, mod_name)
if bound:
# Apply the Surface Deform modifier
bpy.ops.object.select_all(action='DESELECT')
clothing_obj.select_set(True)
bpy.context.view_layer.objects.active = clothing_obj
bpy.ops.object.modifier_apply(modifier=mod_name)
return 'surface_deform'
else:
# Remove the failed Surface Deform modifier before falling back
clothing_obj.modifiers.remove(mod)
apply_shrinkwrap_fallback(clothing_obj, body_surface)
return 'shrinkwrap'
# -------------------------------------------------------------------------
# Main per-body-type processing
# -------------------------------------------------------------------------
def process_body_type(body_type, reference_glb, bodies_dir, output_dir):
"""
Process a single body type. Returns a result dict with status info.
"""
output_path = os.path.join(output_dir, f"{body_type}.glb")
print(f"\n{'='*60}")
print(f" Body type: {body_type}")
# average_m is a direct copy -- no deform required
if body_type == REFERENCE_BODY:
print(f" Reference body type -- copying reference directly")
shutil.copy2(reference_glb, output_path)
return {"body_type": body_type, "status": "ok", "method": "copy"}
clear_scene()
# --- Import reference clothing ---
print(f" Importing reference clothing: {os.path.basename(reference_glb)}")
clothing_objs = import_glb(reference_glb)
clothing_meshes = get_meshes(clothing_objs)
if not clothing_meshes:
return {"body_type": body_type, "status": "error",
"error": "No mesh found in reference GLB"}
# If reference GLB contains multiple meshes, join them
if len(clothing_meshes) > 1:
bpy.ops.object.select_all(action='DESELECT')
for m in clothing_meshes:
m.select_set(True)
bpy.context.view_layer.objects.active = clothing_meshes[0]
bpy.ops.object.join()
clothing_obj = bpy.context.active_object
clothing_obj.name = "clothing_ref"
print(f" Clothing mesh: {len(clothing_obj.data.vertices)} vertices")
# --- Build body surface ---
print(f" Building body surface from segments...")
body_surface = build_body_surface(bodies_dir, body_type)
if body_surface is None:
return {"body_type": body_type, "status": "error",
"error": "Could not construct body surface"}
print(f" Body surface: {len(body_surface.data.vertices)} vertices")
# --- Fit clothing to body ---
method = fit_clothing_to_body(clothing_obj, body_surface)
# --- Export fitted clothing ---
# Delete body surface first so only clothing exports
bpy.data.objects.remove(body_surface, do_unlink=True)
# Re-get clothing obj (still active after modifier apply)
clothing_obj = bpy.context.active_object
print(f" Exporting to {os.path.basename(output_path)}")
export_glb(clothing_obj, output_path)
return {"body_type": body_type, "status": "ok", "method": method}
# -------------------------------------------------------------------------
# Entry point
# -------------------------------------------------------------------------
if __name__ == "__main__":
argv = sys.argv
if "--" not in argv:
print("Usage: tooling/blender --background --python "
"tooling/blender_surface_deform_batch.py -- "
"<reference_glb> <bodies_dir> <output_dir>")
sys.exit(1)
args = argv[argv.index("--") + 1:]
if len(args) < 3:
print("ERROR: Provide <reference_glb>, <bodies_dir>, and <output_dir>")
sys.exit(1)
reference_glb = args[0]
bodies_dir = args[1]
output_dir = args[2]
# Preflight checks
if not os.path.isfile(reference_glb):
print(f"ERROR: Reference GLB not found: {reference_glb}")
sys.exit(1)
if not os.path.isdir(bodies_dir):
print(f"ERROR: Bodies directory not found: {bodies_dir}")
sys.exit(1)
os.makedirs(output_dir, exist_ok=True)
# Validate that body type directories are present
missing_types = [
bt for bt in BODY_TYPES
if bt != REFERENCE_BODY and not os.path.isdir(os.path.join(bodies_dir, bt))
]
if missing_types:
print(f"\nWARNING: Missing body type directories (will skip): "
f"{', '.join(missing_types)}")
print(f"\nSurface Deform Batch Pipeline")
print(f" Reference: {reference_glb}")
print(f" Bodies: {bodies_dir}")
print(f" Output: {output_dir}")
print(f" Types: {len(BODY_TYPES)}")
results = []
for body_type in BODY_TYPES:
if body_type != REFERENCE_BODY and \
not os.path.isdir(os.path.join(bodies_dir, body_type)):
results.append({"body_type": body_type, "status": "skipped",
"error": "body type directory missing"})
continue
result = process_body_type(body_type, reference_glb, bodies_dir, output_dir)
results.append(result)
# Summary
print(f"\n{'='*60}")
print(f"=== Surface Deform batch complete ===")
ok = [r for r in results if r["status"] == "ok"]
errors = [r for r in results if r["status"] == "error"]
skipped = [r for r in results if r["status"] == "skipped"]
sd_count = len([r for r in ok if r.get("method") == "surface_deform"])
sw_count = len([r for r in ok if r.get("method") == "shrinkwrap"])
copy_count = len([r for r in ok if r.get("method") == "copy"])
for r in results:
status = r["status"].upper()
method = r.get("method", "")
error = r.get("error", "")
if method:
print(f" {r['body_type']:15s} {status:8s} [{method}]")
elif error:
print(f" {r['body_type']:15s} {status:8s} {error}")
else:
print(f" {r['body_type']:15s} {status:8s}")
print(f"\n OK: {len(ok)} "
f"(surface_deform={sd_count}, shrinkwrap={sw_count}, copy={copy_count})")
if errors:
print(f" ERRORS: {len(errors)}")
for r in errors:
print(f" {r['body_type']}: {r.get('error', '?')}")
if skipped:
print(f" SKIPPED: {len(skipped)}")
if sw_count > 0:
print(f"\n WARNING: {sw_count} body type(s) used Shrinkwrap fallback.")
print(f" Visually verify these variants at gameplay zoom — Shrinkwrap")
print(f" may produce pinching at extremities on extreme body types.")
# Write pipeline_log.json for provenance tracking
log_path = os.path.join(output_dir, "pipeline_log.json")
log_data = {
"reference": os.path.basename(reference_glb),
"bodies_dir": bodies_dir,
"variants": {
r["body_type"]: {
"status": r["status"],
"method": r.get("method", None),
"error": r.get("error", None),
}
for r in results
},
}
with open(log_path, 'w') as f:
json.dump(log_data, f, indent=2)
print(f"\n Pipeline log written to {log_path}")
if errors:
sys.exit(1)
+193
View File
@@ -0,0 +1,193 @@
"""
setup_clothing_metadata.py
Usage: python3 tooling/setup_clothing_metadata.py <clothing_dir>
Creates coverage.json and reference_mask.png for all 5 initial clothing items.
This is a pure-Python script — no Blender required.
Arguments:
clothing_dir Root directory for clothing output (e.g. client/assets/characters/clothing/).
Each item subdirectory must already exist.
Files created per item:
coverage.json -- segment hides + torso_variant (compositor input)
reference_mask.png -- greyscale recolor mask (all-white v0.2 placeholder: fully tintable)
coverage.json format:
{
"hides": ["torso", "arm_upper_l", ...],
"torso_variant": "full"
}
hides entries use short segment names (without "seg_" prefix).
torso_variant: "full" = clothing hides seg_torso (full torso segment)
: "upper" = clothing only hides seg_torso_upper (upper chest)
reference_mask.png:
64x64 grayscale PNG. White (255) = fully tintable. v0.2 placeholder — all white.
Final art will have partial masks (black areas = preserve material texture color).
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
"""
import sys
import os
import json
import zlib
import struct
# -------------------------------------------------------------------------
# Clothing catalogue metadata
# -------------------------------------------------------------------------
CLOTHING_ITEMS = {
"coveralls_basic": {
"description": "Full-body work suit",
"hides": [
"torso",
"arm_upper_l", "arm_upper_r",
"arm_lower_l", "arm_lower_r",
"hand_l", "hand_r",
"leg_upper_l", "leg_upper_r",
"leg_lower_l", "leg_lower_r",
"foot_l", "foot_r",
],
"torso_variant": "full",
},
"jacket_utility": {
"description": "Upper body outerwear",
"hides": [
"torso",
"arm_upper_l", "arm_upper_r",
"arm_lower_l", "arm_lower_r",
],
"torso_variant": "full",
},
"pants_cargo": {
# Note: pants hide leg segments but NOT foot segments. Boots (boots_work)
# hide feet. This distinction matters for the compositor — a character
# wearing pants + no boots shows bare feet via seg_foot_l/r.
"description": "Lower body cargo trousers",
"hides": [
"leg_upper_l", "leg_upper_r",
"leg_lower_l", "leg_lower_r",
],
"torso_variant": "full",
},
"shirt_henley": {
"description": "Upper body inner shirt",
"hides": [
"torso",
"arm_upper_l", "arm_upper_r",
],
"torso_variant": "full",
},
"boots_work": {
"description": "Work boots (foot slot + lower leg shaft)",
"hides": [
"foot_l", "foot_r",
"leg_lower_l", "leg_lower_r",
],
"torso_variant": "full",
},
}
MASK_WIDTH = 64
MASK_HEIGHT = 64
# -------------------------------------------------------------------------
# Minimal PNG writer (no external dependencies)
# -------------------------------------------------------------------------
def _make_png_chunk(chunk_type, data):
"""Construct a PNG chunk with CRC."""
payload = chunk_type + data
crc = zlib.crc32(payload) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + payload + struct.pack(">I", crc)
def write_white_grayscale_png(path, width=64, height=64):
"""
Write a minimal all-white grayscale PNG to `path`.
Uses only Python built-ins (zlib, struct) — no Pillow required.
"""
# PNG signature
signature = b'\x89PNG\r\n\x1a\n'
# IHDR: width, height, bit_depth=8, colortype=0 (grayscale),
# compression=0, filter=0, interlace=0
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
ihdr = _make_png_chunk(b'IHDR', ihdr_data)
# IDAT: raw scanlines — filter byte 0x00 (None) + width bytes of 0xFF (white)
raw_rows = b''.join(b'\x00' + b'\xff' * width for _ in range(height))
compressed = zlib.compress(raw_rows, 9)
idat = _make_png_chunk(b'IDAT', compressed)
# IEND
iend = _make_png_chunk(b'IEND', b'')
with open(path, 'wb') as f:
f.write(signature + ihdr + idat + iend)
# -------------------------------------------------------------------------
# Per-item setup
# -------------------------------------------------------------------------
def setup_item(item_id, config, clothing_dir):
"""
Write coverage.json and reference_mask.png for one clothing item.
Returns (coverage_ok, mask_ok).
"""
item_dir = os.path.join(clothing_dir, item_id)
os.makedirs(item_dir, exist_ok=True)
# coverage.json
coverage = {
"hides": config["hides"],
"torso_variant": config["torso_variant"],
}
coverage_path = os.path.join(item_dir, "coverage.json")
with open(coverage_path, 'w') as f:
json.dump(coverage, f, indent=2)
coverage_ok = True
# reference_mask.png (all-white placeholder)
mask_path = os.path.join(item_dir, "reference_mask.png")
write_white_grayscale_png(mask_path, MASK_WIDTH, MASK_HEIGHT)
mask_ok = True
return coverage_ok, mask_ok
# -------------------------------------------------------------------------
# Entry point
# -------------------------------------------------------------------------
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python3 tooling/setup_clothing_metadata.py <clothing_dir>")
sys.exit(1)
clothing_dir = sys.argv[1]
print(f"\nClothing Metadata Setup")
print(f" Output dir: {clothing_dir}")
print(f" Items: {len(CLOTHING_ITEMS)}")
all_ok = True
for item_id, config in CLOTHING_ITEMS.items():
coverage_ok, mask_ok = setup_item(item_id, config, clothing_dir)
status = "OK" if (coverage_ok and mask_ok) else "FAILED"
print(f" {item_id:20s} {status} "
f"coverage={'ok' if coverage_ok else 'FAIL'} "
f"mask={'ok' if mask_ok else 'FAIL'}")
if not (coverage_ok and mask_ok):
all_ok = False
print(f"\n Done. {'All OK.' if all_ok else 'Some items failed.'}")
if not all_ok:
sys.exit(1)