Merge remote-tracking branch 'origin/visual'
This commit is contained in:
@@ -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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"hides": [
|
||||
"foot_l",
|
||||
"foot_r",
|
||||
"leg_lower_l",
|
||||
"leg_lower_r"
|
||||
],
|
||||
"torso_variant": "full"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"hides": [
|
||||
"leg_upper_l",
|
||||
"leg_upper_r",
|
||||
"leg_lower_l",
|
||||
"leg_lower_r"
|
||||
],
|
||||
"torso_variant": "full"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hides": [
|
||||
"torso",
|
||||
"arm_upper_l",
|
||||
"arm_upper_r"
|
||||
],
|
||||
"torso_variant": "full"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 98 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user