From 765dffbed039926f9713551639ffa9482e574920 Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sat, 30 May 2026 20:04:07 +0300 Subject: [PATCH] Clothes and hairs --- assets/blender/characters/CMakeLists.txt | 90 ++++++++++--------- .../characters/clothes-female-hair.blend | 3 + .../characters/clothes-male-bottom.blend | 4 +- .../characters/clothes-male-hair.blend | 3 + assets/blender/characters/combine_clothes.py | 33 +++++++ assets/blender/characters/consolidate.py | 47 ++++++++-- .../characters/edited-normal-female.blend | 4 +- .../characters/edited-normal-male.blend | 4 +- assets/blender/scripts/export_models2.py | 89 +++++++++++++++++- .../editScene/components/CharacterSlots.hpp | 3 +- .../editScene/systems/CharacterRegistry.cpp | 5 ++ .../editScene/systems/CharacterSlotSystem.cpp | 58 +++++++++--- .../editScene/systems/SceneSerializer.cpp | 4 + .../editScene/ui/CharacterSlotsEditor.cpp | 56 ++++++++++++ 14 files changed, 331 insertions(+), 72 deletions(-) create mode 100644 assets/blender/characters/clothes-female-hair.blend create mode 100644 assets/blender/characters/clothes-male-hair.blend diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index b942b50..aa30c85 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -62,6 +62,7 @@ add_custom_command( DEPENDS ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py ${VRM_IMPORTED_BLENDS} ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend +# ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-hair.stamp ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed WORKING_DIRECTORY ${CMAKE_BINARY_DIR} VERBATIM @@ -418,12 +419,28 @@ function(add_clothes_pipeline INPUT_BLEND WEIGHTED_BLEND FINAL_OUTPUT_BLEND) ) endfunction() -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-top.blend) -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-bottom.blend) -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-male-feet.blend) -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-top.blend) -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-bottom.blend) -weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-female-feet.blend) +set(SEX_LIST "male" "female") +foreach(SEX ${SEX_LIST}) + set(HAIR_WEIGHTED_STAMP "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.stamp") + add_custom_command( + OUTPUT ${HAIR_WEIGHTED_STAMP} + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-hair.blend + ${CMAKE_CURRENT_SOURCE_DIR}/process_clothes.py + ${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed + 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}/clothes-${SEX}-hair.blend + ./ ${CMAKE_CURRENT_BINARY_DIR}/clothes + COMMAND ${CMAKE_COMMAND} -E touch ${HAIR_WEIGHTED_STAMP} + COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-${SEX}-hair_weighted.blend + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake + COMMENT "Processing hair meshes (weight transfer)" + ) + + weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-top.blend) + weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-bottom.blend) + weight_clothes(${CMAKE_CURRENT_SOURCE_DIR}/clothes-${SEX}-feet.blend) +endforeach() # Propagate missing shape keys (like "fat") from Body_shapes to base body parts. # This ensures all body part meshes have consistent shape keys, preventing seams. @@ -452,40 +469,31 @@ add_shape_key_propagation( ) # male -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-shapes.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-bottom_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # BOTTOM_OUTPUT_BLEND -) - -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-bottom.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-top_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # FINAL_OUTPUT_BLEND -) - -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated-top.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-male-feet_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-male-consolidated.blend" # FINAL_OUTPUT_BLEND -) +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}" + ) + set(SLOT_INPUT ${SLOT_OUTPUT}) +endforeach() # female -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-shapes.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-bottom_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # FINAL_OUTPUT_BLEND -) - -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-bottom.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-top_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # FINAL_OUTPUT_BLEND -) - -add_clothes_pipeline( - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated-top.blend" # INPUT_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/clothes/clothes-female-feet_weighted.blend" # WEIGHTED_BLEND - "${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend" # FINAL_OUTPUT_BLEND -) - +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}) +endforeach() diff --git a/assets/blender/characters/clothes-female-hair.blend b/assets/blender/characters/clothes-female-hair.blend new file mode 100644 index 0000000..bbd8329 --- /dev/null +++ b/assets/blender/characters/clothes-female-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0aa11db762f9f6974949e4cc579f66c211d7fc913def2c77b71a0ad9199f7bc +size 4132304 diff --git a/assets/blender/characters/clothes-male-bottom.blend b/assets/blender/characters/clothes-male-bottom.blend index 6b5a068..d064993 100644 --- a/assets/blender/characters/clothes-male-bottom.blend +++ b/assets/blender/characters/clothes-male-bottom.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c04c45ba4de84bdecb55580eecacfe7d8deade014e4112102c3a36147cf26dd6 -size 1434352 +oid sha256:950298780654bd908c15045d8afe572de755ddb1aa38e548c1dfb912b1cc059c +size 1401408 diff --git a/assets/blender/characters/clothes-male-hair.blend b/assets/blender/characters/clothes-male-hair.blend new file mode 100644 index 0000000..e6941b1 --- /dev/null +++ b/assets/blender/characters/clothes-male-hair.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0402b6bc9a2749bc5eb6ce4d932f719b24c0843654edcb08824c302753784173 +size 865899 diff --git a/assets/blender/characters/combine_clothes.py b/assets/blender/characters/combine_clothes.py index 323a908..91cf204 100644 --- a/assets/blender/characters/combine_clothes.py +++ b/assets/blender/characters/combine_clothes.py @@ -203,8 +203,41 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= clothing_obj.select_set(True) new_target.select_set(True) bpy.context.view_layer.objects.active = new_target + + # Remember clothing's material BEFORE join so we can verify + # after Blender potentially remaps faces to a stale copy. + clothing_mat = clothing_obj.active_material 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 + for i, slot in enumerate(new_target.material_slots): + if slot.material == clothing_mat: + clothing_slot = i + elif (slot.material and + slot.material != clothing_mat and + slot.material.name == clothing_mat.name): + # Same name, different data block -> stale + stale_slot = i + if clothing_slot is not None and stale_slot is not None: + mesh = new_target.data + fixed = 0 + for poly in mesh.polygons: + if poly.material_index == stale_slot: + poly.material_index = clothing_slot + fixed += 1 + if fixed: + print(f" Fixed {fixed} faces: stale mat slot " + 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 for prop_name, prop_value in clothing_props.items(): diff --git a/assets/blender/characters/consolidate.py b/assets/blender/characters/consolidate.py index b6e64fd..89f78e0 100644 --- a/assets/blender/characters/consolidate.py +++ b/assets/blender/characters/consolidate.py @@ -10,27 +10,57 @@ from transfer_shape_keys import fix_seams_across_objects, fix_normals_across_obj 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): 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. + with bpy.data.libraries.load(file_path, link=False) as (data_from, data_to): + data_to.materials = data_from.materials + imported_materials = {} + for mat in data_to.materials: + if mat is None: + continue + existing = bpy.data.materials.get(mat.name) + if existing and existing != mat: + print(f" Material '{mat.name}' already exists with " + 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 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) - + + # ---- Remap material slots to imported versions ---- + for slot in obj.material_slots: + if slot.material and slot.material.name in imported_materials: + imported = imported_materials[slot.material.name] + if imported != slot.material: + print(f" Remapping slot " + f"'{slot.material.name}' -> " + f"'{imported.name}' on '{obj.name}'") + slot.material = imported + # --------------------------------------------------- + # 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) @@ -40,19 +70,19 @@ def process_append(source_files, output_path): # 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: @@ -90,4 +120,3 @@ if __name__ == "__main__": process_append(sources, output) except ValueError: print("Error: Use '--' to separate Blender args from script args.") - diff --git a/assets/blender/characters/edited-normal-female.blend b/assets/blender/characters/edited-normal-female.blend index 2c66b3f..0646652 100644 --- a/assets/blender/characters/edited-normal-female.blend +++ b/assets/blender/characters/edited-normal-female.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24b51f926ef0407e7ef5cc8f51f745f5b7f3cd00dd0b911908748f216617c582 -size 19202013 +oid sha256:530457a55e3a1be1100e7ad2349c6c7268356074371b05b7a428d413e61ce849 +size 19308281 diff --git a/assets/blender/characters/edited-normal-male.blend b/assets/blender/characters/edited-normal-male.blend index 435079d..adc543d 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:406b5f878469fe8cb66d50825c7d3c0d1a6561b976bc5a6da6f99c64b2709768 -size 14736682 +oid sha256:3e555a3e665b49e908aa6eaf3df01dcd3f89798f9d8a11ec0498614266bd0e04 +size 14749883 diff --git a/assets/blender/scripts/export_models2.py b/assets/blender/scripts/export_models2.py index 2231653..fd69d82 100644 --- a/assets/blender/scripts/export_models2.py +++ b/assets/blender/scripts/export_models2.py @@ -355,10 +355,27 @@ for mapping in[CommandLineMapping()]: for slot in obj.material_slots: if slot.material: mat = slot.material - # 3. Check if material already has the prefix + # 3. Normalize material name: strip Blender auto-suffixes + # (.001,.002 from name collisions, .### from dupes) + # before applying the armature prefix. This prevents + # duplicate .material files with unpredictable names + # (e.g. male_male-clothes-ed.001 vs male_male-clothes-ed) + # that cause submeshes to reference missing materials + # at runtime. + import re + clean = re.sub(r'\.\d{3}$', '', mat.name) + if clean != mat.name: + old = mat.name + mat.name = clean + # If Blender added .001 again because the clean + # name is already taken, keep the suffixed name. + print(f"Normalized material '{old}' -> '{mat.name}'" + f" on object '{name}'") + # 4. Check if material already has the prefix if not mat.name.startswith(prefix): mat.name = prefix + mat.name - print(f"Renamed material '{mat.name}' on object '{name}'") + print(f"Renamed material '{mat.name}'" + f" on object '{name}'") # 3. Export custom properties to json save_data = {} for key in obj.keys(): @@ -446,15 +463,81 @@ for mapping in[CommandLineMapping()]: armobj = bpy.data.objects.get(mapping.armature_name) armobj.data.name = armobj.name + + # ---- Fix: sync Image filepath to Image name ---- + # Blender silently reuses existing Images when appending + # from libraries, so the Image's filepath may still point + # to the old texture even after the user renamed it. + # blender2ogre writes the filepath to .material files, + # so we must update filepath to match the Image name. + import re as _re + seen_imgs = set() + for name in obj_names: + obj = bpy.data.objects.get(name) + if obj and obj.type == 'MESH': + for slot in obj.material_slots: + if slot.material and slot.material.node_tree: + for node in slot.material.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + img = node.image + if img.name not in seen_imgs: + seen_imgs.add(img.name) + # Strip Blender auto-suffix (.NNN) + clean = _re.sub( + r'\.\d{3}$', '', img.name) + # Extract just the filename from + # the current filepath + old_name = os.path.basename( + img.filepath) + if old_name != clean and clean: + # Rebuild filepath with new + # filename + dirpart = os.path.dirname( + img.filepath) + new_path = os.path.join( + dirpart, clean) + print(f" Updating Image " + f"'{img.name}': " + f"filepath " + f"'{old_name}' -> " + f"'{clean}'") + img.filepath = new_path + # --------------------------------------------------- + bpy.ops.ogre.export(filepath=mapping.gltf_path.replace(".glb", ".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') + ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene")) + + # Fix alpha_rejection values in ALL generated .material files. + # blender2ogre 0.9.0 writes float values (e.g. "127.5") for + # alpha_rejection thresholds, but OGRE 14 expects an integer + # in the range 0-255. Float values are truncated to integer + # during material compilation, causing 127.5 -> 127 when 128 + # was intended. This makes submeshes with alpha < 128 in the + # texture atlas completely invisible. + import glob as _glob + _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: + import re as _re + _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) + print(f" Fixed alpha_rejection in {_mf}") + # Post-process exported OGRE meshes to fix normal/tangent seams between # body parts. Even with identical custom normals in Blender, the OGRE # exporter's calc_tangents() produces slightly different tangents for # matching vertices because BodyTop and BodyBottom have different face # topologies. This causes a visible lighting seam with normal mapping, # especially when shape keys (like "fat") are applied. - ogre_export_dir = os.path.dirname(mapping.gltf_path.replace(".glb", ".scene")) fix_script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fix_ogre_mesh_seams.py") if os.path.exists(fix_script): print(f"\nPost-processing OGRE meshes in {ogre_export_dir}...") diff --git a/src/features/editScene/components/CharacterSlots.hpp b/src/features/editScene/components/CharacterSlots.hpp index f212bca..1b6401c 100644 --- a/src/features/editScene/components/CharacterSlots.hpp +++ b/src/features/editScene/components/CharacterSlots.hpp @@ -7,10 +7,11 @@ /** * Selection criteria for a single character slot. - * Layer 0 (nude base) is always implicit. + * Layer 0 (nude base) can be selected via combo box (e.g. hair styles). * Layer 1 and 2 are selected via combo boxes. */ struct SlotSelection { + Ogre::String layer0Mesh; // "none" or exact mesh name for layer 0 Ogre::String layer1Mesh; // "none" or exact mesh name for layer 1 Ogre::String layer2Mesh; // "none" or exact mesh name for layer 2 Ogre::String explicitMesh; // backward-compat override diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index 2e60250..6a3f68d 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -301,6 +301,8 @@ readPrefabAppearance(const std::string &path, CharacterSlotsComponent &cs, for (auto &[slot, selJson] : s["slotSelections"].items()) { SlotSelection sel; + sel.layer0Mesh = + selJson.value("layer0Mesh", ""); sel.layer1Mesh = selJson.value("layer1Mesh", ""); sel.layer2Mesh = @@ -1207,6 +1209,7 @@ nlohmann::json CharacterRegistry::serialize() const nlohmann::json selJson; for (const auto &kv : c.inlineSlotSelections) { nlohmann::json s; + s["layer0Mesh"] = kv.second.layer0Mesh; s["layer1Mesh"] = kv.second.layer1Mesh; s["layer2Mesh"] = kv.second.layer2Mesh; s["explicitMesh"] = kv.second.explicitMesh; @@ -1393,6 +1396,8 @@ void CharacterRegistry::deserialize(const nlohmann::json &j) for (auto &[slot, s] : rec["inlineSlotSelections"].items()) { SlotSelection sel; + sel.layer0Mesh = + s.value("layer0Mesh", ""); sel.layer1Mesh = s.value("layer1Mesh", ""); sel.layer2Mesh = diff --git a/src/features/editScene/systems/CharacterSlotSystem.cpp b/src/features/editScene/systems/CharacterSlotSystem.cpp index 8f91ba5..fa73dcc 100644 --- a/src/features/editScene/systems/CharacterSlotSystem.cpp +++ b/src/features/editScene/systems/CharacterSlotSystem.cpp @@ -182,13 +182,34 @@ Ogre::String CharacterSlotSystem::getMeshLabel(const Ogre::String &age, if (entry.value("mesh", "") == mesh) { const auto &garments = entry.value( "garments", nlohmann::json::array()); - if (garments.empty()) - return "nude"; - Ogre::String label; - for (size_t i = 0; i < garments.size(); ++i) { - if (i > 0) - label += " + "; - label += garments[i].get(); + if (!garments.empty()) { + Ogre::String label; + for (size_t i = 0; i < garments.size(); ++i) { + if (i > 0) + label += " + "; + label += garments[i].get(); + } + return label; + } + /* For non-garment slots (hair, face, etc.), derive + * a human-readable label from the mesh filename */ + Ogre::String label = mesh; + /* Strip directory prefix */ + auto slashPos = label.rfind('/'); + if (slashPos != Ogre::String::npos) + label = label.substr(slashPos + 1); + /* Strip .mesh extension */ + auto dotPos = label.rfind(".mesh"); + if (dotPos != Ogre::String::npos) + label = label.substr(0, dotPos); + /* Strip sex prefix (male_/female_) */ + Ogre::String sexPrefix = sex + "_"; + if (label.find(sexPrefix) == 0) + label = label.substr(sexPrefix.length()); + /* Replace underscores with spaces */ + for (auto &c : label) { + if (c == '_') + c = ' '; } return label; } @@ -270,6 +291,14 @@ Ogre::String CharacterSlotSystem::resolveMesh(const Ogre::String &age, const auto &slotEntries = s_bodyParts[age][sex][slot]; + /* If layer 0 is explicitly selected, use it */ + if (sel.layer0Mesh != "none" && !sel.layer0Mesh.empty()) { + for (const auto &entry : slotEntries) { + if (entry.value("mesh", "") == sel.layer0Mesh) + return sel.layer0Mesh; + } + } + /* outfitLevel: 0=nude, 1=lingerie, 2=clothed */ if (outfitLevel >= 2 && sel.layer2Mesh != "none" && !sel.layer2Mesh.empty()) { @@ -550,6 +579,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, */ prepareEntityTempBlendBuffers(masterEnt); + /* Notify AnimationTreeSystem that entity changed */ if (e.has()) e.get_mut().dirty = true; @@ -572,6 +602,9 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, if (mesh.empty()) continue; + Ogre::LogManager::getSingleton().logMessage( + "[CharacterSlotSystem] slot='" + slot + + try { ensureMeshPoseAnimation(mesh); Ogre::MeshPtr partMesh = @@ -593,6 +626,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, * proper per-entity pose animation. */ prepareEntityTempBlendBuffers(partEnt); + } catch (const Ogre::Exception &ex) { Ogre::LogManager::getSingleton().logMessage( "[CharacterSlotSystem] buildCharacter: FAILED to load part '" + @@ -603,7 +637,7 @@ void CharacterSlotSystem::buildCharacter(flecs::entity e, } void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent, - const nlohmann::json *entry) + const nlohmann::json *entry) { if (!ent || !entry) return; @@ -659,8 +693,8 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent, Ogre::AnimationStateSet *stateSet = ent->getAllAnimationStates(); if (stateSet) { - as = stateSet->createAnimationState( - "ShapeKeys", 0.0, 1.0, 1.0, false); + as = stateSet->createAnimationState("ShapeKeys", 0.0, + 1.0, 1.0, false); } else { Ogre::LogManager::getSingleton().logMessage( "[CharacterSlotSystem] applyShapeKeys: entity '" + @@ -701,8 +735,8 @@ void CharacterSlotSystem::applyShapeKeys(flecs::entity e, Ogre::Entity *ent, t < anim->getNumVertexTracks(); ++t) { Ogre::VertexAnimationTrack *track = anim->getVertexTrack(t); - if (!track || track->getAnimationType() != - Ogre::VAT_POSE) + if (!track || + track->getAnimationType() != Ogre::VAT_POSE) continue; Ogre::VertexPoseKeyFrame *kf = track->getVertexPoseKeyFrame(0); diff --git a/src/features/editScene/systems/SceneSerializer.cpp b/src/features/editScene/systems/SceneSerializer.cpp index f3063e9..1599381 100644 --- a/src/features/editScene/systems/SceneSerializer.cpp +++ b/src/features/editScene/systems/SceneSerializer.cpp @@ -2209,6 +2209,7 @@ nlohmann::json SceneSerializer::serializeCharacterSlots(flecs::entity entity) nlohmann::json selections = nlohmann::json::object(); for (const auto &pair : cs.slotSelections) { nlohmann::json sel; + sel["layer0Mesh"] = pair.second.layer0Mesh; sel["layer1Mesh"] = pair.second.layer1Mesh; sel["layer2Mesh"] = pair.second.layer2Mesh; sel["explicitMesh"] = pair.second.explicitMesh; @@ -2236,6 +2237,9 @@ void SceneSerializer::deserializeCharacterSlots(flecs::entity entity, json["slotSelections"].is_object()) { for (auto &[slot, selJson] : json["slotSelections"].items()) { SlotSelection sel; + if (selJson.contains("layer0Mesh")) + sel.layer0Mesh = + selJson.value("layer0Mesh", ""); if (selJson.contains("layer1Mesh")) sel.layer1Mesh = selJson.value("layer1Mesh", ""); diff --git a/src/features/editScene/ui/CharacterSlotsEditor.cpp b/src/features/editScene/ui/CharacterSlotsEditor.cpp index b6d3ecd..facc934 100644 --- a/src/features/editScene/ui/CharacterSlotsEditor.cpp +++ b/src/features/editScene/ui/CharacterSlotsEditor.cpp @@ -220,6 +220,62 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity, ImGui::EndCombo(); } } else { + /* Layer 0 combo (base meshes like hair) */ + std::vector layer0Meshes = + CharacterSlotSystem:: + getMeshesForLayer( + currentAge, + cs.sex, slot, + 0); + if (!layer0Meshes.empty()) { + Ogre::String l0Preview = "auto"; + if (!sel.layer0Mesh.empty() && + sel.layer0Mesh != "none") + l0Preview = CharacterSlotSystem:: + getMeshLabel( + currentAge, + cs.sex, + slot, + sel.layer0Mesh); + if (ImGui::BeginCombo( + "Base (Layer 0)", + l0Preview.c_str())) { + if (ImGui::Selectable( + "auto", + sel.layer0Mesh.empty() || + sel.layer0Mesh == + "none")) { + sel.layer0Mesh = + "none"; + modified = true; + cs.dirty = true; + } + for (const auto &m : + layer0Meshes) { + Ogre::String label = CharacterSlotSystem:: + getMeshLabel( + currentAge, + cs.sex, + slot, + m); + bool isSelected = + (sel.layer0Mesh == + m); + if (ImGui::Selectable( + label.c_str(), + isSelected)) { + sel.layer0Mesh = + m; + modified = + true; + cs.dirty = + true; + } + } + ImGui::EndCombo(); + } + } + /* Layer 1 combo */ std::vector layer1Meshes = CharacterSlotSystem::