Character hair physics implementation
This commit is contained in:
@@ -459,41 +459,24 @@ function(add_shape_key_propagation INPUT_BLEND OUTPUT_BLEND)
|
||||
)
|
||||
endfunction()
|
||||
|
||||
add_shape_key_propagation(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend
|
||||
)
|
||||
add_shape_key_propagation(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-female.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend
|
||||
)
|
||||
|
||||
# male
|
||||
set(SLOT_LIST "bottom" "top" "feet" "hair")
|
||||
set(SLOT_INPUT "edited-normal-male-shapes.blend")
|
||||
list(GET SLOT_LIST -1 LAST_SLOT)
|
||||
foreach (SLOT ${SLOT_LIST})
|
||||
set(SLOT_OUTPUT "edited-normal-male-consolidated-${SLOT}.blend")
|
||||
if (SLOT STREQUAL LAST_SLOT)
|
||||
set(SLOT_OUTPUT "edited-normal-male-consolidated.blend")
|
||||
endif()
|
||||
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-${SLOT}_weighted.blend"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
|
||||
# male and female clothes pipeline
|
||||
foreach (SEX ${SEX_LIST})
|
||||
add_shape_key_propagation(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-${SEX}.blend
|
||||
${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${SEX}-shapes.blend
|
||||
)
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
|
||||
# female
|
||||
set(SLOT_INPUT "edited-normal-female-shapes.blend")
|
||||
foreach (SLOT ${SLOT_LIST})
|
||||
set(SLOT_OUTPUT "edited-normal-female-consolidated-${SLOT}.blend")
|
||||
if (SLOT STREQUAL LAST_SLOT)
|
||||
set(SLOT_OUTPUT "edited-normal-female-consolidated.blend")
|
||||
endif()
|
||||
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-${SLOT}_weighted.blend"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
|
||||
)
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
set(SLOT_INPUT "edited-normal-${SEX}-shapes.blend")
|
||||
foreach (SLOT ${SLOT_LIST})
|
||||
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated-${SLOT}.blend")
|
||||
if (SLOT STREQUAL LAST_SLOT)
|
||||
set(SLOT_OUTPUT "edited-normal-${SEX}-consolidated.blend")
|
||||
endif()
|
||||
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-${SLOT}_weighted.blend"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/${SLOT_OUTPUT}"
|
||||
)
|
||||
set(SLOT_INPUT ${SLOT_OUTPUT})
|
||||
endforeach()
|
||||
endforeach()
|
||||
|
||||
Binary file not shown.
@@ -151,16 +151,19 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
|
||||
|
||||
print(f"Processing: {clothing_name_for_final} -> {target_name} (using copy)")
|
||||
|
||||
# NEW CODE: Store custom properties from clothing before they might be lost
|
||||
# Store custom properties from clothing before they might be lost
|
||||
clothing_props = {}
|
||||
for prop_name in ["ref_shapes", "ref_ray_length"]:
|
||||
for prop_name in ["ref_shapes", "ref_ray_length",
|
||||
"has_own_armature", "own_armature_name"]:
|
||||
if prop_name in clothing_obj:
|
||||
clothing_props[prop_name] = clothing_obj[prop_name]
|
||||
print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing")
|
||||
|
||||
# Also store metadata tags for export pipeline
|
||||
clothing_meta = {}
|
||||
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", "age", "sex", "slot"]:
|
||||
for prop_name in ["ref_layer", "ref_part", "garment_id", "tags",
|
||||
"age", "sex", "slot",
|
||||
"has_own_armature", "own_armature_name"]:
|
||||
if prop_name in clothing_obj:
|
||||
clothing_meta[prop_name] = clothing_obj[prop_name]
|
||||
print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing")
|
||||
@@ -181,18 +184,31 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
|
||||
whitelist.add(master_arm)
|
||||
|
||||
# Handle clothing armature (if it's not a copy for layer 2)
|
||||
has_own_arm = clothing_obj.get("has_own_armature", False)
|
||||
if not is_clothing_copy and clothing_obj.parent and clothing_obj.parent.type == 'ARMATURE':
|
||||
old_arm = clothing_obj.parent
|
||||
clothing_obj.matrix_world = clothing_obj.matrix_world.copy()
|
||||
clothing_obj.parent = None
|
||||
if old_arm not in whitelist:
|
||||
bpy.data.objects.remove(old_arm, do_unlink=True)
|
||||
# Keep hair armatures (objects with own skeleton)
|
||||
if not has_own_arm:
|
||||
if old_arm not in whitelist:
|
||||
bpy.data.objects.remove(old_arm, do_unlink=True)
|
||||
else:
|
||||
whitelist.add(old_arm) # preserve hair skeleton
|
||||
elif not is_clothing_copy and has_own_arm:
|
||||
# Hair has own skeleton but parent is None (or not an armature);
|
||||
# find the armature by name and preserve it.
|
||||
own_arm_name = clothing_obj.get("own_armature_name", "")
|
||||
old_arm = bpy.data.objects.get(own_arm_name)
|
||||
if old_arm and old_arm.type == 'ARMATURE' and old_arm not in whitelist:
|
||||
whitelist.add(old_arm)
|
||||
print(f" Preserved hair armature '{old_arm.name}' by name for {clothing_name_for_final}")
|
||||
|
||||
# For layer 2 clothing copies, we don't need to handle armature separately
|
||||
# as they'll inherit from the target
|
||||
|
||||
# Reparent to master armature if exists
|
||||
if master_arm:
|
||||
if master_arm and not clothing_obj.get("has_own_armature", False):
|
||||
clothing_obj.parent = master_arm
|
||||
for mod in clothing_obj.modifiers:
|
||||
if mod.type == 'ARMATURE':
|
||||
@@ -210,11 +226,6 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
|
||||
bpy.ops.object.join()
|
||||
|
||||
# ---- Fix: ensure clothing faces keep the clothing's material ----
|
||||
# After join, Blender may have assigned clothing faces to a
|
||||
# pre-existing slot with the same base name but older texture
|
||||
# references (loaded from the body blend). Find the slot that
|
||||
# actually holds the clothing's original material data block
|
||||
# and reassign any faces that landed in a stale look-alike slot.
|
||||
if clothing_mat:
|
||||
clothing_slot = None
|
||||
stale_slot = None
|
||||
@@ -238,15 +249,12 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy=
|
||||
f"{stale_slot} -> clothing slot {clothing_slot}")
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
# NEW CODE: Copy stored custom properties to combined object
|
||||
# After joining, new_target is the active object and contains the combined mesh
|
||||
# Copy stored custom properties to combined object
|
||||
for prop_name, prop_value in clothing_props.items():
|
||||
new_target[prop_name] = prop_value
|
||||
print(f" Copied property '{prop_name}' = {prop_value} to combined object")
|
||||
|
||||
# NEW CODE: Aggregate clothing metadata onto combined object
|
||||
# Collect from all clothing objects processed for this target
|
||||
# Use JSON string since Blender ID properties don't support Python lists/sets
|
||||
# Aggregate clothing metadata onto combined object
|
||||
if "_combined_meta" in new_target:
|
||||
meta = json.loads(new_target["_combined_meta"])
|
||||
else:
|
||||
@@ -297,17 +305,14 @@ def run_batch_combine():
|
||||
all_objs = bpy.data.objects
|
||||
|
||||
# Separate body objects (no ref_layer property)
|
||||
# Filter out rig control shapes (cs_*), helper objects, and objects with no vertices
|
||||
body_objects = []
|
||||
skipped_objects = []
|
||||
for o in all_objs:
|
||||
if o.type != 'MESH' or "ref_layer" in o:
|
||||
continue
|
||||
# Skip rig control shapes and helper objects
|
||||
if o.name.startswith("cs_") or o.name.startswith("WGT-") or o.name.startswith("MCH-") or o.name.startswith("ORG-"):
|
||||
skipped_objects.append(o.name)
|
||||
continue
|
||||
# Skip objects with no vertices (empty meshes)
|
||||
if len(o.data.vertices) == 0:
|
||||
skipped_objects.append(o.name)
|
||||
continue
|
||||
@@ -316,7 +321,6 @@ def run_batch_combine():
|
||||
if skipped_objects:
|
||||
print(f"Skipped {len(skipped_objects)} non-body mesh objects: {', '.join(skipped_objects[:10])}{'...' if len(skipped_objects) > 10 else ''}")
|
||||
|
||||
# Filter body objects: those missing age/sex/slot are treated as helpers/references
|
||||
required_body_props = {"age", "sex", "slot"}
|
||||
valid_body_objects = []
|
||||
skipped_body_objects = []
|
||||
@@ -335,7 +339,6 @@ def run_batch_combine():
|
||||
|
||||
body_objects = valid_body_objects
|
||||
|
||||
# Separate clothing by layer, filtering out objects missing required clothing properties
|
||||
required_clothing_props = {"ref_layer", "ref_part", "garment_id"}
|
||||
clothing_layer1 = []
|
||||
clothing_layer2 = []
|
||||
@@ -343,7 +346,6 @@ def run_batch_combine():
|
||||
for o in all_objs:
|
||||
if o.type != 'MESH' or "ref_layer" not in o:
|
||||
continue
|
||||
# Check if this is a valid clothing object (has required clothing properties)
|
||||
missing = [p for p in required_clothing_props if p not in o]
|
||||
if missing:
|
||||
print(f"WARNING: Mesh object '{o.name}' has ref_layer but is missing required clothing properties {missing}.")
|
||||
@@ -362,16 +364,11 @@ def run_batch_combine():
|
||||
print(f"Found {len(clothing_layer1)} layer 1 clothing objects")
|
||||
print(f"Found {len(clothing_layer2)} layer 2 clothing objects")
|
||||
|
||||
# Create dictionary of body objects for quick lookup
|
||||
body_objects_dict = {obj.name: obj for obj in body_objects}
|
||||
|
||||
# Track objects to keep
|
||||
whitelist = set()
|
||||
|
||||
# List to store combined objects from layer 1
|
||||
combined_objects = []
|
||||
|
||||
# PROCESS LAYER 1: Combine with original body parts
|
||||
# PROCESS LAYER 1
|
||||
print("\n=== PROCESSING LAYER 1 CLOTHING ===")
|
||||
for clothing_obj in clothing_layer1:
|
||||
if "ref_part" not in clothing_obj:
|
||||
@@ -385,7 +382,6 @@ def run_batch_combine():
|
||||
print(f"Warning: Target body '{target_name}' not found for clothing '{clothing_obj.name}', skipping")
|
||||
continue
|
||||
|
||||
# Process the pair
|
||||
result = process_clothing_pair(clothing_obj, original_body, whitelist)
|
||||
if result:
|
||||
combined_objects.append(result)
|
||||
@@ -399,27 +395,20 @@ def run_batch_combine():
|
||||
|
||||
for clothing_obj in clothing_layer2:
|
||||
print(f"\nProcessing layer 2 clothing: {clothing_obj.name}")
|
||||
|
||||
# Store the original clothing name for later use
|
||||
original_clothing_name = clothing_obj.name
|
||||
|
||||
# For each combined object from layer 1
|
||||
for combined_obj in combined_objects:
|
||||
# Create a copy of the layer 2 clothing
|
||||
clothing_copy = clothing_obj.copy()
|
||||
clothing_copy.data = clothing_obj.data.copy()
|
||||
bpy.context.collection.objects.link(clothing_copy)
|
||||
|
||||
# Copy custom properties
|
||||
for key in clothing_obj.keys():
|
||||
clothing_copy[key] = clothing_obj[key]
|
||||
|
||||
# Set the ref_part to point to the combined object
|
||||
clothing_copy["ref_part"] = combined_obj.name
|
||||
|
||||
print(f" Combining with layer 1 result: {combined_obj.name}")
|
||||
|
||||
# Process the pair
|
||||
result = process_clothing_pair(clothing_copy, combined_obj, whitelist,
|
||||
is_clothing_copy=True,
|
||||
original_clothing_name=original_clothing_name)
|
||||
@@ -429,7 +418,7 @@ def run_batch_combine():
|
||||
|
||||
print(f"Layer 2 first pass complete. Created {len(layer2_results_over_l1)} combined objects")
|
||||
|
||||
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts (like layer 1)
|
||||
# PROCESS LAYER 2 (Second Pass): Combine directly with body parts
|
||||
print("\n=== PROCESSING LAYER 2 CLOTHING (Second Pass - Direct to Body) ===")
|
||||
layer2_results_direct = []
|
||||
|
||||
@@ -447,7 +436,6 @@ def run_batch_combine():
|
||||
|
||||
print(f"\nProcessing layer 2 clothing directly with body: {clothing_obj.name} -> {target_name}")
|
||||
|
||||
# Process directly with body part (no copy needed for the clothing itself)
|
||||
result = process_clothing_pair(clothing_obj, original_body, whitelist, is_clothing_copy=False)
|
||||
if result:
|
||||
layer2_results_direct.append(result)
|
||||
@@ -455,7 +443,6 @@ def run_batch_combine():
|
||||
|
||||
print(f"Layer 2 second pass complete. Created {len(layer2_results_direct)} combined objects")
|
||||
|
||||
# Add all results to combined objects list
|
||||
all_results = combined_objects + layer2_results_over_l1 + layer2_results_direct
|
||||
|
||||
print(f"\n=== SUMMARY ===")
|
||||
@@ -464,7 +451,6 @@ def run_batch_combine():
|
||||
print(f"Layer 2 direct to body results: {len(layer2_results_direct)}")
|
||||
print(f"Total combined objects: {len(all_results)}")
|
||||
|
||||
# Finalize aggregated metadata on all combined objects
|
||||
for obj in all_results:
|
||||
whitelist.add(obj)
|
||||
if "_combined_meta" in obj:
|
||||
@@ -490,16 +476,13 @@ def run_batch_combine():
|
||||
|
||||
print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}")
|
||||
|
||||
# Remove temporary meta property
|
||||
del obj["_combined_meta"]
|
||||
|
||||
cleanup_unused_objects(whitelist)
|
||||
|
||||
# Save the result
|
||||
bpy.ops.wm.save_as_mainfile(filepath=output_path)
|
||||
print(f"\nSaved to: {output_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_batch_combine()
|
||||
|
||||
|
||||
@@ -16,13 +16,10 @@ def process_append(source_files, output_path):
|
||||
print(f"Warning: Source file not found: {file_path}")
|
||||
continue
|
||||
|
||||
# ---- Pre-import materials from source blend ----
|
||||
# Blender silently reuses existing materials when appending
|
||||
# objects, even if the incoming one has different properties
|
||||
# (texture references). Force materials to be imported first
|
||||
# so texture changes in the source .blend survive.
|
||||
# ---- Pre-import materials and armatures from source blend ----
|
||||
with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to):
|
||||
data_to.materials = data_from.materials
|
||||
data_to.objects = data_from.objects
|
||||
imported_materials = {}
|
||||
for mat in data_to.materials:
|
||||
if mat is None:
|
||||
@@ -33,15 +30,26 @@ def process_append(source_files, output_path):
|
||||
f"different data; incoming material will "
|
||||
f"replace it on appended objects.")
|
||||
imported_materials[mat.name] = mat
|
||||
# ----------------------------------------------------
|
||||
|
||||
with bpy.data.libraries.load(file_path) as (data_from, data_to):
|
||||
data_to.objects = data_from.objects
|
||||
# Also track imported armatures
|
||||
imported_armatures = {}
|
||||
for obj in data_to.objects:
|
||||
if obj is None:
|
||||
continue
|
||||
if obj.type == 'ARMATURE':
|
||||
imported_armatures[obj.name] = obj
|
||||
# ----------------------------------------------------
|
||||
|
||||
for obj in data_to.objects:
|
||||
if obj is None: continue
|
||||
|
||||
# Check criteria
|
||||
# Keep armatures from the source blend
|
||||
if obj.type == 'ARMATURE':
|
||||
bpy.context.collection.objects.link(obj)
|
||||
print(f"Imported armature '{obj.name}' from source blend")
|
||||
continue
|
||||
|
||||
# Check criteria for body part meshes
|
||||
has_props = all(p in obj.keys() for p in required_props)
|
||||
if obj.type == 'MESH' and has_props:
|
||||
# 1. Link to the scene root temporarily
|
||||
@@ -61,17 +69,36 @@ def process_append(source_files, output_path):
|
||||
# 2. Synchronize Names
|
||||
obj.data.name = obj.name
|
||||
|
||||
# 3. Find Target Armature
|
||||
# 3. Armature handling
|
||||
has_own_arm = obj.get("has_own_armature", False)
|
||||
if has_own_arm:
|
||||
own_arm_name = obj.get("own_armature_name", "")
|
||||
own_arm = bpy.data.objects.get(own_arm_name)
|
||||
if not own_arm and own_arm_name in imported_armatures:
|
||||
own_arm = imported_armatures[own_arm_name]
|
||||
if own_arm:
|
||||
# Keep hair's own armature; don't reparent
|
||||
# to body armature.
|
||||
# Fix the Armature modifier to point to the
|
||||
# hair armature (combine_clothes may have
|
||||
# changed it to the body rig).
|
||||
arm_mod = next((m for m in obj.modifiers
|
||||
if m.type == 'ARMATURE'), None)
|
||||
if arm_mod:
|
||||
arm_mod.object = own_arm
|
||||
obj.parent = own_arm
|
||||
print(f"Processed {obj.name}: keeping own "
|
||||
f"armature '{own_arm_name}'")
|
||||
continue
|
||||
|
||||
# Normal body part: parent to body armature
|
||||
arm_name = obj.get("sex")
|
||||
arm_obj = bpy.data.objects.get(arm_name)
|
||||
|
||||
if arm_obj and arm_obj.type == 'ARMATURE':
|
||||
# A. Handle Collections: Move mesh to armature's collections
|
||||
# Remove from all current collections first
|
||||
# A. Handle Collections
|
||||
for col in obj.users_collection:
|
||||
col.objects.unlink(obj)
|
||||
|
||||
# Link to every collection the armature belongs to
|
||||
for col in arm_obj.users_collection:
|
||||
col.objects.link(obj)
|
||||
|
||||
@@ -82,28 +109,22 @@ def process_append(source_files, output_path):
|
||||
arm_mod = next((m for m in obj.modifiers if m.type == 'ARMATURE'), None)
|
||||
if not arm_mod:
|
||||
arm_mod = obj.modifiers.new(name="Armature", type='ARMATURE')
|
||||
|
||||
arm_mod.object = arm_obj
|
||||
print(f"Processed {obj.name}: Parented and Modset to {arm_name}")
|
||||
else:
|
||||
print(f"Warning: Armature '{arm_name}' not found for {obj.name}")
|
||||
elif obj.type == 'MESH':
|
||||
# Object is a mesh but missing required properties - treat as helper/reference
|
||||
missing = [p for p in required_props if p not in obj.keys()]
|
||||
print(f"WARNING: Mesh object '{obj.name}' is missing required properties {missing}.")
|
||||
print(f" Treating as helper/reference object (not a body part).")
|
||||
print(f" Available properties: {[k for k in obj.keys() if not k.startswith('_')]}")
|
||||
# Don't remove - keep helper/reference objects in the scene
|
||||
else:
|
||||
# Clean up data not meeting criteria
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
# 4. Recursive Purge of all unlinked data (Materials, Textures, Meshes)
|
||||
bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
|
||||
|
||||
# 5. Fix seams across ALL body parts in the consolidated scene.
|
||||
# This synchronizes shape key offsets and normals for matching vertices
|
||||
# across base body parts and combined clothing variants.
|
||||
print("\nFixing seams across all consolidated body parts...")
|
||||
fix_seams_across_objects()
|
||||
fix_normals_across_objects()
|
||||
|
||||
@@ -72,7 +72,7 @@ def process_batch():
|
||||
# 3. Locate Reference Library
|
||||
target_lib_name = f"normal_{age}_{sex}.blend"
|
||||
target_lib_path = os.path.join(lib_directory, target_lib_name)
|
||||
rig_name = str(sex)
|
||||
rig_name = str(sex)
|
||||
|
||||
if not os.path.exists(target_lib_path):
|
||||
if target_lib_name == "normal_adult_male.blend":
|
||||
@@ -94,11 +94,48 @@ def process_batch():
|
||||
source_mesh = bpy.data.objects.get(ref_mesh_name)
|
||||
rig = bpy.data.objects.get(rig_name)
|
||||
|
||||
# ---- Detect hair with its own skeleton ----
|
||||
has_own_arm = False
|
||||
own_arm_name = None
|
||||
own_arm = None
|
||||
print(f" Checking {name} modifiers for own armature:")
|
||||
for mod in clothing.modifiers:
|
||||
print(f" type={mod.type} object={mod.object}")
|
||||
if mod.type == 'ARMATURE':
|
||||
arm = mod.object
|
||||
if arm is None:
|
||||
print(f" mod.object is None, trying by name...")
|
||||
continue
|
||||
print(f" arm.name='{arm.name}' rig_name='{rig_name}'")
|
||||
if arm.name != rig_name:
|
||||
has_own_arm = True
|
||||
own_arm_name = arm.name
|
||||
own_arm = arm
|
||||
break
|
||||
if has_own_arm and own_arm:
|
||||
clothing["has_own_armature"] = True
|
||||
clothing["own_armature_name"] = own_arm_name
|
||||
# Ensure hair armature is linked to scene so parent/modifier
|
||||
# references survive the save.
|
||||
try:
|
||||
bpy.context.collection.objects.link(own_arm)
|
||||
except RuntimeError:
|
||||
pass # Already linked
|
||||
print(f" DETECTED own armature '{own_arm_name}' on {name}")
|
||||
else:
|
||||
print(f" No own armature on {name} (rig='{rig_name}')")
|
||||
# -------------------------------------------
|
||||
|
||||
# 5. Prep Objects (Apply Scale & Clear Animation)
|
||||
bpy.context.view_layer.objects.active = clothing
|
||||
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
|
||||
|
||||
for item in [clothing, rig]:
|
||||
# Don't clear animation on the hair armature if it has
|
||||
# its own skeleton (we want to preserve hair animations)
|
||||
items_to_clear = [clothing]
|
||||
if not has_own_arm:
|
||||
items_to_clear.append(rig)
|
||||
for item in items_to_clear:
|
||||
if item.animation_data: item.animation_data_clear()
|
||||
if item.type == 'ARMATURE':
|
||||
bpy.context.view_layer.objects.active = item
|
||||
@@ -106,30 +143,54 @@ def process_batch():
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# 6. Weight Transfer
|
||||
clothing.vertex_groups.clear()
|
||||
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
|
||||
dt.object = source_mesh
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
|
||||
bpy.context.view_layer.objects.active = clothing
|
||||
# "Generate Data Layers" step
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
|
||||
remove_empty_vertex_groups(clothing, threshold=0.001)
|
||||
# Also clear the body rig's animation (always)
|
||||
if has_own_arm:
|
||||
for item in [rig]:
|
||||
if item.animation_data: item.animation_data_clear()
|
||||
if item.type == 'ARMATURE':
|
||||
bpy.context.view_layer.objects.active = item
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
bpy.ops.pose.transforms_clear()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
# Set garment_id from object name if not already set
|
||||
if "garment_id" not in clothing:
|
||||
clothing["garment_id"] = clothing.name
|
||||
|
||||
# 6. Weight Transfer
|
||||
if has_own_arm:
|
||||
# Hair with its own skeleton already has correct vertex
|
||||
# groups for its hair bones; body weight transfer would
|
||||
# destroy them.
|
||||
print(f"Skipped weight transfer for {name}: has own skeleton")
|
||||
else:
|
||||
clothing.vertex_groups.clear()
|
||||
dt = clothing.modifiers.new(name="WT", type='DATA_TRANSFER')
|
||||
dt.object = source_mesh
|
||||
dt.use_vert_data = True
|
||||
dt.data_types_verts = {'VGROUP_WEIGHTS'}
|
||||
dt.layers_vgroup_select_src = 'ALL'
|
||||
dt.vert_mapping = 'POLYINTERP_NEAREST'
|
||||
|
||||
bpy.context.view_layer.objects.active = clothing
|
||||
# "Generate Data Layers" step
|
||||
bpy.ops.object.datalayout_transfer(modifier=dt.name)
|
||||
bpy.ops.object.modifier_apply(modifier=dt.name)
|
||||
|
||||
remove_empty_vertex_groups(clothing, threshold=0.001)
|
||||
|
||||
# 7. Final Parenting
|
||||
clothing.parent = rig
|
||||
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
|
||||
arm_mod.object = rig
|
||||
if has_own_arm:
|
||||
# Hair with its own skeleton: keep the existing Armature
|
||||
# modifier (which points to the hair skeleton). Just
|
||||
# parent to the hair armature; do NOT add a body
|
||||
# Armature modifier.
|
||||
clothing.parent = own_arm
|
||||
print(f"Parented {name} to hair armature '{own_arm_name}'")
|
||||
else:
|
||||
clothing.parent = rig
|
||||
arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE')
|
||||
arm_mod.object = rig
|
||||
|
||||
# 8. Cleanup Reference Mesh (keep the Rig!)
|
||||
bpy.data.objects.remove(source_mesh, do_unlink=True)
|
||||
@@ -147,4 +208,3 @@ def process_batch():
|
||||
|
||||
if __name__ == "__main__":
|
||||
process_batch()
|
||||
|
||||
|
||||
@@ -248,6 +248,27 @@ for mapping in[CommandLineMapping()]:
|
||||
discovered.append(ob.name)
|
||||
mapping.objs += discovered
|
||||
print(f"Auto-discovered {len(discovered)} body part objects: {discovered}")
|
||||
|
||||
# ---- Separate hair with its own skeleton ----
|
||||
hair_own_skel = []
|
||||
hair_own_skel_objs = [] # save object references
|
||||
for name in list(mapping.objs):
|
||||
obj = bpy.data.objects.get(name)
|
||||
if obj:
|
||||
has_it = obj.get("has_own_armature", False)
|
||||
own_arm = obj.get("own_armature_name", "")
|
||||
print(f" Object '{name}': has_own_armature="
|
||||
f"{has_it}, own_armature_name='{own_arm}'")
|
||||
if has_it:
|
||||
hair_own_skel.append(name)
|
||||
hair_own_skel_objs.append(obj)
|
||||
mapping.objs.remove(name)
|
||||
if hair_own_skel:
|
||||
print(f"Hair with own skeleton (exported separately): "
|
||||
f"{hair_own_skel}")
|
||||
else:
|
||||
print(f"No hair with own skeleton detected")
|
||||
# ---------------------------------------------
|
||||
else:
|
||||
bpy.ops.wm.append(
|
||||
filepath=os.path.join(mapping.blend_path, mapping.inner_path),
|
||||
@@ -266,6 +287,8 @@ for mapping in[CommandLineMapping()]:
|
||||
bpy.data.objects.remove(ob)
|
||||
# Remove auto-discovered objects that weren't meant for export
|
||||
elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs:
|
||||
if ob.get("has_own_armature", False):
|
||||
continue # exported separately, don't rename
|
||||
if not ob.name.endswith("-noimp"):
|
||||
ob.name = ob.name + "-noimp"
|
||||
|
||||
@@ -294,11 +317,28 @@ for mapping in[CommandLineMapping()]:
|
||||
bpy.data.actions.remove(act)
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == 'MESH':
|
||||
# Skip hair-with-own-skeleton objects — they're
|
||||
# exported separately and must not be renamed
|
||||
if obj.get("has_own_armature", False):
|
||||
continue
|
||||
if not obj.name in mapping.objs and obj.parent is None:
|
||||
if not obj.name.endswith("-noimp"):
|
||||
obj.name = obj.name + "-noimp"
|
||||
bpy.ops.wm.save_as_mainfile(filepath=(basepath + "/assets/blender/scripts/" + mapping.outfile))
|
||||
|
||||
# Hide hair-with-own-skeleton objects from the body glTF export.
|
||||
# The glTF exporter crashes when sampling animations for hair armatures
|
||||
# that are not part of the main rig, so we exclude them here and export
|
||||
# hair separately via OGRE later.
|
||||
for obj in hair_own_skel_objs:
|
||||
obj.hide_viewport = True
|
||||
obj.hide_render = True
|
||||
arm_name = obj.get("own_armature_name", "")
|
||||
hair_arm = bpy.data.objects.get(arm_name)
|
||||
if hair_arm:
|
||||
hair_arm.hide_viewport = True
|
||||
hair_arm.hide_render = True
|
||||
|
||||
os.makedirs(os.path.dirname(mapping.gltf_path), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(filepath=mapping.gltf_path,
|
||||
use_selection=False,
|
||||
@@ -317,7 +357,7 @@ for mapping in[CommandLineMapping()]:
|
||||
use_mesh_edges=False,
|
||||
use_mesh_vertices=False,
|
||||
export_cameras=False,
|
||||
use_visible=False,
|
||||
use_visible=True,
|
||||
use_renderable=False,
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
@@ -331,6 +371,16 @@ for mapping in[CommandLineMapping()]:
|
||||
export_lights=False,
|
||||
export_skins=True)
|
||||
print("exported to: " + mapping.gltf_path)
|
||||
|
||||
# Unhide hair objects for OGRE export
|
||||
for obj in hair_own_skel_objs:
|
||||
obj.hide_viewport = False
|
||||
obj.hide_render = False
|
||||
arm_name = obj.get("own_armature_name", "")
|
||||
hair_arm = bpy.data.objects.get(arm_name)
|
||||
if hair_arm:
|
||||
hair_arm.hide_viewport = False
|
||||
hair_arm.hide_render = False
|
||||
obj_names = mapping.objs
|
||||
prefix = mapping.armature_name + "_"
|
||||
|
||||
@@ -556,6 +606,115 @@ for mapping in[CommandLineMapping()]:
|
||||
else:
|
||||
print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}")
|
||||
|
||||
# ---- Export hair with own skeleton ----
|
||||
print(f"\n=== HAIR DEBUG: auto_discover={mapping.auto_discover} "
|
||||
f"hair_own_skel={hair_own_skel}")
|
||||
if mapping.auto_discover and hair_own_skel:
|
||||
# Collect hair meshes grouped by their armature name
|
||||
hair_by_arm = {}
|
||||
for obj in hair_own_skel_objs:
|
||||
arm_name = obj.get("own_armature_name", "")
|
||||
print(f" HAIR DEBUG: obj '{obj.name}' "
|
||||
f"own_armature_name='{arm_name}' "
|
||||
f"has_own_armature={obj.get('has_own_armature', False)}")
|
||||
if arm_name not in hair_by_arm:
|
||||
hair_by_arm[arm_name] = []
|
||||
hair_by_arm[arm_name].append(obj)
|
||||
print(f" HAIR DEBUG: hair_by_arm has {len(hair_by_arm)} groups")
|
||||
|
||||
body_arm_name = mapping.armature_name # save "male"/"female"
|
||||
body_prefix = body_arm_name + "_"
|
||||
|
||||
# Debug: list all armatures in the scene
|
||||
all_arms = [o.name for o in bpy.data.objects
|
||||
if o.type == 'ARMATURE']
|
||||
print(f" HAIR DEBUG: armatures in scene: {all_arms}")
|
||||
|
||||
for arm_name, hair_objs in hair_by_arm.items():
|
||||
hair_arm = bpy.data.objects.get(arm_name)
|
||||
print(f" HAIR DEBUG: arm_name='{arm_name}' "
|
||||
f"hair_arm_found={hair_arm is not None}")
|
||||
if not hair_arm or hair_arm.type != 'ARMATURE':
|
||||
print(f" WARNING: Armature '{arm_name}' not found "
|
||||
f"for hair, skipping")
|
||||
continue
|
||||
|
||||
hair_obj_names = [o.name for o in hair_objs]
|
||||
print(f" Exporting hair with skeleton '{arm_name}': "
|
||||
f"{hair_obj_names}")
|
||||
|
||||
# Sync armature data name
|
||||
hair_arm.data.name = hair_arm.name
|
||||
print(f" Armature data name: '{hair_arm.data.name}'")
|
||||
|
||||
# Write body_part JSON for each hair mesh
|
||||
json_dir = os.path.dirname(mapping.gltf_path)
|
||||
for obj in hair_objs:
|
||||
new_mesh_name = body_prefix + obj.name
|
||||
obj.data.name = new_mesh_name
|
||||
save_data = {
|
||||
"age": obj.get("age", ""),
|
||||
"sex": obj.get("sex", ""),
|
||||
"slot": obj.get("slot", ""),
|
||||
"mesh": obj.data.name + ".mesh",
|
||||
"layer": int(obj.get("layer", 0)),
|
||||
"garments": [],
|
||||
"tags": [],
|
||||
"shape_keys": [],
|
||||
"own_skeleton": True,
|
||||
"attach_to_bone": "mixamorig:Head"
|
||||
}
|
||||
garments = obj.get("garments", "")
|
||||
if garments:
|
||||
save_data["garments"] = [
|
||||
g.strip()
|
||||
for g in str(garments).split(";")
|
||||
if g.strip()]
|
||||
clothing_tags = obj.get("clothing_tags", "")
|
||||
if clothing_tags:
|
||||
save_data["tags"] = [
|
||||
t.strip()
|
||||
for t in str(clothing_tags).split(";")
|
||||
if t.strip()]
|
||||
save_file = (json_dir + "/body_part_" +
|
||||
obj.data.name + ".json")
|
||||
with open(save_file, 'w') as f:
|
||||
json.dump(save_data, f, indent=2)
|
||||
print(f" Wrote {save_file}")
|
||||
|
||||
# OGRE export for hair with its own skeleton
|
||||
|
||||
# OGRE export for hair with its own skeleton
|
||||
hair_scene = mapping.gltf_path.replace(
|
||||
".glb", "_hair_" + arm_name + ".scene")
|
||||
bpy.ops.ogre.export(
|
||||
filepath=hair_scene,
|
||||
EX_SELECTED_ONLY=False,
|
||||
EX_SHARED_ARMATURE=True,
|
||||
EX_LOD_GENERATION='0',
|
||||
EX_LOD_DISTANCE=20,
|
||||
EX_LOD_LEVELS=4,
|
||||
EX_GENERATE_TANGENTS='4')
|
||||
print(f" Exported hair scene: {hair_scene}")
|
||||
|
||||
# Fix alpha_rejection in hair .material files
|
||||
_mat_files = _glob.glob(
|
||||
os.path.join(ogre_export_dir, "*.material"))
|
||||
for _mf in _mat_files:
|
||||
with open(_mf, 'r') as _f:
|
||||
_content = _f.read()
|
||||
if 'alpha_rejection' in _content:
|
||||
_new = _re.sub(
|
||||
r'alpha_rejection (\S+) (\d+\.?\d*)',
|
||||
lambda m: 'alpha_rejection ' +
|
||||
m.group(1) + ' ' +
|
||||
str(int(float(m.group(2)))),
|
||||
_content)
|
||||
if _new != _content:
|
||||
with open(_mf, 'w') as _f:
|
||||
_f.write(_new)
|
||||
# -----------------------------------------
|
||||
|
||||
bpy.ops.wm.read_homefile(use_empty=True)
|
||||
time.sleep(2)
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
Reference in New Issue
Block a user