diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index ea4acf1..c3472b1 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -47,15 +47,12 @@ set(VRM_IMPORTED_BLENDS # COMMAND ${CMAKE_COMMAND} -E touch_nocreate ${CHARACTER_GLBS} # 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_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-consolidated.blend ${CMAKE_BINARY_DIR}/characters/male/normal-male.glb - "${MALE_OBJECTS}" "male" tmp-edited-male.blend COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/male/normal-male.glb @@ -76,7 +73,6 @@ add_custom_command( COMMAND ${BLENDER} -b -Y -P ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_models2.py -- ${CMAKE_CURRENT_BINARY_DIR}/edited-normal-female-consolidated.blend ${CMAKE_BINARY_DIR}/characters/female/normal-female.glb - "${FEMALE_OBJECTS}" "female" tmp-edited-female.blend COMMAND ${CMAKE_COMMAND} -D FILE=${CMAKE_BINARY_DIR}/characters/female/normal-female.glb @@ -422,12 +418,19 @@ 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-female-bottom.blend) 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-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.blend" # FINAL_OUTPUT_BLEND ) diff --git a/assets/blender/characters/clothes-female-bottom.blend b/assets/blender/characters/clothes-female-bottom.blend index 26a9fea..db42a4c 100644 --- a/assets/blender/characters/clothes-female-bottom.blend +++ b/assets/blender/characters/clothes-female-bottom.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55218aaeab82db2d0d1fc87846a06b9d8a9c4604a1c07eba97aab150e7b242f6 -size 10176516 +oid sha256:a07d9e6f7db75eccbdfe3f349bffdeb9f98bd560f41e5adfcb385b8eb4519c4f +size 21246936 diff --git a/assets/blender/characters/clothes-male-bottom.blend b/assets/blender/characters/clothes-male-bottom.blend index 82d746e..6b5a068 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:0f3a804260bf6fb3c15852de047a2fd1d39666d17a4645b897bef4f883fe81c1 -size 1433176 +oid sha256:c04c45ba4de84bdecb55580eecacfe7d8deade014e4112102c3a36147cf26dd6 +size 1434352 diff --git a/assets/blender/characters/clothes-male-top.blend b/assets/blender/characters/clothes-male-top.blend new file mode 100644 index 0000000..c5c69ff --- /dev/null +++ b/assets/blender/characters/clothes-male-top.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10b18111add43e899ff9d6c37f59ebd099960cd92eee92b629541bd7d90abdb9 +size 5393044 diff --git a/assets/blender/characters/combine_clothes.py b/assets/blender/characters/combine_clothes.py index 03271a7..ab5000a 100644 --- a/assets/blender/characters/combine_clothes.py +++ b/assets/blender/characters/combine_clothes.py @@ -2,6 +2,7 @@ import bpy import bmesh import sys import os +import json import mathutils from mathutils.bvhtree import BVHTree @@ -158,6 +159,13 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= clothing_props[prop_name] = clothing_obj[prop_name] print(f" Stored property '{prop_name}' = {clothing_obj[prop_name]} from clothing") + # Also store metadata tags for export pipeline + clothing_meta = {} + for prop_name in ["ref_layer", "ref_part", "garment_id", "tags", "age", "sex", "slot"]: + if prop_name in clothing_obj: + clothing_meta[prop_name] = clothing_obj[prop_name] + print(f" Stored meta '{prop_name}' = {clothing_obj[prop_name]} from clothing") + # Step A: Raycast & adjust vertices out_ray_length = 0.015 if "ref_ray_length" in clothing_obj: @@ -204,6 +212,26 @@ def process_clothing_pair(clothing_obj, target_obj, whitelist, is_clothing_copy= new_target[prop_name] = prop_value print(f" Copied property '{prop_name}' = {prop_value} to combined object") + # NEW CODE: Aggregate clothing metadata onto combined object + # Collect from all clothing objects processed for this target + # Use JSON string since Blender ID properties don't support Python lists/sets + if "_combined_meta" in new_target: + meta = json.loads(new_target["_combined_meta"]) + else: + meta = {"garments": [], "tags": [], "layers": []} + if "garment_id" in clothing_meta: + meta["garments"].append(clothing_meta["garment_id"]) + else: + meta["garments"].append(clothing_name_for_final) + if "tags" in clothing_meta and clothing_meta["tags"]: + for t in str(clothing_meta["tags"]).split(";"): + t = t.strip() + if t and t not in meta["tags"]: + meta["tags"].append(t) + if "ref_layer" in clothing_meta: + meta["layers"].append(int(clothing_meta["ref_layer"])) + new_target["_combined_meta"] = json.dumps(meta) + # Rename the combined object using the appropriate clothing name new_target.name = f"{target_name}_{clothing_name_for_final}" whitelist.add(new_target) @@ -349,9 +377,34 @@ def run_batch_combine(): print(f"Layer 2 direct to body results: {len(layer2_results_direct)}") print(f"Total combined objects: {len(all_results)}") - # Final cleanup - keep all combined objects and their armatures + # Finalize aggregated metadata on all combined objects for obj in all_results: whitelist.add(obj) + if "_combined_meta" in obj: + meta = json.loads(obj["_combined_meta"]) + layers = meta.get("layers", []) + garments = meta.get("garments", []) + tags = meta.get("tags", []) + + if layers: + obj["layer"] = max(layers) + else: + obj["layer"] = 0 + + if garments: + obj["garments"] = ";".join(garments) + else: + obj["garments"] = "" + + if tags: + obj["clothing_tags"] = ";".join(sorted(set(tags))) + else: + obj["clothing_tags"] = "" + + print(f"Finalized metadata for {obj.name}: layer={obj['layer']}, garments={obj['garments']}, tags={obj['clothing_tags']}") + + # Remove temporary meta property + del obj["_combined_meta"] cleanup_unused_objects(whitelist) diff --git a/assets/blender/characters/garment_system_ui.py b/assets/blender/characters/garment_system_ui.py new file mode 100644 index 0000000..633067c --- /dev/null +++ b/assets/blender/characters/garment_system_ui.py @@ -0,0 +1,397 @@ +#!/usr/bin/env python3 +""" +Garment System UI Panel for Blender + +Run this script in Blender's Scripting editor (or via blender -b -P garment_system_ui.py) +to add a side-panel for editing garment-system custom properties on mesh objects. + +Properties managed: + Body Part -> age, sex, slot, layer, garments, clothing_tags + Clothing -> ref_layer, ref_part, garment_id, ref_sex, ref_age, ref_clothing, tags +""" + +import bpy + +# --------------------------------------------------------------------------- +# Property helpers +# --------------------------------------------------------------------------- + +SLOT_ITEMS = [ + ("face", "Face", ""), + ("bottom", "Bottom", ""), + ("top", "Top", ""), + ("feet", "Feet", ""), + ("hair", "Hair", ""), +] + +SEX_ITEMS = [ + ("male", "Male", ""), + ("female", "Female", ""), +] + +AGE_ITEMS = [ + ("adult", "Adult", ""), + ("child", "Child", ""), + ("teen", "Teen", ""), + ("baby", "Baby", ""), +] + + +def _ensure_prop(obj, key, default): + if key not in obj: + obj[key] = default + return obj[key] + + +def _get_prop_str(obj, key, default=""): + if key not in obj: + return default + return str(obj[key]) + + +def _get_prop_int(obj, key, default=0): + if key not in obj: + return default + try: + return int(obj[key]) + except (ValueError, TypeError): + return default + + +def _set_prop_str(obj, key, value): + if value: + obj[key] = value + elif key in obj: + del obj[key] + + +def _set_prop_int(obj, key, value): + obj[key] = int(value) + + +def _del_prop(obj, key): + if key in obj: + del obj[key] + + +# --------------------------------------------------------------------------- +# Operators +# --------------------------------------------------------------------------- + +class GARMENT_OT_apply_body_part(bpy.types.Operator): + bl_idname = "garment.apply_body_part" + bl_label = "Apply Body Part" + bl_description = "Write body-part custom properties to the active object" + bl_options = {"REGISTER", "UNDO"} + + age: bpy.props.EnumProperty(name="Age", items=AGE_ITEMS) + sex: bpy.props.EnumProperty(name="Sex", items=SEX_ITEMS) + slot: bpy.props.EnumProperty(name="Slot", items=SLOT_ITEMS) + layer: bpy.props.IntProperty(name="Layer", min=0, max=2, default=0) + garments: bpy.props.StringProperty(name="Garments", description="Semicolon-separated garment names") + clothing_tags: bpy.props.StringProperty(name="Clothing Tags", description="Semicolon-separated tags") + + def execute(self, context): + obj = context.active_object + if not obj or obj.type != "MESH": + self.report({"WARNING"}, "Active object is not a mesh") + return {"CANCELLED"} + + _set_prop_str(obj, "age", self.age) + _set_prop_str(obj, "sex", self.sex) + _set_prop_str(obj, "slot", self.slot) + _set_prop_int(obj, "layer", self.layer) + _set_prop_str(obj, "garments", self.garments) + _set_prop_str(obj, "clothing_tags", self.clothing_tags) + + # Remove clothing-only props so they don't confuse the pipeline + for k in ("ref_layer", "ref_part", "garment_id", "ref_sex", + "ref_age", "ref_clothing", "tags"): + _del_prop(obj, k) + + self.report({"INFO"}, f"Body-part props applied to {obj.name}") + return {"FINISHED"} + + def invoke(self, context, event): + obj = context.active_object + if obj: + self.age = _get_prop_str(obj, "age", "adult") + self.sex = _get_prop_str(obj, "sex", "male") + self.slot = _get_prop_str(obj, "slot", "bottom") + self.layer = _get_prop_int(obj, "layer", 0) + self.garments = _get_prop_str(obj, "garments", "") + self.clothing_tags = _get_prop_str(obj, "clothing_tags", "") + return context.window_manager.invoke_props_dialog(self) + + +class GARMENT_OT_apply_clothing(bpy.types.Operator): + bl_idname = "garment.apply_clothing" + bl_label = "Apply Clothing" + bl_description = "Write clothing custom properties to the active object" + bl_options = {"REGISTER", "UNDO"} + + ref_layer: bpy.props.IntProperty(name="Ref Layer", min=1, max=2, default=1, + description="1 = lingerie, 2 = clothing") + ref_part: bpy.props.StringProperty(name="Ref Part", + description="Target body-part object name, e.g. BodyBottom") + garment_id: bpy.props.StringProperty(name="Garment ID", + description="Garment name used for labels, e.g. panties7") + ref_sex: bpy.props.EnumProperty(name="Ref Sex", items=SEX_ITEMS) + ref_age: bpy.props.EnumProperty(name="Ref Age", items=AGE_ITEMS) + ref_clothing: bpy.props.StringProperty(name="Ref Clothing", + description="Mesh name used for weight transfer, e.g. BodyBottom") + tags: bpy.props.StringProperty(name="Tags", description="Semicolon-separated tags") + + def execute(self, context): + obj = context.active_object + if not obj or obj.type != "MESH": + self.report({"WARNING"}, "Active object is not a mesh") + return {"CANCELLED"} + + _set_prop_int(obj, "ref_layer", self.ref_layer) + _set_prop_str(obj, "ref_part", self.ref_part) + _set_prop_str(obj, "garment_id", self.garment_id) + _set_prop_str(obj, "ref_sex", self.ref_sex) + _set_prop_str(obj, "ref_age", self.ref_age) + _set_prop_str(obj, "ref_clothing", self.ref_clothing) + _set_prop_str(obj, "tags", self.tags) + + # Remove body-part-only props so they don't confuse the pipeline + for k in ("age", "sex", "slot", "layer", "garments", "clothing_tags"): + _del_prop(obj, k) + + self.report({"INFO"}, f"Clothing props applied to {obj.name}") + return {"FINISHED"} + + def invoke(self, context, event): + obj = context.active_object + if obj: + self.ref_layer = _get_prop_int(obj, "ref_layer", 1) + self.ref_part = _get_prop_str(obj, "ref_part", "") + self.garment_id = _get_prop_str(obj, "garment_id", "") + self.ref_sex = _get_prop_str(obj, "ref_sex", "male") + self.ref_age = _get_prop_str(obj, "ref_age", "adult") + self.ref_clothing = _get_prop_str(obj, "ref_clothing", "") + self.tags = _get_prop_str(obj, "tags", "") + return context.window_manager.invoke_props_dialog(self) + + +class GARMENT_OT_auto_detect(bpy.types.Operator): + bl_idname = "garment.auto_detect" + bl_label = "Auto-detect from Name" + bl_description = "Guess sex/slot from object name" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj or obj.type != "MESH": + self.report({"WARNING"}, "Active object is not a mesh") + return {"CANCELLED"} + + name = obj.name.lower() + changed = [] + + # Guess sex + if "female" in name: + obj["sex"] = "female" + changed.append("sex=female") + elif "male" in name: + obj["sex"] = "male" + changed.append("sex=male") + + # Guess slot + slot_map = { + "face": "face", + "bottom": "bottom", + "top": "top", + "feet": "feet", + "hair": "hair", + } + for key, slot in slot_map.items(): + if key in name: + obj["slot"] = slot + changed.append(f"slot={slot}") + break + + # Guess layer from name hints + if any(x in name for x in ("panties", "lingerie", "underwear")): + obj["layer"] = 1 + changed.append("layer=1") + elif any(x in name for x in ("pants", "skirt", "robe", "shirt", "dress", "cloth")): + obj["layer"] = 2 + changed.append("layer=2") + else: + obj["layer"] = 0 + changed.append("layer=0") + + # Set garment_id from object name for clothing + if "ref_layer" in obj or "ref_part" in obj: + if "garment_id" not in obj or not obj["garment_id"]: + obj["garment_id"] = obj.name + changed.append(f"garment_id={obj.name}") + + self.report({"INFO"}, "Auto-detect: " + ", ".join(changed) if changed else "nothing changed") + return {"FINISHED"} + + +class GARMENT_OT_clear_props(bpy.types.Operator): + bl_idname = "garment.clear_props" + bl_label = "Clear All Garment Props" + bl_description = "Remove all garment-system custom properties from the active object" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + keys = list(obj.keys()) + removed = [] + for k in keys: + if k in ("age", "sex", "slot", "layer", "garments", "clothing_tags", + "ref_layer", "ref_part", "garment_id", "ref_sex", "ref_age", + "ref_clothing", "tags"): + del obj[k] + removed.append(k) + self.report({"INFO"}, f"Removed {len(removed)} props" if removed else "No garment props found") + return {"FINISHED"} + + +class GARMENT_OT_sync_tags(bpy.types.Operator): + bl_idname = "garment.sync_tags" + bl_label = "Sync Tags from Garments" + bl_description = "Copy clothing_tags from garments field" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + obj = context.active_object + if not obj: + return {"CANCELLED"} + garments = _get_prop_str(obj, "garments", "") + obj["clothing_tags"] = garments + self.report({"INFO"}, f"Tags set to: {garments}") + return {"FINISHED"} + + +# --------------------------------------------------------------------------- +# Panel +# --------------------------------------------------------------------------- + +class GARMENT_PT_panel(bpy.types.Panel): + bl_label = "Garment System" + bl_idname = "GARMENT_PT_panel" + bl_space_type = "VIEW_3D" + bl_region_type = "UI" + bl_category = "Garment" + + def draw(self, context): + layout = self.layout + obj = context.active_object + + if not obj: + layout.label(text="No active object") + return + + layout.label(text=f"Object: {obj.name}", icon="OBJECT_DATA") + + # Detect current type + is_body = any(k in obj for k in ("age", "sex", "slot")) + is_cloth = any(k in obj for k in ("ref_layer", "ref_part", "garment_id")) + + if is_body and is_cloth: + layout.alert = True + layout.label(text="WARNING: mixed props", icon="ERROR") + layout.alert = False + elif is_body: + layout.label(text="Type: Body Part", icon="MESH_DATA") + elif is_cloth: + layout.label(text="Type: Clothing", icon="MOD_CLOTH") + else: + layout.label(text="Type: (unset)", icon="QUESTION") + + layout.separator() + + # Quick action buttons + row = layout.row(align=True) + row.operator("garment.apply_body_part", icon="MESH_DATA") + row = layout.row(align=True) + row.operator("garment.apply_clothing", icon="MOD_CLOTH") + + layout.separator() + row = layout.row(align=True) + row.operator("garment.auto_detect", icon="VIEWZOOM") + row.operator("garment.clear_props", icon="X") + + layout.separator() + + # Show current body-part props + if is_body or not is_cloth: + box = layout.box() + box.label(text="Body Part Props", icon="MESH_DATA") + col = box.column(align=True) + col.label(text=f"age: {_get_prop_str(obj, 'age', '—')}") + col.label(text=f"sex: {_get_prop_str(obj, 'sex', '—')}") + col.label(text=f"slot: {_get_prop_str(obj, 'slot', '—')}") + col.label(text=f"layer: {_get_prop_int(obj, 'layer', 0)}") + col.label(text=f"garments: {_get_prop_str(obj, 'garments', '—')}") + col.label(text=f"clothing_tags: {_get_prop_str(obj, 'clothing_tags', '—')}") + if _get_prop_str(obj, "garments", ""): + col.operator("garment.sync_tags", icon="FILE_REFRESH") + + # Show current clothing props + if is_cloth: + box = layout.box() + box.label(text="Clothing Props", icon="MOD_CLOTH") + col = box.column(align=True) + col.label(text=f"ref_layer: {_get_prop_int(obj, 'ref_layer', 1)}") + col.label(text=f"ref_part: {_get_prop_str(obj, 'ref_part', '—')}") + col.label(text=f"garment_id: {_get_prop_str(obj, 'garment_id', '—')}") + col.label(text=f"ref_sex: {_get_prop_str(obj, 'ref_sex', '—')}") + col.label(text=f"ref_age: {_get_prop_str(obj, 'ref_age', '—')}") + col.label(text=f"ref_clothing: {_get_prop_str(obj, 'ref_clothing', '—')}") + col.label(text=f"tags: {_get_prop_str(obj, 'tags', '—')}") + + layout.separator() + + # Scene overview + box = layout.box() + box.label(text="Scene Overview", icon="SCENE_DATA") + body_count = 0 + cloth_count = 0 + for o in bpy.data.objects: + if o.type != "MESH": + continue + if any(k in o for k in ("age", "sex", "slot")): + body_count += 1 + elif any(k in o for k in ("ref_layer", "ref_part", "garment_id")): + cloth_count += 1 + col = box.column(align=True) + col.label(text=f"Body parts: {body_count}") + col.label(text=f"Clothing: {cloth_count}") + + +# --------------------------------------------------------------------------- +# Registration +# --------------------------------------------------------------------------- + +classes = [ + GARMENT_OT_apply_body_part, + GARMENT_OT_apply_clothing, + GARMENT_OT_auto_detect, + GARMENT_OT_clear_props, + GARMENT_OT_sync_tags, + GARMENT_PT_panel, +] + + +def register(): + for cls in classes: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in reversed(classes): + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + register() diff --git a/assets/blender/characters/process_clothes.py b/assets/blender/characters/process_clothes.py index fc5a176..9b92305 100644 --- a/assets/blender/characters/process_clothes.py +++ b/assets/blender/characters/process_clothes.py @@ -122,6 +122,10 @@ def process_batch(): remove_empty_vertex_groups(clothing, threshold=0.001) + # Set garment_id from object name if not already set + if "garment_id" not in clothing: + clothing["garment_id"] = clothing.name + # 7. Final Parenting clothing.parent = rig arm_mod = clothing.modifiers.new(name="Armature", type='ARMATURE') diff --git a/assets/blender/scripts/blender2ogre/io_ogre/ogre/material.py b/assets/blender/scripts/blender2ogre/io_ogre/ogre/material.py index 7a98800..4598a75 100644 --- a/assets/blender/scripts/blender2ogre/io_ogre/ogre/material.py +++ b/assets/blender/scripts/blender2ogre/io_ogre/ogre/material.py @@ -354,7 +354,11 @@ class OgreMaterialGenerator(object): else: image_filepath = bpy.path.abspath(image.filepath, library=image.library) image_filepath = os.path.normpath(image_filepath) - + + if not os.path.isfile(image_filepath): + logger.warning("Skipping texture copy: source path is not a file (%s)", image_filepath) + return + # Should we update the file update = False if os.path.isfile(target_filepath): diff --git a/assets/blender/scripts/export_models2.py b/assets/blender/scripts/export_models2.py index 556325a..efb72bc 100644 --- a/assets/blender/scripts/export_models2.py +++ b/assets/blender/scripts/export_models2.py @@ -186,26 +186,32 @@ def extra_linear(angle, offset): mapping_blend_path = argv[0] mapping_gltf_path = argv[1] -mapping_objects = argv[2] -mapping_armature_name = argv[3] -mapping_outfile = argv[4] +# Backward compat: support both 4-arg (blend, gltf, armature, outfile) and 5-arg (blend, gltf, objects, armature, outfile) +if len(argv) >= 5: + mapping_objects = argv[2] + mapping_armature_name = argv[3] + mapping_outfile = argv[4] +else: + mapping_objects = "AUTO" + mapping_armature_name = argv[2] + mapping_outfile = argv[3] #for mapping in [ExportMappingFemale(), ExportMappingMale(), ExportMappingMaleBabyShape(), ExportMappingMaleEdited(), ExportMappingFemaleEdited(), ExportMappingMaleTestShapeEdited(), ExportMappingMaleBaseShapeEdited()]: class CommandLineMapping: blend_path = mapping_blend_path gltf_path = mapping_gltf_path -# ogre_scene = "characters/female/vroid-normal-female.scene" inner_path = "Object" -# objs = ["male", "Body", "Hair", "Face", "BackHair", "Tops", "Bottoms", "Shoes", "Accessory"] -# objs = ["female", "Body", "Hair", "Face", "BackHair", "Tops", "Bottoms", "Shoes", "Accessory"] objs = [] armature_name = mapping_armature_name outfile = mapping_outfile default_action = 'default' + auto_discover = False def __init__(self): self.objs = [mapping_armature_name] - if len(mapping_objects) > 0: + if len(mapping_objects) > 0 and mapping_objects != "AUTO": self.objs += [o.strip() for o in mapping_objects.split(";")] + else: + self.auto_discover = True self.files = [] for fobj in self.objs: self.files.append({"name": fobj}) @@ -224,11 +230,30 @@ for mapping in[CommandLineMapping()]: bpy.app.driver_namespace["angle_to_linear_x"] = angle_to_linear_x print("Driver setup done...") - bpy.ops.wm.append( - filepath=os.path.join(mapping.blend_path, mapping.inner_path), - directory=os.path.join(mapping.blend_path, mapping.inner_path), - files=mapping.files) - print("Append done...") + if mapping.auto_discover: + # Load all objects from the blend file so we can discover meshes by custom props + with bpy.data.libraries.load(mapping.blend_path) as (data_from, data_to): + data_to.objects = data_from.objects + for obj in data_to.objects: + if obj is not None: + try: + bpy.context.collection.objects.link(obj) + except RuntimeError: + pass # Already linked + print("Library load done...") + + discovered = [] + for ob in bpy.data.objects: + if ob.type == 'MESH' and all(p in ob.keys() for p in ["age", "sex", "slot"]): + discovered.append(ob.name) + mapping.objs += discovered + print(f"Auto-discovered {len(discovered)} body part objects: {discovered}") + else: + bpy.ops.wm.append( + filepath=os.path.join(mapping.blend_path, mapping.inner_path), + directory=os.path.join(mapping.blend_path, mapping.inner_path), + files=mapping.files) + print("Append done...") prepare_armature(mapping) print("Armature done...") @@ -239,6 +264,10 @@ for mapping in[CommandLineMapping()]: bpy.data.objects.remove(ob) elif ob.name.startswith("Face") and ob.name != "Face": bpy.data.objects.remove(ob) + # Remove auto-discovered objects that weren't meant for export + elif mapping.auto_discover and ob.type == 'MESH' and ob.name not in mapping.objs: + if not ob.name.endswith("-noimp"): + ob.name = ob.name + "-noimp" print("Removing original armature and actions...") orig_arm = bpy.data.objects[mapping.armature_name + '_orig'] @@ -317,12 +346,48 @@ for mapping in[CommandLineMapping()]: save_data[key] = obj[key] if key.startswith("body_"): save_data[key.replace("body_", "", 1)] = obj[key] + + # Export aggregated clothing metadata + if "layer" in obj: + save_data["layer"] = int(obj["layer"]) + else: + save_data["layer"] = 0 + + tags = [] + if "garments" in obj and obj["garments"]: + garments = [g.strip() for g in str(obj["garments"]).split(";") if g.strip()] + save_data["garments"] = garments + tags.extend(garments) + else: + save_data["garments"] = [] + + if "clothing_tags" in obj and obj["clothing_tags"]: + clothing_tags = [t.strip() for t in str(obj["clothing_tags"]).split(";") if t.strip()] + tags.extend(clothing_tags) + + # Deduplicate and sort + seen = set() + unique_tags = [] + for t in tags: + t_lower = t.lower() + if t_lower not in seen: + seen.add(t_lower) + unique_tags.append(t) + save_data["tags"] = unique_tags + + # Export shape keys if present + shape_keys = [] + if obj.data.shape_keys and obj.data.shape_keys.key_blocks: + for sk in obj.data.shape_keys.key_blocks: + shape_keys.append(sk.name) + save_data["shape_keys"] = shape_keys + save_data["mesh"] = obj.data.name + ".mesh" json_dir = os.path.dirname(mapping.gltf_path) save_file = json_dir + "/body_part_" + obj.data.name + ".json" json_filepath = os.path.join(json_dir, save_file) with open(json_filepath, 'w') as f: - json.dump(save_data, f) + json.dump(save_data, f, indent=2) armobj = bpy.data.objects.get(mapping.armature_name) armobj.data.name = armobj.name