From e646cbee7c6942dfb47ff8703abc7f9cb6bb16e5 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 21 Sep 2025 01:28:33 -0500 Subject: [PATCH 1/4] Fix #47: Use Object Origins for Primitive Reference Points - Fix reference points defaulting to geometric origin of meshes, instead use Blender object Origin. - Enable alpha-sorting via reference point/object origin Fix for 47: https://github.com/BenchmarkSims/bms-blender-plugin/issues/47 --- bms_blender_plugin/common/util.py | 10 ++++++++++ bms_blender_plugin/exporter/parser.py | 22 +++++++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index b2201ea..a97da47 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -273,6 +273,9 @@ def copy_collection_flat( if copied_object: bpy.context.view_layer.objects.active = copied_object bpy.ops.object.mode_set(mode="OBJECT") + + # Single scene update at the end to refresh all transform matrices - attempt to fix nested DOF transforms failing due to Blender quirk + bpy.context.view_layer.update() def reset_dof(obj): @@ -366,6 +369,13 @@ def apply_all_modifiers_on_obj(obj): bpy.ops.object.mode_set(mode="OBJECT") bpy.ops.object.convert(target="MESH", keep_original=False) + # Store the world position before transform application for reference points + if (obj.type == "MESH" and + get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): + # Store the position in a custom property that survives transform_apply + obj["bms_reference_point"] = tuple(obj.location) + + # Apply transforms using original logic (restored) if get_bml_type(obj) not in [ BlenderNodeType.DOF, BlenderNodeType.SLOT, diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index 4ae27bc..d832227 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -54,11 +54,15 @@ def parse_mesh( for obj_vertex in obj_vertices: obj_vertices_data += obj_vertex.to_data() - # DOF children use coordinates local to their DOF - if get_bml_type(obj.parent) == BlenderNodeType.DOF and obj.parent.dof_type != DofType.TRANSLATE.name: - reference_point = to_bms_coords((0, 0, 0)) + # Use stored reference point if available, otherwise fall back to current location. + # Property assigned in util.py - preserves Blender origin to use as reference point for alpha sorting + # All objects now use their origins for reference points, including DOF children + if "bms_reference_point" in obj: + stored_position = Vector(obj["bms_reference_point"]) + reference_point = to_bms_coords(stored_position) else: - reference_point = get_objcenter(obj) + # Fallback for objects without stored reference point + reference_point = to_bms_coords(obj.location) node = Primitive( index=len(nodes), @@ -122,7 +126,15 @@ def parse_bbl_light( for obj_vertex in obj_vertices: obj_vertices_data += obj_vertex.to_data() - reference_point = get_objcenter(obj) + # Use stored reference point if available, otherwise fall back to world translation + # All objects now use their origins for reference points, including DOF children + if "bms_reference_point" in obj: + stored_position = Vector(obj["bms_reference_point"]) + reference_point = to_bms_coords(stored_position) + else: + # Fallback for objects without stored reference point + reference_point = to_bms_coords(obj.matrix_world.translation) + node = Primitive( index=len(nodes), topology=PrimitiveTopology.TRIANGLE_LIST, From c1c10d2f0c467e42b4639138358c66783c9a8eff Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:05:37 -0500 Subject: [PATCH 2/4] Fix #10: DOF/switch/callback.xml Reload Buttons - Reload option for DOF/switch/callback.xml source files added to add-on preferences dialog. - Clears scene/global context/cache then forces reload, avoids persistent cache blocking xml updates. --- bms_blender_plugin/preferences.py | 91 ++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/bms_blender_plugin/preferences.py b/bms_blender_plugin/preferences.py index a0f3982..1a2b5f8 100644 --- a/bms_blender_plugin/preferences.py +++ b/bms_blender_plugin/preferences.py @@ -4,7 +4,83 @@ from bms_blender_plugin.common.blender_types import BlenderNodeType from bms_blender_plugin.common.bml_structs import DofType -from bms_blender_plugin.common.util import get_bml_type +from bms_blender_plugin.common.util import get_bml_type, get_dofs, get_switches, get_callbacks + + +class ReloadDofList(Operator): + """Reload DOF list from DOF.xml file""" + bl_idname = "bml.reload_dof_list" + bl_label = "Reload DOF.xml" + bl_description = "Reload the DOF list from the DOF.xml file. Use this after modifying the XML file" + bl_options = {"REGISTER"} + + def execute(self, context): + # Clear the global cache first + import bms_blender_plugin.common.util as util_module + util_module.dofs = [] + + # Clear the scene cache + context.scene.dof_list.clear() + + # Repopulate the scene cache immediately + for dof in get_dofs(): + item = context.scene.dof_list.add() + item.name = dof.name + item.dof_number = int(dof.dof_number) + + self.report({'INFO'}, f"Reloaded {len(context.scene.dof_list)} DOFs from DOF.xml") + return {'FINISHED'} + + +class ReloadSwitchList(Operator): + """Reload Switch list from switch.xml file""" + bl_idname = "bml.reload_switch_list" + bl_label = "Reload switch.xml" + bl_description = "Reload the Switch list from the switch.xml file. Use this after modifying the XML file" + bl_options = {"REGISTER"} + + def execute(self, context): + # Clear the global cache first + import bms_blender_plugin.common.util as util_module + util_module.switches = [] + + # Clear the scene cache + context.scene.switch_list.clear() + + # Repopulate the scene cache immediately + for switch in get_switches(): + item = context.scene.switch_list.add() + item.name = switch.name + item.switch_number = int(switch.switch_number) + item.branch_number = int(switch.branch) + + self.report({'INFO'}, f"Reloaded {len(context.scene.switch_list)} Switches from switch.xml") + return {'FINISHED'} + + +class ReloadCallbackList(Operator): + """Reload Callback list from callbacks.xml file""" + bl_idname = "bml.reload_callback_list" + bl_label = "Reload callbacks.xml" + bl_description = "Reload the Callback list from the callbacks.xml file. Use this after modifying the XML file" + bl_options = {"REGISTER"} + + def execute(self, context): + # Clear the global cache first + import bms_blender_plugin.common.util as util_module + util_module.callbacks = [] + + # Clear the scene cache + context.scene.bml_all_callbacks.clear() + + # Repopulate the scene cache immediately + for callback in get_callbacks(): + new_callback = context.scene.bml_all_callbacks.add() + new_callback.name = callback.name + new_callback.group = callback.group + + self.report({'INFO'}, f"Reloaded {len(context.scene.bml_all_callbacks)} Callbacks from callbacks.xml") + return {'FINISHED'} class ExporterPreferences(bpy.types.AddonPreferences): @@ -114,6 +190,13 @@ def draw(self, context): box.operator(ApplyEmptyDisplaysToDofs.bl_idname, icon="CHECKMARK") + layout.separator() + layout.label(text="Data Management") + box = layout.box() + box.operator(ReloadDofList.bl_idname, icon="FILE_REFRESH") + box.operator(ReloadSwitchList.bl_idname, icon="FILE_REFRESH") + box.operator(ReloadCallbackList.bl_idname, icon="FILE_REFRESH") + layout.separator() layout.row().label(text="Debug options") layout.row().label(text="Use at your own risk. All options should be OFF by default.", icon="ERROR") @@ -164,6 +247,9 @@ def execute(self, context): def register(): + bpy.utils.register_class(ReloadDofList) + bpy.utils.register_class(ReloadSwitchList) + bpy.utils.register_class(ReloadCallbackList) bpy.utils.register_class(ApplyEmptyDisplaysToDofs) bpy.utils.register_class(ExporterPreferences) @@ -171,3 +257,6 @@ def register(): def unregister(): bpy.utils.unregister_class(ExporterPreferences) bpy.utils.unregister_class(ApplyEmptyDisplaysToDofs) + bpy.utils.unregister_class(ReloadCallbackList) + bpy.utils.unregister_class(ReloadSwitchList) + bpy.utils.unregister_class(ReloadDofList) From 61dd5035e14291b5ba79134466d8b829c6be2a26 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Tue, 23 Sep 2025 02:35:19 -0500 Subject: [PATCH 3/4] Fix 21, Introduce Mesh Batch Processing - Fix #21 - At mesh join, rename active UV layer to "UVMap" if required to prevent data loss - Improve performance in join loop by batching join operation - approx 10% performance increase in scenes heavy with non-mesh nodes, approx 50% in scenes heavy with mesh nodes (not thoroughly tested for actual performance gains, but it's definitely better :) ) --- bms_blender_plugin/exporter/bml_mesh.py | 2 + bms_blender_plugin/exporter/export_lods.py | 113 ++++++++++++++------- 2 files changed, 81 insertions(+), 34 deletions(-) diff --git a/bms_blender_plugin/exporter/bml_mesh.py b/bms_blender_plugin/exporter/bml_mesh.py index 7bcd93a..ec05817 100644 --- a/bms_blender_plugin/exporter/bml_mesh.py +++ b/bms_blender_plugin/exporter/bml_mesh.py @@ -64,6 +64,8 @@ def get_bml_mesh_data(obj, max_vertex_index): world_normal = world_coord.inverted_safe().transposed().to_3x3() + + for face in mesh.polygons: # loop over face loop for vert in [mesh.loops[i] for i in face.loop_indices]: diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 781ed96..6b306d8 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -1,3 +1,10 @@ +""" +Performance Notes: +- Material batching optimization gives ~10-12% improvement in DOF/switch heavy scenes +- Mesh-heavy scenes should see higher gains (~50%?) +- Further perf improvements: batch DOF processing, reduce object selection calls +""" + import os import struct @@ -300,17 +307,22 @@ def _recursively_parse_nodes(objects): def join_objects_with_same_materials(objects, materials_objects, auto_smooth_value): """Joins objects of the same BML node level (i.e. not separated by DOFs, Switches or Slots) to a single Blender object. This is critical to reduce draw calls""" + + # Instead of joining objects one-by-one, we first group them + # by material, then batch join all objects with the same material in a single operation. + object_names = [] for obj in objects: if obj: object_names.append(obj.name) + # Step 1: Categorize objects and prepare light data (no joining yet) + mesh_objects_by_material = {} # List of obj for batch join + for obj_name in object_names: obj = bpy.data.objects[obj_name] - if obj.type == "MESH": - # regular meshes # "do not merge" flag - just use a custom material name which will never be looked up # enhance this by including BBOXs in the do not merge category - Otherwise joined objects will not render if obj.bml_do_not_merge or get_bml_type(obj) == BlenderNodeType.BBOX: @@ -330,7 +342,6 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val # before we join the lights into a common object, we need to store their individual object values in # separate face variables, so we can create their vertices later # the keys of all stored values is their face index - if get_bml_type(obj) == BlenderNodeType.PBR_LIGHT: # make sure we only join lights with other lights - simply change the key material_name = "BML_BBL_" + material_name @@ -357,7 +368,6 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val layer_normal_x.data[face.index].value = face.normal.x layer_normal_y.data[face.index].value = face.normal.y layer_normal_z.data[face.index].value = face.normal.z - else: # omnidirectional layer_normal_x.data[face.index].value = 0 @@ -370,35 +380,10 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val layer_color_b.data[face.index].value = obj.color[2] layer_color_a.data[face.index].value = obj.color[3] - object_with_same_material_list = materials_objects.get(material_name) - - # join objects with the same material name - if object_with_same_material_list is not None: - if len(object_with_same_material_list) != 1: - raise Exception("Invalid length of material list objects") - - object_with_same_material = object_with_same_material_list[0] - - # force autosmooth on the objects to be merged (reason: when joining, Blender will override the - # smoothing options to the last object selected) - if ( - object_with_same_material.data.use_auto_smooth - or obj.data.use_auto_smooth - ): - force_auto_smoothing_on_object( - object_with_same_material, auto_smooth_value - ) - force_auto_smoothing_on_object(obj, auto_smooth_value) - - bpy.ops.object.select_all(action="DESELECT") - obj.select_set(True) - object_with_same_material.select_set(True) - bpy.context.view_layer.objects.active = object_with_same_material - bpy.ops.object.join() - - else: - # no entries found, add material and obj as new entries - materials_objects[material_name] = [obj] + # Group mesh objects by material for batch processing + if material_name not in mesh_objects_by_material: + mesh_objects_by_material[material_name] = [] + mesh_objects_by_material[material_name].append(obj) # make sure that DOFs, Switches and Slots are never joined elif obj.type == "EMPTY" and ( @@ -413,5 +398,65 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val elif obj.type == "EMPTY": # add default empties as well so their children can be parsed materials_objects["_EMPTY_" + obj.name] = [obj] - + + # Step 2: Batch join - one join per material group instead of one join per object pair + for material_name, objects_with_same_material in mesh_objects_by_material.items(): + if len(objects_with_same_material) == 1: + # Single object with this material, no joining needed + materials_objects[material_name] = objects_with_same_material + else: + # Multiple objects with same material - batch join them all at once + print(f"Batch join {len(objects_with_same_material)} objects, material: '{material_name}'") + + # Fix UV layer preservation during join (Issue #21) + # Blender's join operation looks for "UVMap" specifically + for obj in objects_with_same_material: + if len(obj.data.uv_layers) == 0: + continue # No UV layers, nothing to do + + # Ensure we have an active layer + if not obj.data.uv_layers.active: + obj.data.uv_layers.active_index = 0 + print(f"⚠️ Warning: Object '{obj.name}' has no active UV map, setting first layer as active") + + # Already correct, no processing needed + if obj.data.uv_layers.active.name == "UVMap": + continue + + # Needs to be renamed: delete all other layers and rename active to "UVMap" + active_layer = obj.data.uv_layers.active + print(f"⚠️ Warning: Object '{obj.name}' UV map incorrectly named '{active_layer.name}'") + layers_to_remove = [layer for layer in obj.data.uv_layers if layer != active_layer] + for layer in layers_to_remove: + obj.data.uv_layers.remove(layer) + print(f"⚠️ Warning: Renaming object '{obj.name}' active UV map to: UVMap") + # Get fresh reference after removals to avoid stale reference + obj.data.uv_layers.active.name = "UVMap" + + # force autosmooth on all objects to be merged (reason: when joining, Blender will override the + # smoothing options to the last object selected) + # Check if ANY object in this group has auto_smooth enabled + any_object_has_auto_smooth = any(obj.data.use_auto_smooth for obj in objects_with_same_material) + + if any_object_has_auto_smooth: + # If ANY object has auto_smooth, apply it to ALL objects in the group (original behavior) + for obj in objects_with_same_material: + if not obj.data.use_auto_smooth: + print(f"⚠️ Warning: Object '{obj.name}' does not have auto-smoothing enabled but will be forced to match other objects in material group '{material_name}'") + force_auto_smoothing_on_object(obj, auto_smooth_value) + + # Select all objects with this material at once + bpy.ops.object.select_all(action="DESELECT") + for obj in objects_with_same_material: + obj.select_set(True) + + # Set first object as active (target for join operation) + bpy.context.view_layer.objects.active = objects_with_same_material[0] + + # Perform ONE join operation for all objects with this material + bpy.ops.object.join() + + # Store the joined result (first object now contains all the merged geometry) + materials_objects[material_name] = [objects_with_same_material[0]] + return [item for sublist in materials_objects.values() for item in sublist] From bcce027ccc23f99330549957f89150ab0d6c74d0 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Wed, 24 Sep 2025 04:14:01 -0500 Subject: [PATCH 4/4] UV layer preservation fix Attempt to strengthen handling of UV maps in join operations, particularly use names to avoid stale reference --- bms_blender_plugin/exporter/export_lods.py | 72 +++++++++++++++------- 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 6b306d8..c500a63 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -409,29 +409,57 @@ def join_objects_with_same_materials(objects, materials_objects, auto_smooth_val print(f"Batch join {len(objects_with_same_material)} objects, material: '{material_name}'") # Fix UV layer preservation during join (Issue #21) - # Blender's join operation looks for "UVMap" specifically + # Blender's join tends to favor a layer literally named "UVMap". Keep exactly one primary UV layer. + # Exporter only uses a single UV layer, so we can safely collapse multiples. for obj in objects_with_same_material: - if len(obj.data.uv_layers) == 0: - continue # No UV layers, nothing to do - - # Ensure we have an active layer - if not obj.data.uv_layers.active: - obj.data.uv_layers.active_index = 0 - print(f"⚠️ Warning: Object '{obj.name}' has no active UV map, setting first layer as active") - - # Already correct, no processing needed - if obj.data.uv_layers.active.name == "UVMap": - continue - - # Needs to be renamed: delete all other layers and rename active to "UVMap" - active_layer = obj.data.uv_layers.active - print(f"⚠️ Warning: Object '{obj.name}' UV map incorrectly named '{active_layer.name}'") - layers_to_remove = [layer for layer in obj.data.uv_layers if layer != active_layer] - for layer in layers_to_remove: - obj.data.uv_layers.remove(layer) - print(f"⚠️ Warning: Renaming object '{obj.name}' active UV map to: UVMap") - # Get fresh reference after removals to avoid stale reference - obj.data.uv_layers.active.name = "UVMap" + uv_layers = obj.data.uv_layers + if len(uv_layers) == 0: + continue # No UV layers, nothing to normalize + + # Ensure some layer is active + if not uv_layers.active: + uv_layers.active_index = 0 + print(f"[BML Export] Warning: Object '{obj.name}' had no active UV layer; first layer set active") + + # Prefer an existing primary layer actually named "UVMap" if present + primary_layer = uv_layers.get("UVMap") + if primary_layer is not None: + # Make sure it's the active layer for downstream ops + for i, layer in enumerate(uv_layers): + if layer == primary_layer: + uv_layers.active_index = i + break + else: + # No layer named "UVMap"; use the active layer as the primary and rename it + primary_layer = uv_layers.active + if primary_layer.name != "UVMap": + print(f"📝 Info: Renaming active UV layer '{primary_layer.name}' on '{obj.name}' to 'UVMap'") + primary_layer.name = "UVMap" + + # Remove ALL other layers (exporter uses only one); collect NAMES first so we can re-resolve + removable_names = [layer.name for layer in uv_layers if layer.name != "UVMap"] + for lname in removable_names: + # Re-fetch by name to avoid stale pointer if Blender reallocated internally + layer_obj = uv_layers.get(lname) + if layer_obj is None: + # Already removed/renamed by previous operation + continue + try: + uv_layers.remove(layer_obj) + except RuntimeError as e: + print(f"⚠️ Warning: Failed to remove secondary UV layer '{lname}' from '{obj.name}': {e}") + + # Safety check + if uv_layers.active is None or uv_layers.active.name != "UVMap": + # If something unexpected happened, fall back to first layer and rename + if len(uv_layers): + uv_layers.active_index = 0 + if uv_layers.active and uv_layers.active.name != "UVMap": + try: + uv_layers.active.name = "UVMap" + except Exception: + pass + print(f"📝 Info: Repaired primary UVMap layer on '{obj.name}' after cleanup") # force autosmooth on all objects to be merged (reason: when joining, Blender will override the # smoothing options to the last object selected)