Compare commits
2 Commits
86310e96f2
...
765dffbed0
| Author | SHA1 | Date | |
|---|---|---|---|
| 765dffbed0 | |||
| a553621c7f |
@@ -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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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():
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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():
|
||||
@@ -413,10 +430,132 @@ for mapping in[CommandLineMapping()]:
|
||||
with open(json_filepath, 'w') as f:
|
||||
json.dump(save_data, f, indent=2)
|
||||
|
||||
# Triangulate body part meshes before OGRE export to prevent the exporter's
|
||||
# triangulation from producing mismatched split normals at seams.
|
||||
# BodyTop and BodyBottom have different face topologies, so the exporter's
|
||||
# bmesh triangulation creates slightly different corner normals for matching
|
||||
# vertices. Pre-triangulating ensures the exporter sees already-triangulated
|
||||
# meshes and preserves our custom normals.
|
||||
import bmesh
|
||||
body_part_objs = []
|
||||
for ob in bpy.data.objects:
|
||||
if ob.type == 'MESH' and all(p in ob for p in ["age", "sex", "slot"]):
|
||||
body_part_objs.append(ob)
|
||||
|
||||
print(f"\nTriangulating {len(body_part_objs)} body part meshes for OGRE export...")
|
||||
for obj in body_part_objs:
|
||||
mesh = obj.data
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
bmesh.ops.triangulate(bm, faces=bm.faces)
|
||||
bm.to_mesh(mesh)
|
||||
bm.free()
|
||||
mesh.calc_normals()
|
||||
print(f" Triangulated '{obj.name}': {len(mesh.polygons)} polygons")
|
||||
|
||||
# Re-run normal fix on triangulated meshes to ensure matching vertices
|
||||
# have identical custom split normals after triangulation.
|
||||
chars_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "characters")
|
||||
if chars_dir not in sys.path:
|
||||
sys.path.insert(0, chars_dir)
|
||||
from transfer_shape_keys import fix_normals_across_objects
|
||||
fix_normals_across_objects()
|
||||
|
||||
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.
|
||||
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}...")
|
||||
import subprocess
|
||||
import shutil
|
||||
# Use system python3 if available, since sys.executable in Blender
|
||||
# may point to the Blender binary itself, not a Python interpreter.
|
||||
python_exe = shutil.which('python3') or shutil.which('python') or sys.executable
|
||||
result = subprocess.run(
|
||||
[python_exe, fix_script, ogre_export_dir],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
print(result.stdout)
|
||||
if result.returncode != 0:
|
||||
print(f"WARNING: Mesh seam fix failed: {result.stderr}")
|
||||
else:
|
||||
print(f"WARNING: fix_ogre_mesh_seams.py not found at {fix_script}")
|
||||
|
||||
bpy.ops.wm.read_homefile(use_empty=True)
|
||||
time.sleep(2)
|
||||
bpy.ops.wm.quit_blender()
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Post-process exported OGRE mesh files to fix normal/tangent seams
|
||||
between body part meshes at matching vertex positions.
|
||||
|
||||
Also fixes pose normal offsets (shape key normals) so that animated
|
||||
shape keys don't reintroduce seams at runtime.
|
||||
|
||||
Usage: python3 fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ET
|
||||
import math
|
||||
|
||||
# Default path to OgreXMLConverter (can be overridden via argument or env var)
|
||||
DEFAULT_OGRE_XML_CONVERTER = os.environ.get(
|
||||
'OGRETOOLS_XML_CONVERTER',
|
||||
'/media/slapin/library/ogre3/ogre-sdk/bin/OgreXMLConverter'
|
||||
)
|
||||
|
||||
# Body part prefixes to process
|
||||
BODY_PREFIXES = ['BodyTop', 'BodyBottom', 'BodyFeet']
|
||||
|
||||
|
||||
def find_converter():
|
||||
"""Find OgreXMLConverter executable."""
|
||||
if len(sys.argv) >= 3:
|
||||
return sys.argv[2]
|
||||
if os.path.exists(DEFAULT_OGRE_XML_CONVERTER):
|
||||
return DEFAULT_OGRE_XML_CONVERTER
|
||||
# Try PATH
|
||||
for path in os.environ.get('PATH', '').split(os.pathsep):
|
||||
exe = os.path.join(path, 'OgreXMLConverter')
|
||||
if os.path.exists(exe):
|
||||
return exe
|
||||
return None
|
||||
|
||||
|
||||
def mesh_to_xml(mesh_path, converter):
|
||||
xml_path = mesh_path + '.xml'
|
||||
result = subprocess.run(
|
||||
[converter, mesh_path, xml_path],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"WARNING: Failed to convert {mesh_path} to XML:")
|
||||
print(result.stdout)
|
||||
print(result.stderr)
|
||||
return None
|
||||
return xml_path
|
||||
|
||||
|
||||
def xml_to_mesh(xml_path, converter):
|
||||
mesh_path = xml_path[:-4] # remove .xml
|
||||
result = subprocess.run(
|
||||
[converter, xml_path, mesh_path],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"WARNING: Failed to convert {xml_path} to binary:")
|
||||
print(result.stdout)
|
||||
print(result.stderr)
|
||||
return None
|
||||
return mesh_path
|
||||
|
||||
|
||||
def parse_mesh_xml(xml_path):
|
||||
"""Parse mesh XML and return (tree, vertices list, poses dict)."""
|
||||
try:
|
||||
tree = ET.parse(xml_path)
|
||||
except ET.ParseError as e:
|
||||
print(f"ERROR: Failed to parse {xml_path}: {e}")
|
||||
return None, None, None
|
||||
|
||||
root = tree.getroot()
|
||||
sharedgeom = root.find('.//sharedgeometry')
|
||||
if sharedgeom is None:
|
||||
return None, None, None
|
||||
|
||||
vbs = sharedgeom.findall('vertexbuffer')
|
||||
if len(vbs) < 1:
|
||||
return None, None, None
|
||||
|
||||
# The first vertexbuffer has positions and normals
|
||||
pos_norm_vb = vbs[0]
|
||||
# The second vertexbuffer (if any) has texcoords and tangents
|
||||
tex_tan_vb = vbs[1] if len(vbs) > 1 else None
|
||||
|
||||
pn_verts = pos_norm_vb.findall('vertex')
|
||||
tt_verts = tex_tan_vb.findall('vertex') if tex_tan_vb is not None else []
|
||||
|
||||
vertices = []
|
||||
for i, pnv in enumerate(pn_verts):
|
||||
pos = pnv.find('position')
|
||||
normal = pnv.find('normal')
|
||||
if pos is None or normal is None:
|
||||
continue
|
||||
|
||||
p = (float(pos.get('x')), float(pos.get('y')), float(pos.get('z')))
|
||||
n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
|
||||
|
||||
t = None
|
||||
if i < len(tt_verts):
|
||||
tangent = tt_verts[i].find('tangent')
|
||||
if tangent is not None:
|
||||
t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
|
||||
|
||||
vertices.append({
|
||||
'index': i,
|
||||
'pos': p,
|
||||
'normal': n,
|
||||
'tangent': t,
|
||||
})
|
||||
|
||||
# Parse poses: pose_name -> {vertex_index -> (nx, ny, nz)}
|
||||
poses = {}
|
||||
for pose in root.findall('.//poses/pose'):
|
||||
name = pose.get('name')
|
||||
pose_offsets = {}
|
||||
for po in pose.findall('poseoffset'):
|
||||
idx = int(po.get('index'))
|
||||
nx = float(po.get('nx', 0.0))
|
||||
ny = float(po.get('ny', 0.0))
|
||||
nz = float(po.get('nz', 0.0))
|
||||
pose_offsets[idx] = (nx, ny, nz)
|
||||
poses[name] = pose_offsets
|
||||
|
||||
return tree, vertices, poses
|
||||
|
||||
|
||||
def round_pos(p, tolerance=0.0001):
|
||||
return (
|
||||
round(p[0] / tolerance) * tolerance,
|
||||
round(p[1] / tolerance) * tolerance,
|
||||
round(p[2] / tolerance) * tolerance,
|
||||
)
|
||||
|
||||
|
||||
def vector_normalize(v):
|
||||
length = math.sqrt(v[0]**2 + v[1]**2 + v[2]**2)
|
||||
if length > 0.0001:
|
||||
return (v[0] / length, v[1] / length, v[2] / length)
|
||||
return v
|
||||
|
||||
|
||||
def fix_seams(mesh_dir, converter):
|
||||
"""Fix normal/tangent/pose-normal seams across all body part meshes in directory."""
|
||||
# Find all body part mesh files
|
||||
mesh_files = []
|
||||
for f in os.listdir(mesh_dir):
|
||||
if not f.endswith('.mesh') or f.endswith('.mesh.xml'):
|
||||
continue
|
||||
for prefix in BODY_PREFIXES:
|
||||
if prefix in f:
|
||||
mesh_files.append(os.path.join(mesh_dir, f))
|
||||
break
|
||||
|
||||
if not mesh_files:
|
||||
print(f"No body part meshes found in {mesh_dir}")
|
||||
return 0
|
||||
|
||||
print(f"\n[OGRE Mesh Seam Fix] Found {len(mesh_files)} body part meshes")
|
||||
for mf in mesh_files:
|
||||
print(f" - {os.path.basename(mf)}")
|
||||
|
||||
# Convert all to XML
|
||||
xml_paths = []
|
||||
for mesh_path in mesh_files:
|
||||
print(f"\nConverting {os.path.basename(mesh_path)} to XML...")
|
||||
xml_path = mesh_to_xml(mesh_path, converter)
|
||||
if xml_path:
|
||||
xml_paths.append(xml_path)
|
||||
else:
|
||||
print(f" SKIPPED (conversion failed)")
|
||||
|
||||
if not xml_paths:
|
||||
print("No meshes could be converted to XML, aborting.")
|
||||
return 0
|
||||
|
||||
# Parse all XMLs
|
||||
mesh_data = {}
|
||||
all_pose_names = set()
|
||||
for xml_path in xml_paths:
|
||||
name = os.path.basename(xml_path)[:-9] # remove .mesh.xml
|
||||
tree, vertices, poses = parse_mesh_xml(xml_path)
|
||||
if vertices:
|
||||
mesh_data[name] = {
|
||||
'tree': tree,
|
||||
'vertices': vertices,
|
||||
'poses': poses,
|
||||
'xml_path': xml_path,
|
||||
}
|
||||
all_pose_names.update(poses.keys())
|
||||
else:
|
||||
print(f"WARNING: No vertices found in {name}")
|
||||
|
||||
if not mesh_data:
|
||||
print("No mesh data could be parsed, aborting.")
|
||||
return 0
|
||||
|
||||
# Build global position -> list of (normal, tangent) map for BASIS
|
||||
pos_map = {}
|
||||
for name, data in mesh_data.items():
|
||||
for v in data['vertices']:
|
||||
key = round_pos(v['pos'], 0.0001)
|
||||
if key not in pos_map:
|
||||
pos_map[key] = []
|
||||
pos_map[key].append((v['normal'], v['tangent']))
|
||||
|
||||
# Compute averaged normals and tangents for positions appearing in 2+ meshes
|
||||
avg_data = {}
|
||||
multi_mesh_positions = 0
|
||||
for pos_key, entries in pos_map.items():
|
||||
if len(entries) < 2:
|
||||
continue
|
||||
multi_mesh_positions += 1
|
||||
|
||||
# Average normals
|
||||
avg_n = [0.0, 0.0, 0.0]
|
||||
for n, t in entries:
|
||||
avg_n[0] += n[0]
|
||||
avg_n[1] += n[1]
|
||||
avg_n[2] += n[2]
|
||||
avg_n = vector_normalize(avg_n)
|
||||
|
||||
# Average tangents (only if at least one entry has a tangent)
|
||||
avg_t = [0.0, 0.0, 0.0]
|
||||
tangent_count = 0
|
||||
for n, t in entries:
|
||||
if t is not None:
|
||||
avg_t[0] += t[0]
|
||||
avg_t[1] += t[1]
|
||||
avg_t[2] += t[2]
|
||||
tangent_count += 1
|
||||
|
||||
if tangent_count > 0:
|
||||
avg_t = vector_normalize(avg_t)
|
||||
else:
|
||||
avg_t = None
|
||||
|
||||
avg_data[pos_key] = {
|
||||
'normal': avg_n,
|
||||
'tangent': avg_t,
|
||||
}
|
||||
|
||||
print(f"\nFound {multi_mesh_positions} positions shared across 2+ meshes")
|
||||
print(f"Averaging normals/tangents for {len(avg_data)} positions...")
|
||||
|
||||
# Build global position -> list of pose normal offsets for EACH pose
|
||||
# pose_name -> {pos_key -> list of (nx, ny, nz)}
|
||||
pose_pos_maps = {}
|
||||
for pose_name in all_pose_names:
|
||||
pose_pos_map = {}
|
||||
for name, data in mesh_data.items():
|
||||
if pose_name not in data['poses']:
|
||||
continue
|
||||
pose_offsets = data['poses'][pose_name]
|
||||
for v in data['vertices']:
|
||||
if v['index'] not in pose_offsets:
|
||||
continue
|
||||
key = round_pos(v['pos'], 0.0001)
|
||||
if key not in pose_pos_map:
|
||||
pose_pos_map[key] = []
|
||||
pose_pos_map[key].append(pose_offsets[v['index']])
|
||||
pose_pos_maps[pose_name] = pose_pos_map
|
||||
|
||||
# Compute averaged pose normal offsets for positions appearing in 2+ meshes
|
||||
avg_pose_data = {}
|
||||
for pose_name, pose_pos_map in pose_pos_maps.items():
|
||||
avg_offsets = {}
|
||||
for pos_key, entries in pose_pos_map.items():
|
||||
if len(entries) < 2:
|
||||
continue
|
||||
avg_n = [0.0, 0.0, 0.0]
|
||||
for nx, ny, nz in entries:
|
||||
avg_n[0] += nx
|
||||
avg_n[1] += ny
|
||||
avg_n[2] += nz
|
||||
avg_n = vector_normalize(avg_n)
|
||||
avg_offsets[pos_key] = avg_n
|
||||
if avg_offsets:
|
||||
avg_pose_data[pose_name] = avg_offsets
|
||||
|
||||
if avg_pose_data:
|
||||
print(f"Averaging pose normal offsets for {len(avg_pose_data)} pose(s):")
|
||||
for pose_name in sorted(avg_pose_data.keys()):
|
||||
print(f" - {pose_name}: {len(avg_pose_data[pose_name])} positions")
|
||||
else:
|
||||
print("No pose normal offsets to fix.")
|
||||
|
||||
# Update all meshes with averaged values
|
||||
modified_count = 0
|
||||
for name, data in mesh_data.items():
|
||||
tree = data['tree']
|
||||
vertices = data['vertices']
|
||||
modified = False
|
||||
normal_fixes = 0
|
||||
tangent_fixes = 0
|
||||
pose_normal_fixes = 0
|
||||
|
||||
root = tree.getroot()
|
||||
sharedgeom = root.find('.//sharedgeometry')
|
||||
if sharedgeom is None:
|
||||
continue
|
||||
vbs = sharedgeom.findall('vertexbuffer')
|
||||
if len(vbs) < 1:
|
||||
continue
|
||||
|
||||
pos_norm_vb = vbs[0]
|
||||
tt_verts = vbs[1].findall('vertex') if len(vbs) > 1 else []
|
||||
pn_verts = pos_norm_vb.findall('vertex')
|
||||
|
||||
# Fix basis normals and tangents
|
||||
for v in vertices:
|
||||
key = round_pos(v['pos'], 0.0001)
|
||||
if key not in avg_data:
|
||||
continue
|
||||
|
||||
avg = avg_data[key]
|
||||
|
||||
# Update normal
|
||||
pnv = pn_verts[v['index']]
|
||||
normal = pnv.find('normal')
|
||||
if normal is not None:
|
||||
old_n = (float(normal.get('x')), float(normal.get('y')), float(normal.get('z')))
|
||||
new_n = avg['normal']
|
||||
diff = math.sqrt(
|
||||
(old_n[0]-new_n[0])**2 +
|
||||
(old_n[1]-new_n[1])**2 +
|
||||
(old_n[2]-new_n[2])**2
|
||||
)
|
||||
if diff > 0.000001:
|
||||
normal.set('x', f"{new_n[0]:.6f}")
|
||||
normal.set('y', f"{new_n[1]:.6f}")
|
||||
normal.set('z', f"{new_n[2]:.6f}")
|
||||
normal_fixes += 1
|
||||
|
||||
# Update tangent
|
||||
if avg['tangent'] is not None and v['tangent'] is not None:
|
||||
if v['index'] < len(tt_verts):
|
||||
ttv = tt_verts[v['index']]
|
||||
tangent = ttv.find('tangent')
|
||||
if tangent is not None:
|
||||
old_t = (float(tangent.get('x')), float(tangent.get('y')), float(tangent.get('z')))
|
||||
new_t = avg['tangent']
|
||||
diff = math.sqrt(
|
||||
(old_t[0]-new_t[0])**2 +
|
||||
(old_t[1]-new_t[1])**2 +
|
||||
(old_t[2]-new_t[2])**2
|
||||
)
|
||||
if diff > 0.000001:
|
||||
tangent.set('x', f"{new_t[0]:.6f}")
|
||||
tangent.set('y', f"{new_t[1]:.6f}")
|
||||
tangent.set('z', f"{new_t[2]:.6f}")
|
||||
tangent_fixes += 1
|
||||
|
||||
modified = True
|
||||
|
||||
# Fix pose normal offsets
|
||||
for pose_name, avg_offsets in avg_pose_data.items():
|
||||
if pose_name not in data['poses']:
|
||||
continue
|
||||
# Find the <pose> element
|
||||
pose_elem = None
|
||||
for p in root.findall('.//poses/pose'):
|
||||
if p.get('name') == pose_name:
|
||||
pose_elem = p
|
||||
break
|
||||
if pose_elem is None:
|
||||
continue
|
||||
|
||||
for v in vertices:
|
||||
key = round_pos(v['pos'], 0.0001)
|
||||
if key not in avg_offsets:
|
||||
continue
|
||||
if v['index'] not in data['poses'][pose_name]:
|
||||
continue
|
||||
|
||||
# Find the poseoffset element for this vertex
|
||||
for po in pose_elem.findall('poseoffset'):
|
||||
if int(po.get('index')) == v['index']:
|
||||
old_nx = float(po.get('nx', 0.0))
|
||||
old_ny = float(po.get('ny', 0.0))
|
||||
old_nz = float(po.get('nz', 0.0))
|
||||
new_n = avg_offsets[key]
|
||||
diff = math.sqrt(
|
||||
(old_nx-new_n[0])**2 +
|
||||
(old_ny-new_n[1])**2 +
|
||||
(old_nz-new_n[2])**2
|
||||
)
|
||||
if diff > 0.000001:
|
||||
po.set('nx', f"{new_n[0]:.6f}")
|
||||
po.set('ny', f"{new_n[1]:.6f}")
|
||||
po.set('nz', f"{new_n[2]:.6f}")
|
||||
pose_normal_fixes += 1
|
||||
break
|
||||
|
||||
if pose_normal_fixes > 0:
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
xml_path = data['xml_path']
|
||||
tree.write(xml_path, encoding='utf-8', xml_declaration=True)
|
||||
print(f" {name}: fixed {normal_fixes} normals, {tangent_fixes} tangents, {pose_normal_fixes} pose normals")
|
||||
modified_count += 1
|
||||
else:
|
||||
print(f" {name}: no changes needed")
|
||||
|
||||
# Convert back to binary
|
||||
print(f"\nConverting {modified_count} modified meshes back to binary...")
|
||||
converted = 0
|
||||
for xml_path in xml_paths:
|
||||
mesh_path = xml_to_mesh(xml_path, converter)
|
||||
if mesh_path:
|
||||
converted += 1
|
||||
# Clean up XML file
|
||||
try:
|
||||
os.remove(xml_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
print(f"[OGRE Mesh Seam Fix] Done. {converted}/{len(xml_paths)} meshes converted.")
|
||||
return converted
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: fix_ogre_mesh_seams.py <mesh_directory> [ogre_xml_converter_path]")
|
||||
sys.exit(1)
|
||||
|
||||
mesh_dir = sys.argv[1]
|
||||
converter = find_converter()
|
||||
|
||||
if not converter:
|
||||
print("ERROR: OgreXMLConverter not found. Set OGRETOOLS_XML_CONVERTER env var or pass path as 2nd argument.")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.isdir(mesh_dir):
|
||||
print(f"ERROR: {mesh_dir} is not a directory")
|
||||
sys.exit(1)
|
||||
|
||||
fix_seams(mesh_dir, converter)
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<std::string>();
|
||||
if (!garments.empty()) {
|
||||
Ogre::String label;
|
||||
for (size_t i = 0; i < garments.size(); ++i) {
|
||||
if (i > 0)
|
||||
label += " + ";
|
||||
label += garments[i].get<std::string>();
|
||||
}
|
||||
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<AnimationTreeComponent>())
|
||||
e.get_mut<AnimationTreeComponent>().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);
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -220,6 +220,62 @@ bool CharacterSlotsEditor::renderComponent(flecs::entity entity,
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
} else {
|
||||
/* Layer 0 combo (base meshes like hair) */
|
||||
std::vector<Ogre::String> 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<Ogre::String> layer1Meshes =
|
||||
CharacterSlotSystem::
|
||||
|
||||
Reference in New Issue
Block a user