diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index 4485f7c..17e0d74 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -48,12 +48,12 @@ set(VRM_IMPORTED_BLENDS # DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models.py ${VRM_IMPORTED_BLENDS} ${EDITED_BLEND_TARGETS} # WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) set(FEMALE_OBJECTS "BodyTopRobe;BodyTop;BodyBottom;BodyFeet;Hair;Face;BackHair;Accessoty") -set(MALE_OBJECTS "BodyTopRobe;BodyTop;BodyBottomPants;BodyBottom;BodyFeetPants;BodyFeetPantsShoes;BodyFeet;Hair;Face;BackHair;Accessory") +set(MALE_OBJECTS "BodyTopRobe;BodyTop;BodyBottomPants;BodyBottom_Panties001;BodyBottom;BodyFeetPants;BodyFeetPantsShoes;BodyFeet;Hair;Face;BackHair;Accessory") add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/characters/male/normal-male.glb COMMAND ${CMAKE_COMMAND} -E make_directory ${CREATE_DIRECTORIES} COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py -- - ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male.blend + ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend ${CMAKE_BINARY_DIR}/characters/male/normal-male.glb "${MALE_OBJECTS}" "male" @@ -64,7 +64,7 @@ add_custom_command( -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py ${VRM_IMPORTED_BLENDS} - ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male.blend + ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed WORKING_DIRECTORY ${CMAKE_BINARY_DIR} VERBATIM @@ -215,3 +215,200 @@ add_custom_target(edited-blends ALL DEPENDS ${EDITED_BLEND_TARGETS}) add_custom_target(import_vrm DEPENDS ${CHARACTER_GLBS}) +function(weight_clothes SRC) + get_filename_component(TARGET_NAME ${SRC} NAME_WE) + add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.stamp + ${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.blend + COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_CURRENT_BINARY_DIR}/clothes + COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py -- ${CMAKE_CURRENT_SOURCE_DIR}/${TARGET_NAME}.blend ./ ${CMAKE_CURRENT_BINARY_DIR}/clothes + COMMAND ${CMAKE_COMMAND} -E touch ${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.stamp + COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/${TARGET_NAME}_weighted.blend + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake + DEPENDS ${SRC} + ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed + ) +endfunction() +weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-bottom.blend) + +# Function to combine clothes into a blend file +# Parameters: +# INPUT_BLEND - Input blend file (required) +# WEIGHTED_BLEND - Weighted clothes blend file to combine (required) +# COMBINED_BLEND - Output combined blend file (required) +function(add_clothes_combination INPUT_BLEND WEIGHTED_BLEND COMBINED_BLEND) + # Parse optional arguments + set(options "") + set(oneValueArgs OUTPUT_DIR) + set(multiValueArgs "") + cmake_parse_arguments(COMBINE "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + # Validate required arguments + if(NOT INPUT_BLEND) + message(FATAL_ERROR "INPUT_BLEND is required for add_clothes_combination") + endif() + if(NOT WEIGHTED_BLEND) + message(FATAL_ERROR "WEIGHTED_BLEND is required for add_clothes_combination") + endif() + if(NOT COMBINED_BLEND) + message(FATAL_ERROR "COMBINED_BLEND output path is required for add_clothes_combination") + endif() + + # Get the base name from the weighted blend file for stamp file + get_filename_component(WEIGHTED_BLEND_NAME "${WEIGHTED_BLEND}" NAME_WE) + # Remove "_weighted" suffix if present + string(REGEX REPLACE "_weighted$" "" TARGET_BASE "${WEIGHTED_BLEND_NAME}") + + # Set default output directory for stamp if not provided + if(NOT COMBINE_OUTPUT_DIR) + set(COMBINE_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/clothes") + endif() + + # Define stamp file using the derived base name + set(STAMP_FILE "${COMBINE_OUTPUT_DIR}/${TARGET_BASE}-combined.stamp") + + # Derive the weighted stamp dependency + get_filename_component(WEIGHTED_DIR "${WEIGHTED_BLEND}" DIRECTORY) + get_filename_component(WEIGHTED_BASE "${WEIGHTED_BLEND}" NAME_WE) + set(WEIGHTED_STAMP "${WEIGHTED_DIR}/${WEIGHTED_BASE}.stamp") + + # Ensure the output directory for COMBINED_BLEND exists + get_filename_component(COMBINED_DIR "${COMBINED_BLEND}" DIRECTORY) + + add_custom_command( + OUTPUT ${STAMP_FILE} ${COMBINED_BLEND} + COMMAND ${CMAKE_COMMAND} -E make_directory ${COMBINED_DIR} + COMMAND ${BLENDER} -b -Y -P ${CMAKE_CURRENT_SOURCE_DIR}/combine_clothes.py -- + ${INPUT_BLEND} + ${WEIGHTED_BLEND} + ${COMBINED_BLEND} + COMMAND ${CMAKE_COMMAND} -E touch ${STAMP_FILE} + COMMAND ${CMAKE_COMMAND} -D FILE=${COMBINED_BLEND} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake + DEPENDS ${WEIGHTED_STAMP} + ${WEIGHTED_BLEND} + ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed + ${CMAKE_CURRENT_SOURCE_DIR}/combine_clothes.py + COMMENT "Combining clothes from ${WEIGHTED_BLEND} into ${COMBINED_BLEND}" + ) +endfunction() + +# Function to consolidate blend files +# Parameters: +# INPUT_BLEND - Input blend file to consolidate into (required) +# COMBINED_BLEND - Combined blend file to consolidate (required) +# OUTPUT_BLEND - Output consolidated blend file (required) +function(add_blend_consolidation INPUT_BLEND COMBINED_BLEND OUTPUT_BLEND) + # Parse optional arguments + set(options "") + set(oneValueArgs "") + set(multiValueArgs "") + cmake_parse_arguments(CONSOLIDATE "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + # Validate required arguments + if(NOT INPUT_BLEND) + message(FATAL_ERROR "INPUT_BLEND is required for add_blend_consolidation") + endif() + if(NOT COMBINED_BLEND) + message(FATAL_ERROR "COMBINED_BLEND is required for add_blend_consolidation") + endif() + if(NOT OUTPUT_BLEND) + message(FATAL_ERROR "OUTPUT_BLEND output path is required for add_blend_consolidation") + endif() + + # Get the base name from the combined blend file for stamp derivation + get_filename_component(COMBINED_NAME "${COMBINED_BLEND}" NAME_WE) + # Remove "_combined" suffix if present + string(REGEX REPLACE "_combined$" "" TARGET_BASE "${COMBINED_NAME}") + + # Derive stamp dependency + get_filename_component(COMBINED_DIR "${COMBINED_BLEND}" DIRECTORY) + set(COMBINE_STAMP "${COMBINED_DIR}/${TARGET_BASE}-combined.stamp") + + # Ensure output directory exists + get_filename_component(OUTPUT_DIR "${OUTPUT_BLEND}" DIRECTORY) + + add_custom_command( + OUTPUT ${OUTPUT_BLEND} + DEPENDS ${COMBINED_BLEND} + ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py + ${COMBINE_STAMP} + ${INPUT_BLEND} + COMMAND ${CMAKE_COMMAND} -E make_directory ${OUTPUT_DIR} + COMMAND ${BLENDER} -b -Y ${INPUT_BLEND} + -P ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py -- + ${COMBINED_BLEND} + ${OUTPUT_BLEND} + COMMAND ${CMAKE_COMMAND} -D FILE=${OUTPUT_BLEND} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake + COMMENT "Consolidating ${COMBINED_BLEND} into ${OUTPUT_BLEND}" + ) +endfunction() + +# Combined pipeline function +# Parameters: +# INPUT_BLEND - Input blend file (required) +# WEIGHTED_BLEND - Weighted clothes blend file (required) +# FINAL_OUTPUT_BLEND - Final consolidated output (required) +function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND) + # Parse optional arguments + set(options "") + set(oneValueArgs COMBINED_BLEND INTERMEDIATE_DIR) + set(multiValueArgs "") + cmake_parse_arguments(PIPELINE "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + # Validate required arguments + if(NOT INPUT_BLEND) + message(FATAL_ERROR "INPUT_BLEND is required for add_clothes_pipeline") + endif() + if(NOT WEIGHTED_BLEND) + message(FATAL_ERROR "WEIGHTED_BLEND is required for add_clothes_pipeline") + endif() + if(NOT FINAL_OUTPUT_BLEND) + message(FATAL_ERROR "FINAL_OUTPUT_BLEND is required for add_clothes_pipeline") + endif() + + # Get the base name for deriving intermediate filenames + get_filename_component(WEIGHTED_NAME "${WEIGHTED_BLEND}" NAME_WE) + string(REGEX REPLACE "_weighted$" "" TARGET_BASE "${WEIGHTED_NAME}") + + # Set intermediate directory + if(NOT PIPELINE_INTERMEDIATE_DIR) + set(PIPELINE_INTERMEDIATE_DIR "${CMAKE_CURRENT_BINARY_DIR}/clothes") + endif() + + # Define intermediate combined blend file + if(PIPELINE_COMBINED_BLEND) + set(COMBINED_BLEND "${PIPELINE_COMBINED_BLEND}") + else() + set(COMBINED_BLEND "${PIPELINE_INTERMEDIATE_DIR}/${TARGET_BASE}_combined.blend") + endif() + + # Step 1: Combine clothes + add_clothes_combination( + "${INPUT_BLEND}" + "${WEIGHTED_BLEND}" + "${COMBINED_BLEND}" + OUTPUT_DIR "${PIPELINE_INTERMEDIATE_DIR}" + ) + + # Step 2: Consolidate + add_blend_consolidation( + "${INPUT_BLEND}" + "${COMBINED_BLEND}" + "${FINAL_OUTPUT_BLEND}" + ) + + # Create a custom target to drive the whole pipeline + add_custom_target(${TARGET_BASE}_pipeline ALL + DEPENDS ${FINAL_OUTPUT_BLEND} + COMMENT "Running complete clothes pipeline for ${TARGET_BASE}" + ) +endfunction() + +add_clothes_pipeline( + "${CMAKE_CURRENT_SOURCE_DIR}/edited-normal-male.blend" # INPUT_BLEND + "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-bottom_weighted.blend" # WEIGHTED_BLEND + "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend" # FINAL_OUTPUT_BLEND +) + diff --git a/assets/blender/characters/clothes-male-bottom.blend b/assets/blender/characters/clothes-male-bottom.blend new file mode 100644 index 0000000..97853a8 --- /dev/null +++ b/assets/blender/characters/clothes-male-bottom.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:250660db39e86488c68ced32623d8c0ac058a678d2d8d806edb67fd8d7f67e78 +size 1299736 diff --git a/assets/blender/characters/clothes-top.blend b/assets/blender/characters/clothes-top.blend new file mode 100644 index 0000000..f86e008 --- /dev/null +++ b/assets/blender/characters/clothes-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d102f898d9dced6ed8dec6da301618911647a0222f23035db65d6c9efae059d +size 67552448 diff --git a/assets/blender/characters/combine_clothes.py b/assets/blender/characters/combine_clothes.py new file mode 100644 index 0000000..e857b70 --- /dev/null +++ b/assets/blender/characters/combine_clothes.py @@ -0,0 +1,150 @@ +import bpy +import bmesh +import sys +import os +import mathutils +from mathutils.bvhtree import BVHTree + +def run_batch_combine(): + try: + args = sys.argv[sys.argv.index("--") + 1:] + body_blend_path, clothes_blend_path, output_path = args[0], args[1], args[2] + except (ValueError, IndexError): + print("Usage: blender -b -P script.py -- ") + return + + bpy.ops.wm.read_homefile(use_empty=True) + + # 1. Append Files + for path in [clothes_blend_path, body_blend_path]: + with bpy.data.libraries.load(path) as (data_from, data_to): + data_to.objects = data_from.objects + for obj in data_to.objects: + if obj: bpy.context.collection.objects.link(obj) + + all_objs = bpy.data.objects + clothing_meshes = [o for o in all_objs if o.type == 'MESH' and "ref_part" in o] + whitelist = set() + + for cloth in clothing_meshes: + target_name = cloth["ref_part"] + target_body = all_objs.get(target_name) + if not target_body: continue + + # --- STEP A: RAYCAST & INITIAL HIT LIST --- + depsgraph = bpy.context.evaluated_depsgraph_get() + cloth_eval = cloth.evaluated_get(depsgraph) + bvh_cloth = BVHTree.FromObject(cloth_eval, depsgraph) + + m_body = target_body.matrix_world + m_body_inv = m_body.inverted() + m_body_normal = m_body.to_3x3().inverted().transposed() + + num_verts = len(target_body.data.vertices) + hit_values = [0] * num_verts + has_shape_keys = target_body.data.shape_keys is not None + + for i, v in enumerate(target_body.data.vertices): + v_world = m_body @ v.co + n_world = (m_body_normal @ v.normal).normalized() + hit_f, _, _, _ = bvh_cloth.ray_cast(v_world, n_world, 0.015) + hit_b, _, _, _ = bvh_cloth.ray_cast(v_world, -n_world, 0.005) + + if hit_f or hit_b: + hit_values[i] = 1 + offset = -n_world * (0.005 if hit_f else 0.01) + new_co = m_body_inv @ (v_world + offset) + v.co = new_co + if has_shape_keys: + for kb in target_body.data.shape_keys.key_blocks: + kb.data[i].co = new_co + + # --- STEP B: MULTI-LAYER PROTECTION & DELETE --- + bm = bmesh.new() + bm.from_mesh(target_body.data) + bm.verts.ensure_lookup_table() + + # Phase 1: Identify "Layer 1 Border" (Immediate neighbors of visible verts) + border_l1 = set() + for v in bm.verts: + if hit_values[v.index] == 0: + for edge in v.link_edges: + neighbor = edge.other_vert(v) + if hit_values[neighbor.index] == 1: + border_l1.add(neighbor.index) + + # Phase 2: Identify "Layer 2 Buffer" (Neighbors of Layer 1) + border_l2 = set() + for idx in border_l1: + v = bm.verts[idx] + for edge in v.link_edges: + neighbor = edge.other_vert(v) + if hit_values[neighbor.index] == 1: + border_l2.add(neighbor.index) + + # Merge all protected vertices (Layer 0 (visible) + Layer 1 + Layer 2) + protected_indices = set(border_l1) | set(border_l2) + for i, val in enumerate(hit_values): + if val == 0: protected_indices.add(i) + + # Deletion logic + to_delete = [] + THRESHOLD = 4.0 # Higher threshold for deep cleaning + + for v in bm.verts: + if v.index in protected_indices: + continue + + # Sum hits of neighbors + neighbor_hit_sum = hit_values[v.index] + for edge in v.link_edges: + neighbor = edge.other_vert(v) + neighbor_hit_sum += hit_values[neighbor.index] + + if neighbor_hit_sum >= THRESHOLD: + to_delete.append(v) + elif len(v.link_edges) == 1: + to_delete.append(v) + + bmesh.ops.delete(bm, geom=to_delete, context='VERTS') + bm.to_mesh(target_body.data) + bm.free() + target_body.data.update() + + # --- STEP C: ARMATURE & JOIN --- + master_arm = target_body.parent if (target_body.parent and target_body.parent.type == 'ARMATURE') else None + if master_arm: whitelist.add(master_arm) + + cloth_label, body_label = cloth.name, target_body.name + + if cloth.parent and cloth.parent.type == 'ARMATURE': + old_arm = cloth.parent + cloth.matrix_world = cloth.matrix_world.copy() + cloth.parent = None + if old_arm not in whitelist: bpy.data.objects.remove(old_arm, do_unlink=True) + + if master_arm: + cloth.parent = master_arm + for mod in cloth.modifiers: + if mod.type == 'ARMATURE': mod.object = master_arm + + bpy.ops.object.select_all(action='DESELECT') + cloth.select_set(True) + target_body.select_set(True) + bpy.context.view_layer.objects.active = target_body + bpy.ops.object.join() + + target_body.name = f"{body_label}_{cloth_label}" + whitelist.add(target_body) + + # 4. Final Cleanup + for obj in bpy.data.objects[:]: + if obj.type in {'MESH', 'ARMATURE'} and obj not in whitelist: + bpy.data.objects.remove(obj, do_unlink=True) + + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + bpy.ops.wm.save_as_mainfile(filepath=output_path) + +if __name__ == "__main__": + run_batch_combine() + diff --git a/assets/blender/characters/consolidate.py b/assets/blender/characters/consolidate.py new file mode 100644 index 0000000..1bffd3c --- /dev/null +++ b/assets/blender/characters/consolidate.py @@ -0,0 +1,71 @@ +import bpy +import sys +import os + +def process_append(source_files, output_path): + required_props = {"age", "sex", "slot"} + + for file_path in source_files: + if not os.path.exists(file_path): + continue + + with bpy.data.libraries.load(file_path) as (data_from, data_to): + data_to.objects = data_from.objects + + for obj in data_to.objects: + if obj is None: continue + + # Check criteria + 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 + bpy.context.collection.objects.link(obj) + + # 2. Synchronize Names + obj.data.name = obj.name + + # 3. Find Target 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 + 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) + + # B. Parent to Armature + obj.parent = arm_obj + + # C. Handle Armature Modifier + 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}") + 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) + + # Save + bpy.ops.wm.save_as_mainfile(filepath=output_path) + +if __name__ == "__main__": + try: + args = sys.argv[sys.argv.index("--") + 1:] + if len(args) >= 2: + *sources, output = args + process_append(sources, output) + except ValueError: + print("Error: Use '--' to separate Blender args from script args.") + diff --git a/assets/blender/characters/edited-normal-male.blend b/assets/blender/characters/edited-normal-male.blend index 2296327..beece4e 100644 --- a/assets/blender/characters/edited-normal-male.blend +++ b/assets/blender/characters/edited-normal-male.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aeade10e4eff3dd13f78174bceb3317de312cbe7db6f36444bce25f85ce07328 -size 14308944 +oid sha256:7bdc009dba953dca582555c93115b72b2beec8200ae5d32f19dbdc928c9c0082 +size 14333594 diff --git a/assets/blender/characters/fix_parts.py b/assets/blender/characters/fix_parts.py new file mode 100644 index 0000000..ea802ab --- /dev/null +++ b/assets/blender/characters/fix_parts.py @@ -0,0 +1,53 @@ +import bpy + +def transfer_body_data(): + source_name = "Body" + target_names = ["BodyTop", "bodyBottom", "BodyFeet"] + + source_obj = bpy.data.objects.get(source_name) + if not source_obj: + print(f"Source object '{source_name}' not found!") + return + + for name in target_names: + target_obj = bpy.data.objects.get(name) + if not target_obj: + print(f"Target '{name}' not found, skipping...") + continue + + # 1. FIX NORMALS & WEIGHTS (Data Transfer) + # Required for Blender 4.1+: Enable Auto Smooth (Smooth by Angle) + target_obj.data.use_auto_smooth = True + + dt_mod = target_obj.modifiers.new(name="TR_DATA", type='DATA_TRANSFER') + dt_mod.object = source_obj + + # Transfer Vertex Groups (Weights) + dt_mod.use_vert_data = True + dt_mod.data_types_verts = {'VGROUP_WEIGHTS'} + dt_mod.vert_mapping = 'NEAREST' + + # Transfer Normals (Fixes Seams) + dt_mod.use_loop_data = True + dt_mod.data_types_loops = {'CUSTOM_NORMAL'} + dt_mod.loop_mapping = 'NEAREST_POLYNOR' + + # Apply to bake the weights and normals + bpy.context.view_layer.objects.active = target_obj + bpy.ops.object.modifier_apply(modifier=dt_mod.name) + + # 2. TRANSFER SHAPE KEYS + if source_obj.data.shape_keys: + # Deselect all, then select source then target + bpy.ops.object.select_all(action='DESELECT') + source_obj.select_set(True) + target_obj.select_set(True) + bpy.context.view_layer.objects.active = target_obj + + # Joins shape keys from source to target + bpy.ops.object.shape_key_transfer() + + print("Transfer complete: Normals, Weights, and Shape Keys synced.") + +transfer_body_data() + diff --git a/assets/blender/characters/fix_parts2.py b/assets/blender/characters/fix_parts2.py new file mode 100644 index 0000000..99211f1 --- /dev/null +++ b/assets/blender/characters/fix_parts2.py @@ -0,0 +1,53 @@ +import bpy + +def fix_seams_and_transfer_data(): + source_name = "Body" + target_names = ["BodyTop", "bodyBottom", "BodyFeet"] + + source_obj = bpy.data.objects.get(source_name) + if not source_obj: + print(f"Error: '{source_name}' not found.") + return + + for t_name in target_names: + target_obj = bpy.data.objects.get(t_name) + if not target_obj: + continue + + # 1. PREP TARGET + # In 3.6, Auto Smooth must be True to see custom normals + target_obj.data.use_auto_smooth = True + bpy.context.view_layer.objects.active = target_obj + + # 2. TRANSFER WEIGHTS & NORMALS (Data Transfer Mod) + dt_mod = target_obj.modifiers.new(name="SeamFix", type='DATA_TRANSFER') + dt_mod.object = source_obj + + # Vertex Data (Weights) + dt_mod.use_vert_data = True + dt_mod.data_types_verts = {'VGROUP_WEIGHTS'} + + # Face Corner Data (Normals) + dt_mod.use_loop_data = True + dt_mod.data_types_loops = {'CUSTOM_NORMAL'} + dt_mod.loop_mapping = 'NEAREST_POLYNOR' + + # Apply the modifier to bake the data + bpy.ops.object.modifier_apply(modifier=dt_mod.name) + + # 3. TRANSFER SHAPE KEYS + if source_obj.data.shape_keys: + # Clear selection and set up: Source must be Active, Target Selected + bpy.ops.object.select_all(action='DESELECT') + target_obj.select_set(True) + source_obj.select_set(True) + bpy.context.view_layer.objects.active = source_obj + + # Transfer shape keys based on vertex position + # Note: This creates keys on the Target object + bpy.ops.object.shape_key_transfer() + + print("Process complete for Blender 3.6.") + +fix_seams_and_transfer_data() + diff --git a/assets/blender/characters/optimize_meshes.py b/assets/blender/characters/optimize_meshes.py new file mode 100644 index 0000000..0045625 --- /dev/null +++ b/assets/blender/characters/optimize_meshes.py @@ -0,0 +1,54 @@ +import bpy + +def optimize_mesh_for_ogre(obj): + if obj.type != 'MESH': + return + print(obj.type) + + print(f"Starting with {obj.name}") + # Set as active and enter Weight Paint mode to use ops + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='WEIGHT_PAINT') + + # 1. Clean zero weights (below 0.001) + bpy.ops.object.vertex_group_clean(group_select_mode='ALL', limit=0.001) + + # 2. Limit influences to 4 per vertex (Ogre/GPU standard) + bpy.ops.object.vertex_group_limit_total(limit=4) + + # 3. Normalize all weights (Sum of all bone influences = 1.0) + # This prevents "multiplied" or "weak" movements in engine + bpy.ops.object.vertex_group_normalize_all() + + bpy.ops.object.mode_set(mode='OBJECT') + + # 4. Remove empty Vertex Groups (groups with no assigned vertices) + # This keeps the bone index list identical across modular parts + vgroup_indices_to_remove = [] + for i, group in enumerate(obj.vertex_groups): + has_vertices = False + for v in obj.data.vertices: + for g in v.groups: + if g.group == i and g.weight > 0.001: + has_vertices = True + break + if has_vertices: break + + if not has_vertices: + print("removed group:" + group.name) + vgroup_indices_to_remove.append(group.name) + + for name in vgroup_indices_to_remove: + print("removing group: " + name) + obj.vertex_groups.remove(obj.vertex_groups.get(name)) + + print(f"Finished {obj.name}: 4-weight limit applied, Normalized, {len(vgroup_indices_to_remove)} empty groups removed.") + +# Execute on all selected mesh objects +selected_meshes = [o for o in bpy.data.objects if o.type == 'MESH' and not o.name.startswith("cs_")] +if not selected_meshes: + print("No mesh objects selected.") +else: + for mesh_obj in selected_meshes: + optimize_mesh_for_ogre(mesh_obj) + diff --git a/assets/blender/characters/process_clothes.py b/assets/blender/characters/process_clothes.py new file mode 100644 index 0000000..fc5a176 --- /dev/null +++ b/assets/blender/characters/process_clothes.py @@ -0,0 +1,146 @@ +import bpy +import os +import sys + +def clean_scene(): + bpy.ops.object.select_all(action='SELECT') + bpy.ops.object.delete() + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + +def remove_empty_vertex_groups(obj, threshold=0.001): + """Removes vertex groups with no weights or weights below threshold.""" + if obj.type != 'MESH': + return + + # Ensure we are in object mode + bpy.context.view_layer.objects.active = obj + + # Dictionary to track max weight per group + group_max_weights = {g.index: 0.0 for g in obj.vertex_groups} + + # Iterate over vertices to find actual max weights + for v in obj.data.vertices: + for g in v.groups: + if g.group in group_max_weights: + group_max_weights[g.group] = max(group_max_weights[g.group], g.weight) + + # Remove groups that don't meet the threshold + groups_to_remove = [obj.vertex_groups[idx] for idx, max_w in group_max_weights.items() if max_w < threshold] + + for g in groups_to_remove: + obj.vertex_groups.remove(g) + + print(f"Cleaned {len(groups_to_remove)} empty/low-weight groups from {obj.name}") + +def process_batch(): + try: + args = sys.argv[sys.argv.index("--") + 1:] + clothing_path, lib_directory, out_dir = args[0], args[1], args[2] + except (IndexError, ValueError): + print("Usage: blender -b -P script.py -- ") + return + + if not os.path.exists(out_dir): os.makedirs(out_dir) + + # 1. Identify all clothing in the source file + with bpy.data.libraries.load(clothing_path) as (data_from, data_to): + all_clothing_names = data_from.objects + + # Start with a fresh scene + clean_scene() + + for name in all_clothing_names: + # 2. Append the clothing item + with bpy.data.libraries.load(clothing_path) as (data_from, data_to): + data_to.objects = [name] + + for obj in data_to.objects: + if obj: bpy.context.collection.objects.link(obj) + + clothing = bpy.data.objects.get(name) + if not clothing or clothing.type != 'MESH': continue + + # Get properties + sex = clothing.get("ref_sex") + age = clothing.get("ref_age") + ref_mesh_name = clothing.get("ref_clothing") + + if not all([sex, age, ref_mesh_name]): + print(f"Skipping {name}: Missing properties") + continue + + # 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) + + if not os.path.exists(target_lib_path): + if target_lib_name == "normal_adult_male.blend": + target_lib_name = "edited-normal-male.blend" + elif target_lib_name == "normal_adult_female.blend": + target_lib_name = "edited-normal-female.blend" + target_lib_path = os.path.join(lib_directory, target_lib_name) + if not os.path.exists(target_lib_path): + print(f"Error: Library {target_lib_path} not found") + continue + + # 4. Append Weights Source and Rig + with bpy.data.libraries.load(target_lib_path) as (data_from, data_to): + data_to.objects = [ref_mesh_name, rig_name] + + for obj in data_to.objects: + if obj: bpy.context.collection.objects.link(obj) + + source_mesh = bpy.data.objects.get(ref_mesh_name) + rig = bpy.data.objects.get(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]: + 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') + + # 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) + + # 7. Final Parenting + 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) + print(f"Processed: {name}") + + # 9. Save as single file named after source + _weighted + source_filename = os.path.splitext(os.path.basename(clothing_path))[0] + final_save_path = os.path.join(out_dir, f"{source_filename}_weighted.blend") + + # Purge any remaining junk before final save + bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) + bpy.ops.wm.save_as_mainfile(filepath=final_save_path) + print(f"\n--- ALL DONE ---") + print(f"Saved to: {final_save_path}") + +if __name__ == "__main__": + process_batch() + diff --git a/assets/blender/scripts/export_buildings2.py b/assets/blender/scripts/export_buildings2.py new file mode 100644 index 0000000..db0bddc --- /dev/null +++ b/assets/blender/scripts/export_buildings2.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python + +import os, sys, time +import bpy +from math import pi +import glob +import shutil +from mathutils import Vector, Matrix +from math import radians, pi + +argv = sys.argv +argv = argv[argv.index("--") + 1:] + +incpath = os.path.dirname(__file__) + +sys.path.insert(0, incpath) +sys.path.insert(1, incpath + "/blender2ogre") +print(sys.path) + +import io_ogre +io_ogre.register() + +gltf_file = argv[0] +print("Exporting to " + gltf_file) +basepath = os.getcwd() +# bpy.ops.export_scene.gltf(filepath="", check_existing=True, +# export_import_convert_lighting_mode='SPEC', gltf_export_id="", +# export_format='GLB', ui_tab='GENERAL', export_copyright="", export_image_format='AUTO', +# export_texture_dir="", export_jpeg_quality=75, export_keep_originals=False, +# export_texcoords=True, export_normals=True, export_draco_mesh_compression_enable=False, +# export_draco_mesh_compression_level=6, export_draco_position_quantization=14, +# export_draco_normal_quantization=10, export_draco_texcoord_quantization=12, +# export_draco_color_quantization=10, export_draco_generic_quantization=12, export_tangents=False, +# export_materials='EXPORT', export_original_specular=False, export_colors=True, +# export_attributes=False, use_mesh_edges=False, use_mesh_vertices=False, export_cameras=False, +# use_selection=False, use_visible=False, use_renderable=False, +# use_active_collection_with_nested=True, use_active_collection=False, use_active_scene=False, +# export_extras=False, export_yup=True, export_apply=False, export_animations=True, +# export_frame_range=False, export_frame_step=1, export_force_sampling=True, export_animation_mode='ACTIONS', +# export_nla_strips_merged_animation_name="Animation", export_def_bones=False, +# export_hierarchy_flatten_bones=False, export_optimize_animation_size=True, +# export_optimize_animation_keep_anim_armature=True, export_optimize_animation_keep_anim_object=False, +# export_negative_frame='SLIDE', export_anim_slide_to_zero=False, export_bake_animation=False, +# export_anim_single_armature=True, export_reset_pose_bones=True, export_current_frame=False, +# export_rest_position_armature=True, export_anim_scene_split_object=True, export_skins=True, +# export_all_influences=False, export_morph=True, export_morph_normal=True, +# export_morph_tangent=False, export_morph_animation=True, export_morph_reset_sk_data=True, +# export_lights=False, export_nla_strips=True, will_save_settings=False, filter_glob="*.glb") + +for obj in bpy.data.objects: + if obj.name.endswith("-col"): + bpy.data.objects.remove(obj) + if (obj.rigid_body): + print(obj.rigid_body.collision_shape) + +scene_file = gltf_file.replace(".glb", "").replace(".gltf", "") + ".scene" +bpy.ops.ogre.export(filepath=scene_file, + EX_SWAP_AXIS='xz-y', + EX_V2_MESH_TOOL_VERSION='v2', + EX_EXPORT_XML_DELETE=True, + EX_SCENE=True, + EX_SELECTED_ONLY=False, + EX_EXPORT_HIDDEN=False, + EX_FORCE_CAMERA=False, + EX_FORCE_LIGHTS=False, + EX_NODE_ANIMATION=True, + EX_MATERIALS=True, + EX_SEPARATE_MATERIALS=True, + EX_COPY_SHADER_PROGRAMS=True, + EX_MESH=True, + EX_LOD_LEVELS=3, + EX_LOD_DISTANCE=100, + EX_LOD_PERCENT=40 +) + + +bpy.ops.wm.read_homefile(use_empty=True) +time.sleep(2) +bpy.ops.wm.quit_blender() diff --git a/src/gamedata/CharacterAnimationModule.cpp b/src/gamedata/CharacterAnimationModule.cpp index 66d6870..ef47181 100644 --- a/src/gamedata/CharacterAnimationModule.cpp +++ b/src/gamedata/CharacterAnimationModule.cpp @@ -203,8 +203,10 @@ CharacterAnimationModule::CharacterAnimationModule(flecs::world &ecs) Ogre::Vector3 pos = ch.mBodyNode->getPosition(); Ogre::Vector3 boneMotion = ch.mBoneMotion; v.velocity = Ogre::Vector3::ZERO; + if (eng.delta <= 0.005) + return; float safeDelta = - Ogre::Math::Clamp(eng.delta, 0.001f, 0.99f); + Ogre::Math::Clamp(eng.delta, 0.005f, 0.99f); #if 0 if (!e.has()) { v.velocity = Ogre::Math::lerp(