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/9] 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/9] 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/9] 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 a332ac051d6d834a01d3bdd8d9f80a64bb08a598 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Wed, 24 Sep 2025 03:42:46 -0500 Subject: [PATCH 4/9] WIP: Persistent DOF/Switch IDs (#51) - Introduce persistent properties for DOF and Switch numbers/branches - Retain backward compatibility for list indices but new method is invariant to xml updates and allows manual switch/dof value editing similar to MAX plugin - Updates to UI panels: Persistent IDs for DOF, Switch/Branch, and auto assignment options for current/collection/scene to set those properties (as plugin operators.) Displays (Switch:Branch) in both UI panel list and object name to quickly identify branches. --- bms_blender_plugin/common/constants.py | 22 ++ .../exporter/export_parent_dat.py | 48 +-- bms_blender_plugin/exporter/parser.py | 51 ++- bms_blender_plugin/ui_tools/dof_behaviour.py | 49 ++- .../ui_tools/operators/__init__.py | 145 +++++++- .../ui_tools/operators/assign_from_index.py | 343 ++++++++++++++++++ .../ui_tools/panels/dof_panel.py | 40 +- .../ui_tools/panels/switch_panel.py | 42 ++- 8 files changed, 690 insertions(+), 50 deletions(-) create mode 100644 bms_blender_plugin/common/constants.py create mode 100644 bms_blender_plugin/ui_tools/operators/assign_from_index.py diff --git a/bms_blender_plugin/common/constants.py b/bms_blender_plugin/common/constants.py new file mode 100644 index 0000000..21add2e --- /dev/null +++ b/bms_blender_plugin/common/constants.py @@ -0,0 +1,22 @@ +"""Acts as a header for constants, etc + +Purpose: +- Avoid literals... :) + +Notes: + +""" + +# Maximum allowable (inclusive) identifier values for DOFs and Switches. +# Previously 255. Used in __init__.py (object intproperty limits) and export_parent_dat.py (cap highest number) +BMS_MAX_SWITCH_NUMBER: int = 2048 +BMS_MAX_SWITCH_BRANCH: int = 2048 # Branch uses same bound currently +BMS_MAX_DOF_NUMBER: int = 2048 + +# Not recommended but available if someone were to import with wildcard eg. from bms_blender_plugin.common.constants import * +# If adding above, also add them here if they should be available as if "public" +__all__ = [ + "BMS_MAX_SWITCH_NUMBER", + "BMS_MAX_SWITCH_BRANCH", + "BMS_MAX_DOF_NUMBER", +] diff --git a/bms_blender_plugin/exporter/export_parent_dat.py b/bms_blender_plugin/exporter/export_parent_dat.py index 752ea9c..f9a3c55 100644 --- a/bms_blender_plugin/exporter/export_parent_dat.py +++ b/bms_blender_plugin/exporter/export_parent_dat.py @@ -8,6 +8,10 @@ get_dofs, get_bounding_sphere, ) +from bms_blender_plugin.common.constants import ( + BMS_MAX_SWITCH_NUMBER, + BMS_MAX_DOF_NUMBER, +) from bms_blender_plugin.common.coordinates import to_bms_coords @@ -16,34 +20,36 @@ def get_highest_switch_and_dof_number(objs): # default value 0 to prevent editor crashing highest_switch_number = 0 highest_dof_number = 0 - BMS_MAX_VALUE = 2048 for obj in objs: if len(obj.children) > 0: if get_bml_type(obj) == BlenderNodeType.SWITCH: - try: - switch = get_switches()[obj.switch_list_index] - """parent.dat requires max(switch)+1 to function correctly due to a = vs <= issue in the BMS code. - This Should be resolved for 4.38.""" - required_switch_index = switch.switch_number+1 - if required_switch_index > highest_switch_number: - highest_switch_number = required_switch_index - except IndexError: - raise IndexError(f"Switch index {obj.switch_list_index} not found in switch.xml. Object: {obj.name}. Please update XML files and reload switch list.") + # Prefer persistent properties + switch_number = getattr(obj, "bml_switch_number", -1) + if switch_number is None or switch_number < 0: + try: + sw = get_switches()[obj.switch_list_index] + switch_number = sw.switch_number + except Exception: + switch_number = 0 + required_switch_index = switch_number + 1 # parent.dat off-by-one requirement + if required_switch_index > highest_switch_number: + highest_switch_number = required_switch_index elif get_bml_type(obj) == BlenderNodeType.DOF: - try: - dof = get_dofs()[obj.dof_list_index] - """parent.dat requires max(dof)+1 to function correctly due to a = vs <= issue in the BMS code. - This Should be resolved for 4.38.""" - required_dof_index = dof.dof_number+1 - if required_dof_index > highest_dof_number: - highest_dof_number = required_dof_index - except IndexError: - raise IndexError(f"DOF index {obj.dof_list_index} not found in dof.xml. Object: {obj.name}. Please update XML files and reload DOF list.") + dof_number = getattr(obj, "bml_dof_number", -1) + if dof_number is None or dof_number < 0: + try: + dof_enum = get_dofs()[obj.dof_list_index] + dof_number = dof_enum.dof_number + except Exception: + dof_number = 0 + required_dof_index = dof_number + 1 + if required_dof_index > highest_dof_number: + highest_dof_number = required_dof_index # Cap values at BMS maximum - highest_switch_number = min(highest_switch_number, BMS_MAX_VALUE) - highest_dof_number = min(highest_dof_number, BMS_MAX_VALUE) + highest_switch_number = min(highest_switch_number, BMS_MAX_SWITCH_NUMBER) + highest_dof_number = min(highest_dof_number, BMS_MAX_DOF_NUMBER) return highest_switch_number, highest_dof_number diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index d832227..8bf3352 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -183,21 +183,58 @@ def parse_slot(obj, nodes): def parse_switch(obj, nodes): - """Adds a BML Switch to the BML node list""" + """Adds a BML Switch to the BML node list. + Uses persistent properties (bml_switch_number / bml_switch_branch) when present, otherwise falls back to legacy index lookup.""" print(f"{obj.name} is a SWITCH") - switch = get_switches()[obj.switch_list_index] + persistent_number = getattr(obj, "bml_switch_number", -1) + persistent_branch = getattr(obj, "bml_switch_branch", -1) + if persistent_number is None: + persistent_number = -1 + if persistent_branch is None: + persistent_branch = -1 + + if persistent_number >= 0 and persistent_branch >= 0: + switch_number = persistent_number + branch = persistent_branch + else: + # Legacy fallback + try: + sw_enum = get_switches()[obj.switch_list_index] + switch_number = sw_enum.switch_number + branch = sw_enum.branch + except Exception: + switch_number = 0 + branch = 0 + nodes.append( - Switch(len(nodes), switch.switch_number, switch.branch, obj.switch_default_on) + Switch(len(nodes), switch_number, branch, obj.switch_default_on) ) return ParsedNodes(vertex_data=[], vertices_length=0, vertices_size=0) def parse_dof(obj, nodes): - """Adds a BML DOF to the BML node list""" + """Adds a BML DOF to the BML node list. + Uses persistent property (bml_dof_number) when present, otherwise falls back to legacy index lookup.""" print(f"{obj.name} is a DOF") - # add the DOF start node - - dof = get_dofs()[obj.dof_list_index] + # Determine DOF enum/number + persistent_number = getattr(obj, "bml_dof_number", -1) + if persistent_number is None: + persistent_number = -1 + if persistent_number >= 0: + class _TmpDof: # minimal shim to satisfy downstream attribute access + def __init__(self, dof_number): + self.dof_number = dof_number + self.name = f"DOF {dof_number}" + dof = _TmpDof(persistent_number) + else: + try: + dof = get_dofs()[obj.dof_list_index] + except Exception: + class _TmpDof: + def __init__(self): + self.dof_number = 0 + self.name = "DOF 0" + dof = _TmpDof() obj_orig_rotation_mode = obj.rotation_mode obj.rotation_mode = "QUATERNION" diff --git a/bms_blender_plugin/ui_tools/dof_behaviour.py b/bms_blender_plugin/ui_tools/dof_behaviour.py index 9ec19b8..178ab99 100644 --- a/bms_blender_plugin/ui_tools/dof_behaviour.py +++ b/bms_blender_plugin/ui_tools/dof_behaviour.py @@ -119,13 +119,50 @@ def update_switch_or_dof_name(obj, context): """Updates the name of a DOF or Switch when their respective DOF/Switch values are changed. Overwrites any previous name updates by the user.""" if get_bml_type(obj) == BlenderNodeType.SWITCH: - active_switch = get_switches()[obj.switch_list_index] - obj.name = f"Switch - {active_switch.name} ({active_switch.switch_number})" + # Prefer persistent properties + sw_num = getattr(obj, "bml_switch_number", -1) + sw_branch = getattr(obj, "bml_switch_branch", -1) + label_name = None + if sw_num is not None and sw_num >= 0 and sw_branch is not None and sw_branch >= 0: + # Try to find matching enum (to display its name) but tolerate absence + try: + for sw in get_switches(): + if sw.switch_number == sw_num and sw.branch == sw_branch: + label_name = sw.name + break + except Exception: + pass + if label_name is None: + label_name = "Custom" + obj.name = f"Switch - {label_name} ({sw_num}:{sw_branch})" + else: + # Legacy fallback + try: + active_switch = get_switches()[obj.switch_list_index] + obj.name = f"Switch - {active_switch.name} ({active_switch.switch_number})" + except Exception: + obj.name = "Switch - Unset" elif get_bml_type(obj) == BlenderNodeType.DOF: - active_dof = get_dofs()[obj.dof_list_index] - - name = f"DOF - {active_dof.name} ({active_dof.dof_number})" - obj.name = name + dof_num = getattr(obj, "bml_dof_number", -1) + if dof_num is not None and dof_num >= 0: + # Try resolve name for consistency + dof_name = None + try: + for de in get_dofs(): + if de.dof_number == dof_num: + dof_name = de.name + break + except Exception: + pass + if dof_name is None: + dof_name = "Custom" + obj.name = f"DOF - {dof_name} ({dof_num})" + else: + try: + active_dof = get_dofs()[obj.dof_list_index] + obj.name = f"DOF - {active_dof.name} ({active_dof.dof_number})" + except Exception: + obj.name = "DOF - Unset" for tree in bpy.data.node_groups.values(): if isinstance(tree, nodes_editor.dof_editor.DofNodeTree): diff --git a/bms_blender_plugin/ui_tools/operators/__init__.py b/bms_blender_plugin/ui_tools/operators/__init__.py index a5b1d15..4c2ce81 100644 --- a/bms_blender_plugin/ui_tools/operators/__init__.py +++ b/bms_blender_plugin/ui_tools/operators/__init__.py @@ -9,6 +9,96 @@ update_switch_or_dof_name, dof_set_input, dof_get_input, ) from bms_blender_plugin.ui_tools.slot_behaviour import update_slot_number +from bms_blender_plugin.common.util import get_switches, get_dofs +from bms_blender_plugin.common.constants import ( + BMS_MAX_SWITCH_NUMBER, + BMS_MAX_SWITCH_BRANCH, + BMS_MAX_DOF_NUMBER, +) + + +def _update_switch_list_index(obj, context): + """Whenever the list index changes, force persistent switch number/branch to match the selected XML entry.""" + try: + switches = get_switches() + if 0 <= obj.switch_list_index < len(switches): + sw = switches[obj.switch_list_index] + obj.bml_switch_number = sw.switch_number + obj.bml_switch_branch = sw.branch + except Exception: + pass + update_switch_or_dof_name(obj, context) + + +def _update_dof_list_index(obj, context): + """Whenever the list index changes, force persistent DOF number to match the selected XML entry.""" + try: + dofs = get_dofs() + if 0 <= obj.dof_list_index < len(dofs): + de = dofs[obj.dof_list_index] + obj.bml_dof_number = de.dof_number + except Exception: + pass + update_switch_or_dof_name(obj, context) + + + +# Keep legacy list index in sync when persistent switch IDs are edited manually +def _update_persistent_switch_ids(obj, context): + """When user edits persistent switch number/branch, update switch_list_index to matching XML entry if found. + If both IDs are -1 (not set), leave index unchanged for backward compatibility. + """ + update_switch_or_dof_name(obj, context) + def _tag_redraw(ctx): + try: + if ctx and ctx.screen: + for area in ctx.screen.areas: + area.tag_redraw() + except Exception: + pass + + try: + sw_num = getattr(obj, "bml_switch_number", -1) + sw_branch = getattr(obj, "bml_switch_branch", -1) + if sw_num >= 0 and sw_branch >= 0: + switches = get_switches() + for i, sw in enumerate(switches): + if sw.switch_number == sw_num and sw.branch == sw_branch: + if getattr(obj, "switch_list_index", -1) != i: + # Will not recurse persistent update since indices handler only sets IDs if unset + obj.switch_list_index = i + _tag_redraw(context) + break + # If either is unset (<0), do nothing: legacy index remains visible + except Exception: + pass + +# Keep legacy list index in sync when persistent DOF ID is edited manually +def _update_persistent_dof_number(obj, context): + """When user edits persistent DOF number, update dof_list_index to matching XML entry if found. + If ID is -1 (not set), leave index unchanged. + """ + update_switch_or_dof_name(obj, context) + def _tag_redraw(ctx): + try: + if ctx and ctx.screen: + for area in ctx.screen.areas: + area.tag_redraw() + except Exception: + pass + + try: + dof_num = getattr(obj, "bml_dof_number", -1) + if dof_num >= 0: + dofs = get_dofs() + for i, de in enumerate(dofs): + if de.dof_number == dof_num: + if getattr(obj, "dof_list_index", -1) != i: + obj.dof_list_index = i + _tag_redraw(context) + break + except Exception: + pass def register_blender_properties(): @@ -69,15 +159,42 @@ def register_blender_properties(): # Switches bpy.types.Object.switch_list_index = bpy.props.IntProperty( - name="Index for switch_list", default=0, update=update_switch_or_dof_name + name="Index for switch_list", default=0, update=_update_switch_list_index ) bpy.types.Object.switch_default_on = bpy.props.BoolProperty( name="Default ON", description="The switch is ON by default", default=False ) + # Persistent switch number & branch (new). -1 => unset (legacy scenes) + bpy.types.Object.bml_switch_number = bpy.props.IntProperty( + name="Switch #", + description="Persistent switch number used for export (independent of switch.xml ordering)", + default=-1, + min=-1, + max=BMS_MAX_SWITCH_NUMBER, + update=_update_persistent_switch_ids, + ) + bpy.types.Object.bml_switch_branch = bpy.props.IntProperty( + name="Branch #", + description="Persistent branch number used for export (independent of switch.xml ordering)", + default=-1, + min=-1, + max=BMS_MAX_SWITCH_BRANCH, + update=_update_persistent_switch_ids, + ) # DOFs bpy.types.Object.dof_list_index = bpy.props.IntProperty( - name="Index for dof_list", default=0, update=update_switch_or_dof_name + name="Index for dof_list", default=0, update=_update_dof_list_index + ) + + # Persistent DOF number (new) + bpy.types.Object.bml_dof_number = bpy.props.IntProperty( + name="DOF #", + description="Persistent DOF number used for export (independent of DOF.xml ordering)", + default=-1, + min=-1, + max=BMS_MAX_DOF_NUMBER, + update=_update_persistent_dof_number, ) bpy.types.Object.dof_type = bpy.props.EnumProperty( @@ -194,5 +311,29 @@ def register_blender_properties(): update=dof_update_input, ) + # Migration: fill persistent properties for legacy scenes + try: + for obj in bpy.data.objects: + if getattr(obj, "bml_switch_number", -1) < 0 and hasattr(obj, "switch_list_index"): + try: + switches = get_switches() + if 0 <= obj.switch_list_index < len(switches): + sw = switches[obj.switch_list_index] + obj.bml_switch_number = sw.switch_number + obj.bml_switch_branch = sw.branch + except Exception: + pass + if getattr(obj, "bml_dof_number", -1) < 0 and hasattr(obj, "dof_list_index"): + try: + dofs = get_dofs() + if 0 <= obj.dof_list_index < len(dofs): + de = dofs[obj.dof_list_index] + obj.bml_dof_number = de.dof_number + except Exception: + pass + update_switch_or_dof_name(obj, None) + except Exception: + pass + register_blender_properties() diff --git a/bms_blender_plugin/ui_tools/operators/assign_from_index.py b/bms_blender_plugin/ui_tools/operators/assign_from_index.py new file mode 100644 index 0000000..4816e6a --- /dev/null +++ b/bms_blender_plugin/ui_tools/operators/assign_from_index.py @@ -0,0 +1,343 @@ +import bpy + +from bms_blender_plugin.common.util import get_switches, get_dofs, get_bml_type, get_parent_dof_or_switch +from bms_blender_plugin.common.blender_types import BlenderNodeType +from bms_blender_plugin.ui_tools.dof_behaviour import update_switch_or_dof_name + + +class BML_OT_assign_switch_from_index(bpy.types.Operator): + # Populate persistent Switch number and branch from the currently selected list entry - useful if object was created before persistent ID properties added + # If the index out of range, nothing changed and a warning report issued + + bl_idname = "bml.assign_switch_from_index" + bl_label = "Assign from Index" + bl_description = ( + "Assign persistent Switch Number and Branch from the current switch list selection. " + "Uses switch_list_index; overwrites existing persistent IDs." + ) + bl_options = {"UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + return False + target = get_parent_dof_or_switch(obj) + return target is not None and get_bml_type(target) == BlenderNodeType.SWITCH + + def execute(self, context): + obj = get_parent_dof_or_switch(context.active_object) + switches = get_switches() + idx = getattr(obj, "switch_list_index", -1) + if 0 <= idx < len(switches): + sw = switches[idx] + obj.bml_switch_number = sw.switch_number + obj.bml_switch_branch = sw.branch + update_switch_or_dof_name(obj, context) + self.report({'INFO'}, f"Assigned Switch #{sw.switch_number} Branch {sw.branch} from index {idx}") + return {'FINISHED'} + self.report({'WARNING'}, ( + f"Switch list index {idx} out of range; no assignment performed. " + f"List may be stale or truncated – reload switch.xml (disable/enable addon) or refresh definitions." + )) + return {'CANCELLED'} + + +class BML_OT_assign_dof_from_index(bpy.types.Operator): + # Populate persistent DOF number from the currently selected list entry - useful if object was created before persistent ID properties added + + bl_idname = "bml.assign_dof_from_index" + bl_label = "Assign from Index" + bl_description = ( + "Assign persistent DOF Number from the current DOF list selection. " + "Uses dof_list_index; overwrites existing persistent ID." + ) + bl_options = {"UNDO"} + + @classmethod + def poll(cls, context): + obj = context.active_object + if not obj: + return False + target = get_parent_dof_or_switch(obj) + return target is not None and get_bml_type(target) == BlenderNodeType.DOF + + def execute(self, context): + obj = get_parent_dof_or_switch(context.active_object) + dofs = get_dofs() + idx = getattr(obj, "dof_list_index", -1) + if 0 <= idx < len(dofs): + de = dofs[idx] + obj.bml_dof_number = de.dof_number + update_switch_or_dof_name(obj, context) + self.report({'INFO'}, f"Assigned DOF #{de.dof_number} from index {idx}") + return {'FINISHED'} + self.report({'WARNING'}, ( + f"DOF list index {idx} out of range; no assignment performed. " + f"List may be stale or truncated – reload DOF.xml (disable/enable addon) or refresh definitions." + )) + return {'CANCELLED'} + + +class BML_OT_reassign_all_ids(bpy.types.Operator): + # Batch assign persistent switch/dof ID/branch from current list indices (convert legacy index-based method to persistent property method) + # Scope: entire scene or only active collection hierarchy + # Reassign each for consistency + + + bl_idname = "bml.reassign_all_ids" + bl_label = "Re-Assign All IDs" + bl_description = ( + "Batch assign persistent Switch / DOF IDs from current list indices across the chosen scope. " + "Overwrites existing persistent IDs. Use wrapper operators in UI for specific targets." + ) + bl_options = {"UNDO"} + + scope = bpy.props.EnumProperty( + name="Scope", + items=( + ("SCENE", "Whole Scene", "Process every object in the scene"), + ("ACTIVE_COLLECTION", "Active Collection", "Process only objects in the active collection (recursive)"), + ), + default="SCENE", + ) + + target_types = bpy.props.EnumProperty( + name="Target", + items=( + ("BOTH", "Switches & DOFs", "Assign both"), + ("SWITCH", "Switches Only", "Assign only switches"), + ("DOF", "DOFs Only", "Assign only dofs"), + ), + default="BOTH", + ) + + confirm = bpy.props.BoolProperty(default=True) + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def _collect_objects(self, context): + if self.scope == "SCENE": + return list(context.scene.objects) + # ACTIVE_COLLECTION path + coll = context.view_layer.active_layer_collection.collection if context.view_layer.active_layer_collection else None + if not coll: + return [] + result = set() + + def _recurse(c): + for obj in c.objects: + result.add(obj) + for child in c.children: + _recurse(child) + + _recurse(coll) + return list(result) + + def execute(self, context): + switches_enum = get_switches() + dofs_enum = get_dofs() + processed_switches = 0 + processed_dofs = 0 + objs = self._collect_objects(context) + for obj in objs: + bml_type = get_bml_type(obj) + if self.target_types in {"BOTH", "SWITCH"} and bml_type == BlenderNodeType.SWITCH: + idx = getattr(obj, "switch_list_index", -1) + if 0 <= idx < len(switches_enum): + sw = switches_enum[idx] + obj.bml_switch_number = sw.switch_number + obj.bml_switch_branch = sw.branch + update_switch_or_dof_name(obj, context) + processed_switches += 1 + if self.target_types in {"BOTH", "DOF"} and bml_type == BlenderNodeType.DOF: + idx = getattr(obj, "dof_list_index", -1) + if 0 <= idx < len(dofs_enum): + de = dofs_enum[idx] + obj.bml_dof_number = de.dof_number + update_switch_or_dof_name(obj, context) + processed_dofs += 1 + self.report({'INFO'}, f"Re-assigned IDs - Switches: {processed_switches}, DOFs: {processed_dofs}") + return {'FINISHED'} + + +# --------------------------------------------------------------------------- +# Internal shared helper for wrapper batch operators (simpler popup usage) +# --------------------------------------------------------------------------- +def _batch_reassign(context, scope: str, target: str): + switches_enum = get_switches() + dofs_enum = get_dofs() + processed_switches = 0 + processed_dofs = 0 + + def collect(scope_mode): + if scope_mode == "SCENE": + return list(context.scene.objects) + coll = context.view_layer.active_layer_collection.collection if context.view_layer.active_layer_collection else None + if not coll: + return [] + result = set() + def _rec(c): + for o in c.objects: + result.add(o) + for ch in c.children: + _rec(ch) + _rec(coll) + return list(result) + + objs = collect(scope) + for obj in objs: + bml_type = get_bml_type(obj) + if target in {"SWITCH", "BOTH"} and bml_type == BlenderNodeType.SWITCH: + idx = getattr(obj, "switch_list_index", -1) + if 0 <= idx < len(switches_enum): + sw = switches_enum[idx] + obj.bml_switch_number = sw.switch_number + obj.bml_switch_branch = sw.branch + update_switch_or_dof_name(obj, context) + processed_switches += 1 + if target in {"DOF", "BOTH"} and bml_type == BlenderNodeType.DOF: + idx = getattr(obj, "dof_list_index", -1) + if 0 <= idx < len(dofs_enum): + de = dofs_enum[idx] + obj.bml_dof_number = de.dof_number + update_switch_or_dof_name(obj, context) + processed_dofs += 1 + return processed_switches, processed_dofs + + +class BML_OT_reassign_switches_scene(bpy.types.Operator): + bl_idname = "bml.reassign_switches_scene" + bl_label = "Re-Assign All Switches (Scene)" + bl_description = ( + "Batch assign persistent IDs for every SWITCH in the entire scene " + "from its switch_list_index." + ) + bl_options = {"UNDO"} + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + ps, _ = _batch_reassign(context, "SCENE", "SWITCH") + self.report({'INFO'}, f"Re-assigned {ps} switches (scene)") + return {'FINISHED'} + + +class BML_OT_reassign_switches_collection(bpy.types.Operator): + bl_idname = "bml.reassign_switches_collection" + bl_label = "Re-Assign All Switches (Active Collection)" + bl_description = ( + "Batch assign persistent IDs for every SWITCH in active collection (recursive) " + "from their switch_list_index." + ) + bl_options = {"UNDO"} + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + ps, _ = _batch_reassign(context, "ACTIVE_COLLECTION", "SWITCH") + self.report({'INFO'}, f"Re-assigned {ps} switches (active collection)") + return {'FINISHED'} + + +class BML_OT_reassign_dofs_scene(bpy.types.Operator): + bl_idname = "bml.reassign_dofs_scene" + bl_label = "Re-Assign All DOFs (Scene)" + bl_description = ( + "Batch assign persistent ID for every DOF in the entire scene from its dof_list_index." + ) + bl_options = {"UNDO"} + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + _, pd = _batch_reassign(context, "SCENE", "DOF") + self.report({'INFO'}, f"Re-assigned {pd} DOFs (scene)") + return {'FINISHED'} + + +class BML_OT_reassign_dofs_collection(bpy.types.Operator): + bl_idname = "bml.reassign_dofs_collection" + bl_label = "Re-Assign All DOFs (Active Collection)" + bl_description = ( + "Batch assign persistent ID for DOFs under the active collection (recursive) from dof_list_index." + ) + bl_options = {"UNDO"} + + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + + def execute(self, context): + _, pd = _batch_reassign(context, "ACTIVE_COLLECTION", "DOF") + self.report({'INFO'}, f"Re-assigned {pd} DOFs (active collection)") + return {'FINISHED'} + + +class BML_OT_assign_switch_popup(bpy.types.Operator): + # Popup to choose scope for assigning switch persistent IDs. + bl_idname = "bml.assign_switch_popup" + bl_label = "Assign Switch IDs" + bl_description = ( + "Assign persistent IDs for switch(es)." + ) + + def invoke(self, context, event): + def draw_fn(self_, ctx): + self_.layout.label(text="Assign persistent Switch IDs:") + col = self_.layout.column(align=True) + col.operator("bml.assign_switch_from_index", text="This Switch Only", icon='OBJECT_DATA') + col.operator("bml.reassign_switches_scene", icon='SEQUENCE_COLOR_04') + col.operator("bml.reassign_switches_collection", icon='SEQUENCE_COLOR_02') + self_.layout.separator() + self_.layout.label(text="Esc or click outside to cancel") + context.window_manager.popup_menu(draw_fn, title="Switch ID Assignment", icon='OUTLINER_OB_EMPTY') + return {'FINISHED'} + + +class BML_OT_assign_dof_popup(bpy.types.Operator): + # Popup to choose scope for assigning DOF persistent IDs. + bl_idname = "bml.assign_dof_popup" + bl_label = "Assign DOF IDs" + bl_description = ( + "Assign persistent IDs for DOF(s)." + ) + + def invoke(self, context, event): + def draw_fn(self_, ctx): + self_.layout.label(text="Assign persistent DOF IDs:") + col = self_.layout.column(align=True) + col.operator("bml.assign_dof_from_index", text="This DOF Only", icon='EMPTY_ARROWS') + col.operator("bml.reassign_dofs_scene", icon='SEQUENCE_COLOR_04') + col.operator("bml.reassign_dofs_collection", icon='SEQUENCE_COLOR_02') + self_.layout.separator() + self_.layout.label(text="Esc or click outside to cancel") + context.window_manager.popup_menu(draw_fn, title="DOF ID Assignment", icon='EMPTY_ARROWS') + return {'FINISHED'} + + +def register(): + bpy.utils.register_class(BML_OT_assign_switch_from_index) + bpy.utils.register_class(BML_OT_assign_dof_from_index) + bpy.utils.register_class(BML_OT_reassign_all_ids) + bpy.utils.register_class(BML_OT_reassign_switches_scene) + bpy.utils.register_class(BML_OT_reassign_switches_collection) + bpy.utils.register_class(BML_OT_reassign_dofs_scene) + bpy.utils.register_class(BML_OT_reassign_dofs_collection) + bpy.utils.register_class(BML_OT_assign_switch_popup) + bpy.utils.register_class(BML_OT_assign_dof_popup) + + +def unregister(): + bpy.utils.unregister_class(BML_OT_assign_dof_popup) + bpy.utils.unregister_class(BML_OT_assign_switch_popup) + bpy.utils.unregister_class(BML_OT_reassign_dofs_collection) + bpy.utils.unregister_class(BML_OT_reassign_dofs_scene) + bpy.utils.unregister_class(BML_OT_reassign_switches_collection) + bpy.utils.unregister_class(BML_OT_reassign_switches_scene) + bpy.utils.unregister_class(BML_OT_reassign_all_ids) + bpy.utils.unregister_class(BML_OT_assign_dof_from_index) + bpy.utils.unregister_class(BML_OT_assign_switch_from_index) diff --git a/bms_blender_plugin/ui_tools/panels/dof_panel.py b/bms_blender_plugin/ui_tools/panels/dof_panel.py index 6e24c19..777bc60 100644 --- a/bms_blender_plugin/ui_tools/panels/dof_panel.py +++ b/bms_blender_plugin/ui_tools/panels/dof_panel.py @@ -68,7 +68,32 @@ def draw(self, context): "dof_list_index", ) row = layout.row() - row.prop(dof, "dof_type") + # Persistent ID box shown first + box_ids = layout.box() + box_ids.label(text="Persistent ID for Export") + box_ids.prop(dof, "bml_dof_number") + dof_num = getattr(dof, "bml_dof_number", -1) + if dof_num < 0: + row_unset = box_ids.row(align=True) + row_unset.label(text="Not Assigned", icon="ERROR") + # Use popup to provide single + scene/collection batch assignment options + row_unset.operator("bml.assign_dof_popup", text="Assign...", icon="IMPORT") + else: + found = False + try: + from bms_blender_plugin.common.util import get_dofs + for de in get_dofs(): + if de.dof_number == dof_num: + found = True + break + except Exception: + pass + if not found: + box_ids.label(text="Warning: DOF number not found in DOF.xml (still exported)", icon="INFO") + + # DOF Type selector moved below persistent ID box for clarity + type_row = layout.row() + type_row.prop(dof, "dof_type") layout.separator() row = layout.row() @@ -122,12 +147,13 @@ def draw(self, context): layout.label(text=f"Unknown DOF type: {active_object.dof_type}") layout.separator() - layout.label(text="DOF Options") - layout.prop(dof, "dof_check_limits") - layout.prop(dof, "dof_reverse") - layout.prop(dof, "dof_normalise") - layout.prop(dof, "dof_multiplier") - layout.prop(dof, "dof_multiply_min_max") + options_box = layout.box() + options_box.label(text="DOF Options") + options_box.prop(dof, "dof_check_limits") + options_box.prop(dof, "dof_reverse") + options_box.prop(dof, "dof_normalise") + options_box.prop(dof, "dof_multiplier") + options_box.prop(dof, "dof_multiply_min_max") def register(): diff --git a/bms_blender_plugin/ui_tools/panels/switch_panel.py b/bms_blender_plugin/ui_tools/panels/switch_panel.py index 6ebfbd5..ae2b3c5 100644 --- a/bms_blender_plugin/ui_tools/panels/switch_panel.py +++ b/bms_blender_plugin/ui_tools/panels/switch_panel.py @@ -31,11 +31,12 @@ def draw_item( custom_icon = "OUTLINER_OB_EMPTY" if self.layout_type in {"DEFAULT", "COMPACT"}: - layout.label(text=f"{item.name} ({item.switch_number})", icon=custom_icon) + # Display switch number and branch together e.g. 213:24 + layout.label(text=f"{item.name} ({item.switch_number}:{item.branch_number})", icon=custom_icon) elif self.layout_type in {"GRID"}: layout.alignment = "CENTER" - layout.label(text=item.switch_number, icon=custom_icon) + layout.label(text=f"{item.switch_number}:{item.branch_number}", icon=custom_icon) class SwitchPanel(BasePanel, bpy.types.Panel): @@ -65,13 +66,40 @@ def draw(self, context): switch, "switch_list_index", ) - - comment = get_switches()[switch.switch_list_index].comment - - if comment and comment != "": - layout.row() + # Comment (legacy list based) + try: + comment = get_switches()[switch.switch_list_index].comment + except Exception: + comment = "" + if comment: layout.label(text=comment) + box = layout.box() + box.label(text="Persistent IDs for Export") + row_ids = box.row(align=True) + row_ids.prop(switch, "bml_switch_number") + row_ids.prop(switch, "bml_switch_branch") + + # Show mismatch / status info + sw_num = getattr(switch, "bml_switch_number", -1) + sw_branch = getattr(switch, "bml_switch_branch", -1) + if sw_num < 0 or sw_branch < 0: + row_unset = box.row(align=True) + row_unset.label(text="Not Assigned", icon="ERROR") + row_unset.operator("bml.assign_switch_popup", text="Assign...", icon="IMPORT") + else: + # Check if present in current list + found = False + try: + for sw in get_switches(): + if sw.switch_number == sw_num and sw.branch == sw_branch: + found = True + break + except Exception: + pass + if not found: + box.label(text="Warning: IDs not found in switch.xml (still exported)", icon="INFO") + layout.prop(switch, "switch_default_on") 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 5/9] 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) From dbc8bc3026356a16d4f7bf0868ade8efa1c60b64 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Thu, 25 Sep 2025 02:02:08 -0500 Subject: [PATCH 6/9] Fix DOF/switch UI panel filter Filter box now permits filtering by DOF or switch number. For a switch, appending ":" will show all branches for the preceeding switch number. You can still search by name. Creates filter_items() methods for both SwitchList and DofList. --- .../ui_tools/panels/dof_panel.py | 31 ++++++++++++++++ .../ui_tools/panels/switch_panel.py | 36 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/bms_blender_plugin/ui_tools/panels/dof_panel.py b/bms_blender_plugin/ui_tools/panels/dof_panel.py index 777bc60..ab6a34f 100644 --- a/bms_blender_plugin/ui_tools/panels/dof_panel.py +++ b/bms_blender_plugin/ui_tools/panels/dof_panel.py @@ -28,6 +28,37 @@ class DofList(UIList): def __init__(self): self.use_filter_show = True + def filter_items(self, context, data, propname): + """Custom filter that matches both name and DOF numbers""" + dofs = getattr(data, propname) + + flt_flags = [] + flt_neworder = [] + + # Check if there's a search filter active + if self.filter_name: + # Start with name-based filtering + flt_flags = bpy.types.UI_UL_list.filter_items_by_name( + self.filter_name, self.bitflag_filter_item, dofs, "name" + ) + + # Also check if the filter text matches DOF numbers + filter_text = self.filter_name.lower().strip() + if filter_text.isdigit(): + for i, dof in enumerate(dofs): + # If name filter already matched, keep it + if flt_flags[i] & self.bitflag_filter_item: + continue + + # Check if filter matches DOF number (supports partial matching) + if str(dof.dof_number).startswith(filter_text): + flt_flags[i] |= self.bitflag_filter_item + else: + # No filter, sort by name + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(dofs, "name") + + return flt_flags, flt_neworder + def draw_item( self, context, layout, data, item, icon, active_data, active_propname, index ): diff --git a/bms_blender_plugin/ui_tools/panels/switch_panel.py b/bms_blender_plugin/ui_tools/panels/switch_panel.py index ae2b3c5..e5e6668 100644 --- a/bms_blender_plugin/ui_tools/panels/switch_panel.py +++ b/bms_blender_plugin/ui_tools/panels/switch_panel.py @@ -25,6 +25,42 @@ class SwitchList(UIList): def __init__(self): self.use_filter_show = True + def filter_items(self, context, data, propname): + """Custom filter that matches both name and switch/branch numbers""" + switches = getattr(data, propname) + + flt_flags = [] + flt_neworder = [] + + # Check if there's a search filter active + if self.filter_name: + # Start with name-based filtering + flt_flags = bpy.types.UI_UL_list.filter_items_by_name( + self.filter_name, self.bitflag_filter_item, switches, "name" + ) + + # Also check if the filter text matches switch or branch numbers + filter_text = self.filter_name.lower().strip() + if filter_text.isdigit() or ':' in filter_text: + for i, switch in enumerate(switches): + # If name filter already matched, keep it + if flt_flags[i] & self.bitflag_filter_item: + continue + + # Check if filter matches switch number + if filter_text.isdigit() and str(switch.switch_number).startswith(filter_text): + flt_flags[i] |= self.bitflag_filter_item + # Check if filter matches switch:branch format + elif ':' in filter_text: + switch_branch_text = f"{switch.switch_number}:{switch.branch_number}" + if switch_branch_text.startswith(filter_text): + flt_flags[i] |= self.bitflag_filter_item + else: + # No filter, sort by name + flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(switches, "name") + + return flt_flags, flt_neworder + def draw_item( self, context, layout, data, item, icon, active_data, active_propname, index ): From 23b306fe75f9ccf816ec1140867f8e7d73533103 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Thu, 25 Sep 2025 23:45:27 -0500 Subject: [PATCH 7/9] Export Validation Initial - Implement extensible system for validating a scene at point of export (or whenever) - export_validation.py - Validation pipeline with ExportValidator performing analysis to detect common issues. Returns ValidationIssue (dataclass) objects containing type classification and list of affected objects for resolution - validation_dialogs.py - Dialog operators to guide user through contextual resolution options (eg. execute a batch operation, select issue objects and quit, ignore) Still working on UI and edge cases --- bms_blender_plugin/exporter/__init__.py | 9 + bms_blender_plugin/exporter/bml_output.py | 11 + .../exporter/export_validation.py | 349 ++++++++++++++++++ .../exporter/validation_dialogs.py | 241 ++++++++++++ bms_blender_plugin/ui_tools/dof_behaviour.py | 20 +- .../ui_tools/operators/assign_from_index.py | 26 +- 6 files changed, 647 insertions(+), 9 deletions(-) create mode 100644 bms_blender_plugin/exporter/export_validation.py create mode 100644 bms_blender_plugin/exporter/validation_dialogs.py diff --git a/bms_blender_plugin/exporter/__init__.py b/bms_blender_plugin/exporter/__init__.py index e69de29..5d2f9a4 100644 --- a/bms_blender_plugin/exporter/__init__.py +++ b/bms_blender_plugin/exporter/__init__.py @@ -0,0 +1,9 @@ +from . import validation_dialogs + + +def register(): + validation_dialogs.register() + + +def unregister(): + validation_dialogs.unregister() \ No newline at end of file diff --git a/bms_blender_plugin/exporter/bml_output.py b/bms_blender_plugin/exporter/bml_output.py index 473d106..da5e243 100644 --- a/bms_blender_plugin/exporter/bml_output.py +++ b/bms_blender_plugin/exporter/bml_output.py @@ -17,6 +17,9 @@ ) from bms_blender_plugin.exporter.export_parent_dat import get_slots, export_parent_dat from bms_blender_plugin.exporter.export_bounding_boxes import export_bounding_boxes +from bms_blender_plugin.exporter.export_validation import ( + show_validation_dialog_export, +) from mathutils import Vector @@ -30,6 +33,14 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo * A single 3dButtons.dat """ + # PRE-FLIGHT VALIDATION: Check only the export scope (derived from LODs or active collection) + print(f"Validating scene...\n") + + if show_validation_dialog_export(context, lods=lods): + # A dialog was invoked; cancel export and let the user resolve, then retry + print("Export cancelled") + return "Export cancelled by user", [] + start_time = datetime.datetime.now() print(f"Starting BML export at {start_time}\n") diff --git a/bms_blender_plugin/exporter/export_validation.py b/bms_blender_plugin/exporter/export_validation.py new file mode 100644 index 0000000..7b425f9 --- /dev/null +++ b/bms_blender_plugin/exporter/export_validation.py @@ -0,0 +1,349 @@ +""" +Export validation module for pre-flight checks before BML export. + +Validates scene state to catch common issues that could cause export failures +or silent data corruption, providing clear feedback and resolution options. +Import LodItem type. + +To add a new validation check: +1. Add an issue type to ValidationIssueType enum if required +2. Add a grouping property to ValidationIssue dataclass +3. Add a new _check_*_issues() method to ExportValidator class +4. Call your new method from validate_scene() + +...for issues that need user input for resolution (not just collecting stats): +5. Add filter function(s) like get_*_issues() if resolution of the issue needs special dialog handling +6. Create dialog operator in validation_dialogs.py if needed - eg. user choice required for resolution +7. Update run_validation_with_dialogs() to handle your new issue type with dialogs + +Example: Adding a "missing material" validation check: +# Step 1: Add to ValidationIssueType enum +MATERIAL_MISSING = "material_missing" + +# Step 2: Add grouping property to ValidationIssue +@property +def is_material_issue(self) -> bool: + return self.issue_type == ValidationIssueType.MATERIAL_MISSING + +# Step 3: Add validation method to ExportValidator +def _check_material_issues(self, context) -> List[ValidationIssue]: + issues = [] + for obj in context.scene.objects: + if not obj.material_slots: + issues.append(ValidationIssue( + ValidationIssueType.MATERIAL_MISSING, + [obj], + f"Object '{obj.name}' has no materials assigned" + )) + return issues + +# Step 4: Call from validate_scene() +issues.extend(self._check_material_issues(context)) + +# Steps 5-7: Add dialog handling if needed +""" + +import bpy +from dataclasses import dataclass +from typing import List, Optional, Iterable +from enum import Enum + +from bms_blender_plugin.common.blender_types import BlenderNodeType, LodItem +from bms_blender_plugin.common.util import get_bml_type, get_dofs, get_switches + + +class ValidationIssueType(Enum): + """Types of validation issues that can be detected.""" + DOF_OUT_OF_RANGE = "dof_out_of_range" + SWITCH_OUT_OF_RANGE = "switch_out_of_range" + DOF_MISSING_PERSISTENT_ID = "dof_missing_persistent_id" + SWITCH_MISSING_PERSISTENT_ID = "switch_missing_persistent_id" + + +@dataclass +class ValidationIssue: + """Represents a single validation issue found in the scene.""" + """Contains properties to identify groups of issue types for batch handling.""" + issue_type: ValidationIssueType + objects: List[bpy.types.Object] + description: str + + @property + def is_out_of_range_issue(self) -> bool: + """True if this is an out-of-range XML reference issue.""" + return self.issue_type in ( + ValidationIssueType.DOF_OUT_OF_RANGE, + ValidationIssueType.SWITCH_OUT_OF_RANGE + ) + + @property + def is_missing_persistent_id_issue(self) -> bool: + """True if this is a missing persistent ID issue.""" + return self.issue_type in ( + ValidationIssueType.DOF_MISSING_PERSISTENT_ID, + ValidationIssueType.SWITCH_MISSING_PERSISTENT_ID + ) + + +class ExportValidator: + """Validates scene state before export to catch common issues.""" + + def validate_scene(self, context, objects: Optional[Iterable[bpy.types.Object]] = None) -> List[ValidationIssue]: + """ + Returns list of validation issues found in scene. + + Add new validation method calls here: + issues.extend(self._check_your_new_validation(context)) + """ + issues = [] + issues.extend(self._check_dof_issues(context, objects)) + issues.extend(self._check_switch_issues(context, objects)) + # Add new validation checks here + return issues + + def _get_dof_max_index(self, context) -> int: + """Prefer cached Scene DOF list; fallback to util cache.""" + try: + if hasattr(context.scene, 'dof_list') and len(context.scene.dof_list) > 0: + return len(context.scene.dof_list) - 1 + except Exception: + pass + try: + available_dofs = get_dofs() + return len(available_dofs) - 1 + except Exception: + return -1 + + def _get_switch_max_index(self, context) -> int: + """Prefer cached Scene Switch list; fallback to util cache.""" + try: + if hasattr(context.scene, 'switch_list') and len(context.scene.switch_list) > 0: + return len(context.scene.switch_list) - 1 + except Exception: + pass + try: + available_switches = get_switches() + return len(available_switches) - 1 + except Exception: + return -1 + + def _iter_target_objects(self, context, objects: Optional[Iterable[bpy.types.Object]]): + if objects is not None: + # Ensure we iterate once over a stable list + return list(objects) + return list(context.scene.objects) + + def _check_dof_issues(self, context, objects: Optional[Iterable[bpy.types.Object]]) -> List[ValidationIssue]: + """Check for DOF-related validation issues.""" + issues = [] + max_dof_index = self._get_dof_max_index(context) + if max_dof_index < 0: + # No list available; skip checks safely + return issues + + out_of_range_objects = [] + missing_persistent_id_objects = [] + + for obj in self._iter_target_objects(context, objects): + if get_bml_type(obj) != BlenderNodeType.DOF: + continue + + persistent_id = getattr(obj, "bml_dof_number", -1) + list_index = getattr(obj, "dof_list_index", 0) + + if persistent_id < 0: + # No persistent ID assigned + if list_index > max_dof_index: + # List index is out of range - XML mismatch issue + out_of_range_objects.append(obj) + else: + # Valid list index but no persistent ID - migration needed + missing_persistent_id_objects.append(obj) + + # Create issues for out-of-range objects + if out_of_range_objects: + description = ( + f"Found {len(out_of_range_objects)} DOF(s) referencing XML entries " + f"not found in current DOF.xml (max index: {max_dof_index}). " + "This usually means your DOF.xml file is outdated." + ) + issues.append(ValidationIssue( + ValidationIssueType.DOF_OUT_OF_RANGE, + out_of_range_objects, + description + )) + + # Create issues for missing persistent IDs + if missing_persistent_id_objects: + description = ( + f"Found {len(missing_persistent_id_objects)} DOF(s) using legacy list indices " + "without persistent IDs. These will work for export but may break " + "if DOF.xml files are reordered." + ) + issues.append(ValidationIssue( + ValidationIssueType.DOF_MISSING_PERSISTENT_ID, + missing_persistent_id_objects, + description + )) + + return issues + + def _check_switch_issues(self, context, objects: Optional[Iterable[bpy.types.Object]]) -> List[ValidationIssue]: + """Check for Switch-related validation issues.""" + issues = [] + max_switch_index = self._get_switch_max_index(context) + if max_switch_index < 0: + return issues + + out_of_range_objects = [] + missing_persistent_id_objects = [] + + for obj in self._iter_target_objects(context, objects): + if get_bml_type(obj) != BlenderNodeType.SWITCH: + continue + + persistent_number = getattr(obj, "bml_switch_number", -1) + persistent_branch = getattr(obj, "bml_switch_branch", -1) + list_index = getattr(obj, "switch_list_index", 0) + + if persistent_number < 0 or persistent_branch < 0: + # No persistent ID assigned + if list_index > max_switch_index: + # List index is out of range - XML mismatch issue + out_of_range_objects.append(obj) + else: + # Valid list index but no persistent ID - migration needed + missing_persistent_id_objects.append(obj) + + # Create issues for out-of-range objects + if out_of_range_objects: + description = ( + f"Found {len(out_of_range_objects)} Switch(es) referencing XML entries " + f"not found in current Switch.xml (max index: {max_switch_index}). " + "This usually means your Switch.xml file is outdated." + ) + issues.append(ValidationIssue( + ValidationIssueType.SWITCH_OUT_OF_RANGE, + out_of_range_objects, + description + )) + + # Create issues for missing persistent IDs + if missing_persistent_id_objects: + description = ( + f"Found {len(missing_persistent_id_objects)} Switch(es) using legacy list indices " + "without persistent IDs. These will work for export but may break " + "if Switch.xml files are reordered." + ) + issues.append(ValidationIssue( + ValidationIssueType.SWITCH_MISSING_PERSISTENT_ID, + missing_persistent_id_objects, + description + )) + + return issues + + +def validate_export_readiness(context, objects: Optional[Iterable[bpy.types.Object]] = None) -> List[ValidationIssue]: + """ + Main entry point for export validation. + + Returns list of validation issues that should be addressed before export. + Empty list means scene is ready for export. + """ + validator = ExportValidator() + return validator.validate_scene(context, objects) + + +def get_out_of_range_issues(issues: List[ValidationIssue]) -> List[ValidationIssue]: + """Filter issues to only out-of-range XML reference problems.""" + return [issue for issue in issues if issue.is_out_of_range_issue] + + +def get_missing_persistent_id_issues(issues: List[ValidationIssue]) -> List[ValidationIssue]: + """Filter issues to only missing persistent ID problems.""" + return [issue for issue in issues if issue.is_missing_persistent_id_issue] + + +def select_objects_from_issues(issues: List[ValidationIssue]): + """Select all objects referenced in the given validation issues.""" + bpy.ops.object.select_all(action='DESELECT') + + for issue in issues: + for obj in issue.objects: + obj.select_set(True) + + # Set first object as active if any were selected + selected_objects = [obj for issue in issues for obj in issue.objects] + if selected_objects: + bpy.context.view_layer.objects.active = selected_objects[0] + + +def run_validation_with_dialogs(context) -> bool: + """ + DEPRECATED: Asynchronous dialogs cannot be orchestrated reliably here. + Kept for backward compatibility; returns True as a no-op. + Use validate_export_readiness() + show_validation_dialog_if_needed() instead. + """ + return True + + +def _collect_objects_from_collection(coll: bpy.types.Collection) -> List[bpy.types.Object]: + result = set() + def _rec(c): + for o in c.objects: + result.add(o) + for ch in c.children: + _rec(ch) + _rec(coll) + return list(result) + + +def _collect_objects_from_active_collection(context) -> List[bpy.types.Object]: + alc = context.view_layer.active_layer_collection + coll = alc.collection if alc else None + if not coll: + return [] + return _collect_objects_from_collection(coll) + + +def _collect_objects_from_lods(lods: Iterable[LodItem]) -> List[bpy.types.Object]: + objs = set() + for li in lods: + coll = getattr(li, 'collection', None) + if coll: + for o in _collect_objects_from_collection(coll): + objs.add(o) + return list(objs) + + +def show_validation_dialog_export( + context, + objects: Optional[Iterable[bpy.types.Object]] = None, + lods: Optional[Iterable[LodItem]] = None, +) -> bool: + """ + Stateless helper to show the appropriate validation dialog if issues exist. + Returns True if a dialog was invoked (export should be cancelled by caller), + False if no issues were found. + """ + # Determine the validation scope if not explicitly provided + if objects is None: + if lods: + objects = _collect_objects_from_lods(lods) + else: + objects = _collect_objects_from_active_collection(context) + + issues = validate_export_readiness(context, objects) + if not issues: + return False + # Prioritize out-of-range (schema mismatches) over missing IDs + use_lods_flag = bool(lods) + if get_out_of_range_issues(issues): + bpy.ops.bml.validation_out_of_range_dialog('INVOKE_DEFAULT', use_lods=use_lods_flag) + return True + if get_missing_persistent_id_issues(issues): + bpy.ops.bml.validation_missing_id_dialog('INVOKE_DEFAULT', use_lods=use_lods_flag) + return True + return False + \ No newline at end of file diff --git a/bms_blender_plugin/exporter/validation_dialogs.py b/bms_blender_plugin/exporter/validation_dialogs.py new file mode 100644 index 0000000..b784d90 --- /dev/null +++ b/bms_blender_plugin/exporter/validation_dialogs.py @@ -0,0 +1,241 @@ +""" +Export validation dialog operators. + +Provides user-friendly dialogs for resolving validation issues before export. +""" + +import bpy +from bpy.props import EnumProperty, StringProperty, BoolProperty +from bpy.types import Operator + +from bms_blender_plugin.exporter.export_validation import ( + ValidationIssue, + select_objects_from_issues, + validate_export_readiness, + get_out_of_range_issues, + get_missing_persistent_id_issues, +) +from bms_blender_plugin.ui_tools.operators.assign_from_index import assign_persistent_ids_to_objects + + +def _collect_objects_from_collection(coll): + result = set() + def _recurse(c): + for o in c.objects: + result.add(o) + for ch in c.children: + _recurse(ch) + _recurse(coll) + return list(result) + + +def _export_scope_objects(context, use_lods=False): + """Return objects in the active export collection hierarchy.""" + if use_lods and hasattr(context.scene, 'lod_list') and len(context.scene.lod_list) > 0: + objs = set() + for li in context.scene.lod_list: + coll = getattr(li, 'collection', None) + if coll: + for o in _collect_objects_from_collection(coll): + objs.add(o) + return list(objs) + # Fallback to active collection + alc = context.view_layer.active_layer_collection + coll = alc.collection if alc else None + if not coll: + return [] + return _collect_objects_from_collection(coll) + + +class BML_OT_ValidationOutOfRangeDialog(Operator): + """Dialog for handling out-of-range XML reference issues.""" + + bl_idname = "bml.validation_out_of_range_dialog" + bl_label = "Export Validation Warning" + bl_description = "Resolve out-of-range XML reference issues" + + # Store the validation issues and context for the dialog + issues_data: StringProperty(default="") # type: ignore[misc] + use_lods: BoolProperty(default=False) # type: ignore[misc] # whether to scope validation to all LOD collections + + action: EnumProperty( # type: ignore[misc] + name="Action", + description="Choose how to handle the out-of-range issues", + items=[ + ('SELECT', 'Select Objects & Cancel', 'Select problematic objects and cancel export for manual fixing'), + ('RELOAD', 'Reload XML & Retry', 'Reload XML files and retry validation'), + ('CONTINUE', 'Continue Export', 'Proceed with export using fallback values (may cause incorrect behavior)') + ], + default='SELECT' + ) + + def draw(self, context): + layout = self.layout + + layout.label(text="⚠️ Export Validation Warning", icon='ERROR') + layout.separator() + + # Recompute issues; avoid relying on temporary scene properties + issues = validate_export_readiness(context, _export_scope_objects(context, self.use_lods)) + out_of_range_issues = get_out_of_range_issues(issues) + + if out_of_range_issues: + total_objects = sum(len(issue.objects) for issue in out_of_range_issues) + layout.label(text=f"Found {total_objects} objects with out-of-range XML references:") + + box = layout.box() + for issue in out_of_range_issues: + box.label(text=f"• {issue.issue_type.value.replace('_', ' ').title()}") + object_names = [obj.name for obj in issue.objects[:3]] # Show first 3 + if len(issue.objects) > 3: + object_names.append(f"... and {len(issue.objects) - 3} more") + box.label(text=f" Objects: {', '.join(object_names)}") + + layout.separator() + layout.label(text="This usually means your XML files are outdated.") + layout.separator() + + layout.prop(self, "action", expand=True) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self, width=500) + + def execute(self, context): + # Recompute on execute to reflect current state + issues = validate_export_readiness(context, _export_scope_objects(context, self.use_lods)) + out_of_range_issues = get_out_of_range_issues(issues) + + if self.action == 'SELECT': + select_objects_from_issues(out_of_range_issues) + self.report({'INFO'}, f"Selected {sum(len(issue.objects) for issue in out_of_range_issues)} problematic objects") + + elif self.action == 'RELOAD': + try: + # Use existing reload operators from preferences.py + # These handle proper cache clearing and repopulation + bpy.ops.bml.reload_switch_list() + bpy.ops.bml.reload_dof_list() + self.report({'INFO'}, "XML files reloaded. Re-run export.") + + except Exception as e: + self.report({'ERROR'}, f"Failed to reload XML files: {str(e)}") + + elif self.action == 'CONTINUE': + self.report({'WARNING'}, "Continuing export with fallback behavior") + # Don't set any flags - let export continue + + return {'FINISHED'} + + def _cleanup_scene_properties(self, context): + """Clean up temporary scene properties used by validation system.""" + if hasattr(context.scene, '_bml_validation_issues'): + delattr(context.scene, '_bml_validation_issues') + if hasattr(context.scene, '_bml_export_cancelled'): + delattr(context.scene, '_bml_export_cancelled') + if hasattr(context.scene, '_bml_export_retry'): + delattr(context.scene, '_bml_export_retry') + + +class BML_OT_ValidationMissingIDDialog(Operator): + """Dialog for handling missing persistent ID issues.""" + + bl_idname = "bml.validation_missing_id_dialog" + bl_label = "Legacy DOF/Switch Migration" + bl_description = "Resolve missing persistent ID issues" + use_lods: BoolProperty(default=False) # type: ignore[misc] + + action: EnumProperty( # type: ignore[misc] + name="Action", + description="Choose how to handle missing persistent IDs", + items=[ + ('SELECT', 'Select Objects & Cancel', 'Select objects and cancel export for manual ID assignment'), + ('AUTO_ASSIGN', 'Auto-assign IDs & Continue', 'Automatically assign persistent IDs and continue export'), + ('CONTINUE', 'Continue Legacy Mode', 'Continue with legacy list-index behavior') + ], + default='AUTO_ASSIGN' + ) + + def draw(self, context): + layout = self.layout + + layout.label(text="Legacy DOF/Switch Migration", icon='INFO') + layout.separator() + + # Recompute issues to reflect current state + issues = validate_export_readiness(context, _export_scope_objects(context, self.use_lods)) + missing_id_issues = get_missing_persistent_id_issues(issues) + + if missing_id_issues: + total_objects = sum(len(issue.objects) for issue in missing_id_issues) + layout.label(text=f"Found {total_objects} objects using legacy indices without persistent IDs:") + + box = layout.box() + for issue in missing_id_issues: + issue_type = issue.issue_type.value.replace('_', ' ').title() + box.label(text=f"• {issue_type}: {len(issue.objects)} objects") + + layout.separator() + layout.label(text="These may work for export but may break if XML files are incomplete.") + layout.separator() + + layout.prop(self, "action", expand=True) + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self, width=500) + + def execute(self, context): + # Recompute on execute to reflect current state + issues = validate_export_readiness(context, _export_scope_objects(context, self.use_lods)) + missing_id_issues = get_missing_persistent_id_issues(issues) + + if self.action == 'SELECT': + select_objects_from_issues(missing_id_issues) + self.report({'INFO'}, f"Selected {sum(len(issue.objects) for issue in missing_id_issues)} objects needing persistent IDs") + + elif self.action == 'AUTO_ASSIGN': + total_objects = sum(len(issue.objects) for issue in missing_id_issues) + switch_count, dof_count = self._auto_assign_persistent_ids(missing_id_issues) + total_assigned = switch_count + dof_count + + if total_assigned == total_objects: + self.report({'INFO'}, f"Successfully assigned persistent IDs to all {total_assigned} objects") + elif total_assigned > 0: + self.report({'WARNING'}, f"Assigned persistent IDs to {total_assigned} of {total_objects} objects (some may have out-of-range indices)") + else: + self.report({'ERROR'}, "Failed to assign any persistent IDs - check console for details") + # Continue with export + + elif self.action == 'CONTINUE': + self.report({'INFO'}, "Continuing with legacy list-index behavior") + # Continue with export + + return {'FINISHED'} + + def _auto_assign_persistent_ids(self, issues): + """Auto-assign persistent IDs to specific objects from validation issues.""" + # Collect all objects from the issues that need persistent ID assignment + target_objects = [] + for issue in issues: + target_objects.extend(issue.objects) + + if not target_objects: + return 0, 0 + + try: + # Use targeted assignment that only processes the specific objects, returns (switches_assigned, dofs_assigned) + return assign_persistent_ids_to_objects(bpy.context, target_objects) + except Exception as e: + # If assignment fails, fallback gracefully + print(f"Persistent ID auto-assignment failed: {e}") + return 0, 0 + + +# Registration +def register(): + bpy.utils.register_class(BML_OT_ValidationOutOfRangeDialog) + bpy.utils.register_class(BML_OT_ValidationMissingIDDialog) + + +def unregister(): + bpy.utils.unregister_class(BML_OT_ValidationMissingIDDialog) + bpy.utils.unregister_class(BML_OT_ValidationOutOfRangeDialog) \ No newline at end of file diff --git a/bms_blender_plugin/ui_tools/dof_behaviour.py b/bms_blender_plugin/ui_tools/dof_behaviour.py index 178ab99..210cf46 100644 --- a/bms_blender_plugin/ui_tools/dof_behaviour.py +++ b/bms_blender_plugin/ui_tools/dof_behaviour.py @@ -20,7 +20,7 @@ reset_dof, get_parent_dof_or_switch, ) -from bms_blender_plugin.nodes_editor.util import get_bml_node_type, get_bml_node_tree_type +from bms_blender_plugin.nodes_editor.util import get_bml_node_type, get_bml_node_tree_type, get_dof_enumeration class DofMediator: @@ -50,7 +50,9 @@ def subscribe(cls, dof): """Subscribes a DOF to dof_input updates for his DOF number""" if get_bml_type(dof) != BlenderNodeType.DOF: return - dof_number = get_dofs()[dof.dof_list_index].dof_number + enum = get_dof_enumeration() + idx = getattr(dof, "dof_list_index", -1) + dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 # first time subscription if dof not in cls.dof_dof_number.keys(): @@ -81,7 +83,9 @@ def subscribe(cls, dof): @classmethod def unsubscribe(cls, dof): """Unsubscribes a DOF from all subscriptions""" - dof_number = get_dofs()[dof.dof_list_index].dof_number + enum = get_dof_enumeration() + idx = getattr(dof, "dof_list_index", -1) + dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 cls.dof_number_dofs[dof_number].remove(dof) cls.dof_dof_number.pop(dof) @@ -90,11 +94,11 @@ def post_new_dof_value(cls, dof): """Notifies that a DOF has received a new dof_input value. Updates the other DOFs to the same dof_input.""" if bpy.app.background: return - if dof not in cls.dof_dof_number: cls.rebuild_cache() - - dof_number = get_dofs()[dof.dof_list_index].dof_number + enum = get_dof_enumeration() + idx = getattr(dof, "dof_list_index", -1) + dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 dofs_to_cleanup = [] new_dof_input = dof.dof_input @@ -159,7 +163,9 @@ def update_switch_or_dof_name(obj, context): obj.name = f"DOF - {dof_name} ({dof_num})" else: try: - active_dof = get_dofs()[obj.dof_list_index] + enum = get_dof_enumeration() + idx = getattr(obj, "dof_list_index", -1) + active_dof = enum[idx] if 0 <= idx < len(enum) else None obj.name = f"DOF - {active_dof.name} ({active_dof.dof_number})" except Exception: obj.name = "DOF - Unset" diff --git a/bms_blender_plugin/ui_tools/operators/assign_from_index.py b/bms_blender_plugin/ui_tools/operators/assign_from_index.py index 4816e6a..64c1b09 100644 --- a/bms_blender_plugin/ui_tools/operators/assign_from_index.py +++ b/bms_blender_plugin/ui_tools/operators/assign_from_index.py @@ -165,7 +165,14 @@ def execute(self, context): # --------------------------------------------------------------------------- # Internal shared helper for wrapper batch operators (simpler popup usage) # --------------------------------------------------------------------------- -def _batch_reassign(context, scope: str, target: str): +def _batch_reassign(context, scope: str, target: str, target_objects=None): + """ + Batch assign persistent IDs from list indices. + + Args: + target_objects: Optional list of specific objects to process. + If None, processes all objects in scope. + """ switches_enum = get_switches() dofs_enum = get_dofs() processed_switches = 0 @@ -186,7 +193,12 @@ def _rec(c): _rec(coll) return list(result) - objs = collect(scope) + # Use target_objects if provided, otherwise collect from scope + if target_objects is not None: + objs = target_objects + else: + objs = collect(scope) + for obj in objs: bml_type = get_bml_type(obj) if target in {"SWITCH", "BOTH"} and bml_type == BlenderNodeType.SWITCH: @@ -207,6 +219,16 @@ def _rec(c): return processed_switches, processed_dofs +def assign_persistent_ids_to_objects(context, objects): + """ + Assign persistent IDs to specific objects only. + + Returns (switches_assigned, dofs_assigned) counts. + Used by validation dialogs for targeted assignment. + """ + return _batch_reassign(context, "SCENE", "BOTH", target_objects=objects) + + class BML_OT_reassign_switches_scene(bpy.types.Operator): bl_idname = "bml.reassign_switches_scene" bl_label = "Re-Assign All Switches (Scene)" From 8870429e88cdf75408fba88277c60ef9762f8978 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:00:42 -0500 Subject: [PATCH 8/9] DOF/Switch ID resolution helpers - Pass 1 - Add common/resolve_ids.py with helpers for resolving DOF and Switch persistent IDs - Replace direct list index access throughout. Refactors dof_editor.py, dof_input_node.py, util.py, and dof_behaviour.py - Prevent IndexError and stale data --- bms_blender_plugin/common/resolve_ids.py | 85 +++++++++++++++++++ bms_blender_plugin/nodes_editor/dof_editor.py | 17 ++-- .../nodes_editor/dof_nodes/dof_input_node.py | 17 ++-- bms_blender_plugin/nodes_editor/util.py | 21 ++++- bms_blender_plugin/ui_tools/dof_behaviour.py | 20 ++--- 5 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 bms_blender_plugin/common/resolve_ids.py diff --git a/bms_blender_plugin/common/resolve_ids.py b/bms_blender_plugin/common/resolve_ids.py new file mode 100644 index 0000000..e3de782 --- /dev/null +++ b/bms_blender_plugin/common/resolve_ids.py @@ -0,0 +1,85 @@ +"""Central helpers for resolving DOF / Switch persistent IDs or safe fallbacks. + +All systems (validation, nodes, mediator, exporter) should use these helpers +instead of directly indexing into list indices. This ensures consistent +resolution order and prevents IndexError crashes when XML/cached lists change. + +Resolution order: +1. Persistent ID properties (authoritative) if set. +2. Scene cached list (context.scene.dof_list / switch_list) if index in range. +3. Global cached XML data via get_dofs() / get_switches(). +4. Fallback: return None (caller decides how to proceed gracefully). +""" +from __future__ import annotations +from typing import Optional, Tuple +import bpy + +from bms_blender_plugin.common.blender_types import BlenderNodeType +from bms_blender_plugin.common.util import get_dofs, get_switches, get_bml_type + + +def resolve_dof_number(obj) -> Optional[int]: + """Return persistent DOF number for a DOF object or None if unresolved. + + Safe: never raises IndexError. + """ + if not obj: + return None + if get_bml_type(obj) != BlenderNodeType.DOF: + return None + + pid = getattr(obj, "bml_dof_number", -1) + if isinstance(pid, int) and pid >= 0: + return pid + + idx = getattr(obj, "dof_list_index", -1) + if not isinstance(idx, int) or idx < 0: + return None + + # Scene cached list first + scene_list = getattr(bpy.context.scene, "dof_list", None) + if scene_list and 0 <= idx < len(scene_list): + item = scene_list[idx] + return getattr(item, "dof_number", None) + + # Global cache fallback + try: + dofs = get_dofs() + if 0 <= idx < len(dofs): + return dofs[idx].dof_number + except Exception: + pass + return None + + +def resolve_switch_id(obj) -> Tuple[Optional[int], Optional[int]]: + """Return (switch_number, branch) for a Switch object or (None, None) if unresolved.""" + if not obj: + return None, None + if get_bml_type(obj) != BlenderNodeType.SWITCH: + return None, None + + num = getattr(obj, "bml_switch_number", -1) + br = getattr(obj, "bml_switch_branch", -1) + if isinstance(num, int) and num >= 0 and isinstance(br, int) and br >= 0: + return num, br + + idx = getattr(obj, "switch_list_index", -1) + if not isinstance(idx, int) or idx < 0: + return None, None + + scene_list = getattr(bpy.context.scene, "switch_list", None) + if scene_list and 0 <= idx < len(scene_list): + item = scene_list[idx] + return getattr(item, "switch_number", None), getattr(item, "branch_number", None) + + try: + switches = get_switches() + if 0 <= idx < len(switches): + sw = switches[idx] + return sw.switch_number, sw.branch + except Exception: + pass + return None, None + +__all__ = ["resolve_dof_number", "resolve_switch_id"] diff --git a/bms_blender_plugin/nodes_editor/dof_editor.py b/bms_blender_plugin/nodes_editor/dof_editor.py index 23f2c33..764f878 100644 --- a/bms_blender_plugin/nodes_editor/dof_editor.py +++ b/bms_blender_plugin/nodes_editor/dof_editor.py @@ -8,6 +8,7 @@ BlenderNodeTreeType, ) from bms_blender_plugin.common.util import get_dofs +from bms_blender_plugin.common.resolve_ids import resolve_dof_number from bms_blender_plugin.nodes_editor import dof_node_categories from bms_blender_plugin.nodes_editor.dof_base_node import DofBaseNode from bms_blender_plugin.nodes_editor.dof_nodes.dof_input_node import NodeDofModelInput @@ -144,22 +145,28 @@ def _check_nodes_recursively(recursive_node): # make sure that nodes are not connected with themselves if outgoing_node == recursive_node: - continue + continue # protect against accidental self-links if ( get_bml_node_type(outgoing_node) == BlenderEditorNodeType.DOF_MODEL and outgoing_node.parent_dof and get_bml_node_type(recursive_node) != BlenderEditorNodeType.DOF_MODEL ): - dof_number = list_dof_numbers[ - outgoing_node.parent_dof.dof_list_index - ].dof_number + # We are at a non-DOF node feeding a DOF node, we want to auto-link + dof_number = resolve_dof_number(outgoing_node.parent_dof) + if dof_number is None: + # fallback: derive from list index if persistent ID missing + idx = getattr(outgoing_node.parent_dof, 'dof_list_index', -1) + if 0 <= idx < len(list_dof_numbers): + dof_number = list_dof_numbers[idx].dof_number + if dof_number is None: + continue # give up silently if still unresolved nodes_with_same_dof_number = dofs_dict[dof_number] for node_with_same_dof_number in nodes_with_same_dof_number: node_tree.links.new( recursive_node.outputs[0], node_with_same_dof_number.inputs[0], ) - dofs_dict[dof_number] = [] + dofs_dict[dof_number] = [] # block so we don't do this again if ( get_bml_node_type(recursive_node) diff --git a/bms_blender_plugin/nodes_editor/dof_nodes/dof_input_node.py b/bms_blender_plugin/nodes_editor/dof_nodes/dof_input_node.py index d823d83..57a3eb4 100644 --- a/bms_blender_plugin/nodes_editor/dof_nodes/dof_input_node.py +++ b/bms_blender_plugin/nodes_editor/dof_nodes/dof_input_node.py @@ -4,6 +4,7 @@ from bms_blender_plugin.common.blender_types import BlenderEditorNodeType from bms_blender_plugin.common.bml_structs import DofType, ArgType from bms_blender_plugin.common.util import get_dofs +from bms_blender_plugin.common.resolve_ids import resolve_dof_number from bms_blender_plugin.nodes_editor.dof_base_node import ( DofBaseNode, subscribe_node, @@ -180,11 +181,17 @@ def check_connections(node_tree, node): BaseRenderControl.set_result_type(node, ArgType.SCRATCH_VARIABLE_ID) elif node.parent_dof: # linked to a render control or another DOF - set our result type to the DOF of the current node - BaseRenderControl.set_result_type( - node, - ArgType.DOF_ID, - get_dofs()[node.parent_dof.dof_list_index].dof_number, - ) + dof_num = resolve_dof_number(node.parent_dof) + if dof_num is not None: + BaseRenderControl.set_result_type( + node, + ArgType.DOF_ID, + dof_num, + ) + else: + # Fallback to scratch if unresolved to avoid crashes + # TODO: warn user + BaseRenderControl.set_result_type(node, ArgType.SCRATCH_VARIABLE_ID) def register(): diff --git a/bms_blender_plugin/nodes_editor/util.py b/bms_blender_plugin/nodes_editor/util.py index 70dee8a..515779e 100644 --- a/bms_blender_plugin/nodes_editor/util.py +++ b/bms_blender_plugin/nodes_editor/util.py @@ -1,5 +1,6 @@ from bms_blender_plugin.common.blender_types import BlenderEditorNodeType, BlenderNodeTreeType from bms_blender_plugin.common.util import get_dofs +from bms_blender_plugin.common.resolve_ids import resolve_dof_number def get_incoming_nodes(node): @@ -57,8 +58,15 @@ def get_valid_dof_nodes(tree): for node in tree.nodes: if get_bml_node_type(node) == BlenderEditorNodeType.DOF_MODEL and node.parent_dof: - dof_number = list_dof_numbers[node.parent_dof.dof_list_index].dof_number - if dof_number not in dofs.keys(): + # Resolve via persistent ID first; fall back to list index if valid + dof_number = resolve_dof_number(node.parent_dof) + if dof_number is None: + idx = getattr(node.parent_dof, 'dof_list_index', -1) + if 0 <= idx < len(list_dof_numbers): + dof_number = list_dof_numbers[idx].dof_number + if dof_number is None: + continue # skip invalid/unresolved + if dof_number not in dofs: dofs[dof_number] = [node] else: dofs[dof_number].append(node) @@ -129,7 +137,14 @@ def get_socket_distinct_outgoing_dof_numbers(output_socket): receiving_node = link.to_socket.node if get_bml_node_type(receiving_node) == BlenderEditorNodeType.DOF_MODEL: if receiving_node.parent_dof: - dof_number = get_dofs()[receiving_node.parent_dof.dof_list_index].dof_number + dof_number = resolve_dof_number(receiving_node.parent_dof) + if dof_number is None: + idx = getattr(receiving_node.parent_dof, 'dof_list_index', -1) + dofs_enum = get_dofs() + if 0 <= idx < len(dofs_enum): + dof_number = dofs_enum[idx].dof_number + if dof_number is None: + continue if dof_number not in dof_numbers: dof_numbers.append(dof_number) else: diff --git a/bms_blender_plugin/ui_tools/dof_behaviour.py b/bms_blender_plugin/ui_tools/dof_behaviour.py index 210cf46..178ab99 100644 --- a/bms_blender_plugin/ui_tools/dof_behaviour.py +++ b/bms_blender_plugin/ui_tools/dof_behaviour.py @@ -20,7 +20,7 @@ reset_dof, get_parent_dof_or_switch, ) -from bms_blender_plugin.nodes_editor.util import get_bml_node_type, get_bml_node_tree_type, get_dof_enumeration +from bms_blender_plugin.nodes_editor.util import get_bml_node_type, get_bml_node_tree_type class DofMediator: @@ -50,9 +50,7 @@ def subscribe(cls, dof): """Subscribes a DOF to dof_input updates for his DOF number""" if get_bml_type(dof) != BlenderNodeType.DOF: return - enum = get_dof_enumeration() - idx = getattr(dof, "dof_list_index", -1) - dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 + dof_number = get_dofs()[dof.dof_list_index].dof_number # first time subscription if dof not in cls.dof_dof_number.keys(): @@ -83,9 +81,7 @@ def subscribe(cls, dof): @classmethod def unsubscribe(cls, dof): """Unsubscribes a DOF from all subscriptions""" - enum = get_dof_enumeration() - idx = getattr(dof, "dof_list_index", -1) - dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 + dof_number = get_dofs()[dof.dof_list_index].dof_number cls.dof_number_dofs[dof_number].remove(dof) cls.dof_dof_number.pop(dof) @@ -94,11 +90,11 @@ def post_new_dof_value(cls, dof): """Notifies that a DOF has received a new dof_input value. Updates the other DOFs to the same dof_input.""" if bpy.app.background: return + if dof not in cls.dof_dof_number: cls.rebuild_cache() - enum = get_dof_enumeration() - idx = getattr(dof, "dof_list_index", -1) - dof_number = enum[idx].dof_number if 0 <= idx < len(enum) else -1 + + dof_number = get_dofs()[dof.dof_list_index].dof_number dofs_to_cleanup = [] new_dof_input = dof.dof_input @@ -163,9 +159,7 @@ def update_switch_or_dof_name(obj, context): obj.name = f"DOF - {dof_name} ({dof_num})" else: try: - enum = get_dof_enumeration() - idx = getattr(obj, "dof_list_index", -1) - active_dof = enum[idx] if 0 <= idx < len(enum) else None + active_dof = get_dofs()[obj.dof_list_index] obj.name = f"DOF - {active_dof.name} ({active_dof.dof_number})" except Exception: obj.name = "DOF - Unset" From 54516f90cd6eb938eec41d55da6ba9a558d01c16 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 28 Sep 2025 02:48:40 -0500 Subject: [PATCH 9/9] DOF/Switch ID resolution (List Hydration) - Pass 2 - Hydrate DOF/switch lists, prioritizing scene-cached data over disk XML - New preferences to control list reload behavior and warnings. - Updates all usages to prefer persistent IDs, and improves validation/migration dialogs - Refactor related operators/helpers Known: - Still does not entirely guard against switch/dof xml discrepancies if user reloads - some responsibility on user to reload XMLs with "more"/"improved" information - UIList (dof/switch panel) still display incorrect item if cached list is incomplete - should rely on PID first and deselect the list --- bms_blender_plugin/common/hydration.py | 37 +++ bms_blender_plugin/common/resolve_ids.py | 7 + bms_blender_plugin/common/util.py | 228 +++++++++++++----- .../exporter/export_parent_dat.py | 17 +- .../exporter/export_render_controls.py | 26 +- .../exporter/export_validation.py | 39 +-- bms_blender_plugin/exporter/parser.py | 71 +++--- .../exporter/validation_dialogs.py | 46 ++-- bms_blender_plugin/nodes_editor/util.py | 16 +- bms_blender_plugin/preferences.py | 30 ++- bms_blender_plugin/ui_tools/dof_behaviour.py | 136 +++++++---- .../ui_tools/operators/__init__.py | 106 +++++--- .../ui_tools/operators/assign_from_index.py | 12 + .../ui_tools/panels/switch_panel.py | 7 +- 14 files changed, 538 insertions(+), 240 deletions(-) create mode 100644 bms_blender_plugin/common/hydration.py diff --git a/bms_blender_plugin/common/hydration.py b/bms_blender_plugin/common/hydration.py new file mode 100644 index 0000000..7a23106 --- /dev/null +++ b/bms_blender_plugin/common/hydration.py @@ -0,0 +1,37 @@ +"""Hydration/load_post handler. + +Ensures that on .blend load the scene cached switch/DOF lists (if avail) become the +authoritative source for switch / DOF lists until the user explicitly +reloads from disk XML via UI. + - Allows seamless switching between .blend files with different XML snapshots + - Ensures UI lists reflect the loaded .blend's snapshot immediately + - Primary use case: user working with a foreign blend file created using different XMLs +""" +from __future__ import annotations +import bpy +from bpy.app.handlers import persistent + + +@persistent +def bml_hydrate_after_load(_): + try: + import bms_blender_plugin.common.util as util + util.switches = [] + util.dofs = [] + util._switches_hydrated = False + util._dofs_hydrated = False + # Trigger early hydration so UI immediately reflects snapshot + util.get_switches() + util.get_dofs() + except Exception: + pass + + +def register(): + if bml_hydrate_after_load not in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.append(bml_hydrate_after_load) + + +def unregister(): + if bml_hydrate_after_load in bpy.app.handlers.load_post: + bpy.app.handlers.load_post.remove(bml_hydrate_after_load) \ No newline at end of file diff --git a/bms_blender_plugin/common/resolve_ids.py b/bms_blender_plugin/common/resolve_ids.py index e3de782..b8d942f 100644 --- a/bms_blender_plugin/common/resolve_ids.py +++ b/bms_blender_plugin/common/resolve_ids.py @@ -28,10 +28,12 @@ def resolve_dof_number(obj) -> Optional[int]: if get_bml_type(obj) != BlenderNodeType.DOF: return None + # Persistent ID properties first pid = getattr(obj, "bml_dof_number", -1) if isinstance(pid, int) and pid >= 0: return pid + # Fallback to scene cached list idx = getattr(obj, "dof_list_index", -1) if not isinstance(idx, int) or idx < 0: return None @@ -43,6 +45,7 @@ def resolve_dof_number(obj) -> Optional[int]: return getattr(item, "dof_number", None) # Global cache fallback + print("DOF Resolution: Fallback to global DOF list for index", idx) try: dofs = get_dofs() if 0 <= idx < len(dofs): @@ -59,20 +62,24 @@ def resolve_switch_id(obj) -> Tuple[Optional[int], Optional[int]]: if get_bml_type(obj) != BlenderNodeType.SWITCH: return None, None + # Persistent ID properties first num = getattr(obj, "bml_switch_number", -1) br = getattr(obj, "bml_switch_branch", -1) if isinstance(num, int) and num >= 0 and isinstance(br, int) and br >= 0: return num, br + # Fallback to scene cached list idx = getattr(obj, "switch_list_index", -1) if not isinstance(idx, int) or idx < 0: return None, None + # Scene cached list first scene_list = getattr(bpy.context.scene, "switch_list", None) if scene_list and 0 <= idx < len(scene_list): item = scene_list[idx] return getattr(item, "switch_number", None), getattr(item, "branch_number", None) + print("Switch Resolution: Fallback to global Switch list for index", idx) try: switches = get_switches() if 0 <= idx < len(switches): diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index a97da47..907c765 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -1,11 +1,7 @@ import bpy - import bpy.utils.previews - import os import struct - - import lzma import math from mathutils import Vector @@ -82,37 +78,74 @@ def get_bml_type(obj, purge_orphaned_object=True): switches = [] +_switches_hydrated = False # sentinel controlling hydration of global switch list -def get_switches(): - """Returns a list of BMS Switches which are loaded from the switch.xml""" - global switches - if switches is None or len(switches) == 0: - import os - - switches_tree = ElementTree.parse( - os.path.join(os.path.dirname(__file__), "switch.xml") - ) - root = switches_tree.getroot() - switches = [] - - for switch in root: - switch_number = int(switch.find("SwitchNum").text) - branch = int(switch.find("BranchNum").text) - if switch.find("Name") is not None: - name = switch.find("Name").text - else: - name = "" - - if switch.find("Comment") is not None: - comment = switch.find("Comment").text - else: - comment = "" +def _parse_switch_xml(): + tree = ElementTree.parse(os.path.join(os.path.dirname(__file__), "switch.xml")) + root = tree.getroot() + parsed = [] + for switch in root: + switch_number = int(switch.find("SwitchNum").text) + branch = int(switch.find("BranchNum").text) + name = switch.find("Name").text if switch.find("Name") is not None else "" + comment = switch.find("Comment").text if switch.find("Comment") is not None else "" + parsed.append(SwitchEnum(switch_number, branch, name, comment)) + return parsed - switches.append(SwitchEnum(switch_number, branch, name, comment)) - print(f"Imported {len(switches)} switches from file") +def get_switches(force_disk: bool = False): + """Return switch definitions using hybrid hydration strategy + Order of precedence (unless force_disk): + 1. Already hydrated global list + 2. Scene cached (scene.switch_list) if present & user prefers cached + 3. Disk XML parse (and bootstrap scene snapshot if empty) + """ + global switches, _switches_hydrated + if _switches_hydrated and not force_disk: + return switches + + scene = getattr(bpy.context, 'scene', None) + prefs = None + try: + prefs = bpy.context.preferences.addons[__package__.split('.')[0]].preferences + except Exception: + pass + prefer_scene = getattr(prefs, 'prefer_scene_snapshot', True) if prefs else True + warn_mismatch = getattr(prefs, 'warn_xml_mismatch', True) if prefs else True + scene_list = getattr(scene, 'switch_list', None) if scene else None + + # Scene snapshot path + if not force_disk and prefer_scene and scene_list and len(scene_list) > 0: + switches = [ + SwitchEnum(int(it.switch_number), int(it.branch_number), it.name, getattr(it, 'comment', "")) + for it in scene_list + ] + _switches_hydrated = True + if warn_mismatch: + try: + disk_list = _parse_switch_xml() + if len(disk_list) != len(switches) or any( + (a.switch_number, a.branch) != (b.switch_number, b.branch) + for a, b in zip(switches, disk_list[:len(switches)]) + ): + print("[BMS get_switches] switch.xml differs from scene snapshot – using scene snapshot (Reload switch.xml to adopt disk changes).") + except Exception: + pass + return switches + + # Disk parse + disk_switches = _parse_switch_xml() + switches = disk_switches + _switches_hydrated = True + if scene_list is not None and len(scene_list) == 0: + for sw in switches: + item = scene_list.add() + item.name = sw.name + item.switch_number = sw.switch_number + item.branch_number = sw.branch + print(f"[BMS get_switches] Imported {len(switches)} switches from file") return switches @@ -145,30 +178,57 @@ def get_scripts(): dofs = [] - - -def get_dofs(): - """Returns a list of BMS DOFs which are loaded from the DOF.xml""" - global dofs - - if dofs is None or len(dofs) == 0: - dofs_tree = ElementTree.parse( - os.path.join(os.path.dirname(__file__), "DOF.xml") - ) - root = dofs_tree.getroot() - dofs = [] - - for dof in root: - dof_number = int(dof.find("DOFNum").text) - if dof.find("Name") is not None and dof.find("Name").text is not None: - name = dof.find("Name").text - else: - name = "" - - dofs.append(DofEnum(dof_number, name)) - - print(f"Imported {len(dofs)} dofs from file") - +_dofs_hydrated = False + + +def _parse_dof_xml(): + tree = ElementTree.parse(os.path.join(os.path.dirname(__file__), "DOF.xml")) + root = tree.getroot() + parsed = [] + for dof in root: + dof_number = int(dof.find("DOFNum").text) + name = dof.find("Name").text if (dof.find("Name") is not None and dof.find("Name").text) else "" + parsed.append(DofEnum(dof_number, name)) + return parsed + + +def get_dofs(force_disk: bool = False): + """Return DOF definitions (hybrid hydration like switches).""" + global dofs, _dofs_hydrated + if _dofs_hydrated and not force_disk: + return dofs + + scene = getattr(bpy.context, 'scene', None) + prefs = None + try: + prefs = bpy.context.preferences.addons[__package__.split('.')[0]].preferences + except Exception: + pass + prefer_scene = getattr(prefs, 'prefer_scene_snapshot', True) if prefs else True + warn_mismatch = getattr(prefs, 'warn_xml_mismatch', True) if prefs else True + scene_list = getattr(scene, 'dof_list', None) if scene else None + + if not force_disk and prefer_scene and scene_list and len(scene_list) > 0: + dofs = [DofEnum(int(it.dof_number), it.name) for it in scene_list] + _dofs_hydrated = True + if warn_mismatch: + try: + disk_list = _parse_dof_xml() + if len(disk_list) != len(dofs) or any(a.dof_number != b.dof_number for a, b in zip(dofs, disk_list[:len(dofs)])): + print("[BMS get_dofs] DOF.xml differs from scene snapshot – using scene snapshot (Reload DOF.xml to adopt disk changes).") + except Exception: + pass + return dofs + + disk_dofs = _parse_dof_xml() + dofs = disk_dofs + _dofs_hydrated = True + if scene_list is not None and len(scene_list) == 0: + for de in dofs: + item = scene_list.add() + item.name = de.name + item.dof_number = de.dof_number + print(f"[BMS get_dofs] Imported {len(dofs)} dofs from file") return dofs @@ -208,6 +268,66 @@ def get_callbacks(): return callbacks +# ----------------------------------------------------------------------------- +# Get switch label for a given persistent switch ID/branch. Uses cached scene switch list first, then xml. +# ----------------------------------------------------------------------------- +def lookup_switch_label(switch_number: int, branch_number: int) -> str | None: + """Return switch label from scene switch_list first, then global XML cache. + + Args: + switch_number: persistent switch number + branch_number: persistent branch number + Returns: + Matching label (may be empty string) or None if not found. + """ + try: + scene_list = getattr(bpy.context.scene, "switch_list", None) + if scene_list: + for item in scene_list: + if getattr(item, "switch_number", None) == switch_number and getattr(item, "branch_number", None) == branch_number: + return getattr(item, "name", None) + except Exception: + pass + # Fallback to global cache + try: + for sw in get_switches(): + if sw.switch_number == switch_number and sw.branch == branch_number: + return sw.name + except Exception: + pass + return None + + +def lookup_dof_label(dof_number: int) -> str | None: + """Return DOF label from scene dof_list first, then global cache.""" + try: + scene_list = getattr(bpy.context.scene, "dof_list", None) + if scene_list: + for item in scene_list: + if getattr(item, "dof_number", None) == dof_number: + return getattr(item, "name", None) + except Exception: + pass + try: + for de in get_dofs(): + if de.dof_number == dof_number: + return de.name + except Exception: + pass + return None + +__all__ = [ + # existing public functions intentionally not exhaustively re-listed here + "get_switches", + "get_dofs", + "get_callbacks", + "get_bml_type", + "get_parent_dof_or_switch", + "lookup_switch_label", + "lookup_dof_label", +] + + def flatten_collection(collection, parent_collection): """Removes all non-switch collections from the tree, moving their objects up and deletes empty collections with no children""" diff --git a/bms_blender_plugin/exporter/export_parent_dat.py b/bms_blender_plugin/exporter/export_parent_dat.py index f9a3c55..fd9d5d1 100644 --- a/bms_blender_plugin/exporter/export_parent_dat.py +++ b/bms_blender_plugin/exporter/export_parent_dat.py @@ -8,6 +8,7 @@ get_dofs, get_bounding_sphere, ) +from bms_blender_plugin.common.resolve_ids import resolve_dof_number, resolve_switch_id from bms_blender_plugin.common.constants import ( BMS_MAX_SWITCH_NUMBER, BMS_MAX_DOF_NUMBER, @@ -24,9 +25,12 @@ def get_highest_switch_and_dof_number(objs): for obj in objs: if len(obj.children) > 0: if get_bml_type(obj) == BlenderNodeType.SWITCH: - # Prefer persistent properties - switch_number = getattr(obj, "bml_switch_number", -1) - if switch_number is None or switch_number < 0: + try: + switch_number, _branch = resolve_switch_id(obj) + except Exception: + switch_number = None + if switch_number is None: + # legacy fallback try: sw = get_switches()[obj.switch_list_index] switch_number = sw.switch_number @@ -36,8 +40,11 @@ def get_highest_switch_and_dof_number(objs): if required_switch_index > highest_switch_number: highest_switch_number = required_switch_index elif get_bml_type(obj) == BlenderNodeType.DOF: - dof_number = getattr(obj, "bml_dof_number", -1) - if dof_number is None or dof_number < 0: + try: + dof_number = resolve_dof_number(obj) + except Exception: + dof_number = None + if dof_number is None: try: dof_enum = get_dofs()[obj.dof_list_index] dof_number = dof_enum.dof_number diff --git a/bms_blender_plugin/exporter/export_render_controls.py b/bms_blender_plugin/exporter/export_render_controls.py index 554f54b..31dd543 100644 --- a/bms_blender_plugin/exporter/export_render_controls.py +++ b/bms_blender_plugin/exporter/export_render_controls.py @@ -11,6 +11,7 @@ ) from bms_blender_plugin.common.blender_types import BlenderEditorNodeType, BlenderNodeTreeType from bms_blender_plugin.common.util import get_dofs +from bms_blender_plugin.common.resolve_ids import resolve_dof_number from bms_blender_plugin.nodes_editor.dof_editor import ( update_node_links, ) @@ -69,9 +70,16 @@ def get_render_control_nodes(node_start_index=0): (ArgType.DOF_ID, render_control_node.arguments[0].type.argument_id) ) result_type = ArgType.DOF_ID - result_id = get_dofs()[ - render_control_node.parent_dof.dof_list_index - ].dof_number + try: + result_id = resolve_dof_number(render_control_node.parent_dof) + except Exception: + result_id = None + if result_id is None: + # legacy fallback to list index + try: + result_id = get_dofs()[render_control_node.parent_dof.dof_list_index].dof_number + except Exception: + result_id = 0 elif render_control_node.arguments[0].type.argument_type == ArgType.SCRATCH_VARIABLE_ID: # the DOF node receives its data from a scratch variable - create a "SET" RC for it math_op = MathOp.SET @@ -79,9 +87,15 @@ def get_render_control_nodes(node_start_index=0): (ArgType.SCRATCH_VARIABLE_ID, render_control_node.arguments[0].type.argument_id) ) result_type = ArgType.DOF_ID - result_id = get_dofs()[ - render_control_node.parent_dof.dof_list_index - ].dof_number + try: + result_id = resolve_dof_number(render_control_node.parent_dof) + except Exception: + result_id = None + if result_id is None: + try: + result_id = get_dofs()[render_control_node.parent_dof.dof_list_index].dof_number + except Exception: + result_id = 0 elif render_control_node.arguments[0].type.argument_type == ArgType.DOF_ID: # the DOF node receives its data directly from an RC with a target DOF - nothing to do diff --git a/bms_blender_plugin/exporter/export_validation.py b/bms_blender_plugin/exporter/export_validation.py index 7b425f9..299c754 100644 --- a/bms_blender_plugin/exporter/export_validation.py +++ b/bms_blender_plugin/exporter/export_validation.py @@ -50,6 +50,7 @@ def _check_material_issues(self, context) -> List[ValidationIssue]: from bms_blender_plugin.common.blender_types import BlenderNodeType, LodItem from bms_blender_plugin.common.util import get_bml_type, get_dofs, get_switches +from bms_blender_plugin.common.resolve_ids import resolve_dof_number, resolve_switch_id class ValidationIssueType(Enum): @@ -149,23 +150,28 @@ def _check_dof_issues(self, context, objects: Optional[Iterable[bpy.types.Object continue persistent_id = getattr(obj, "bml_dof_number", -1) - list_index = getattr(obj, "dof_list_index", 0) + # Use resolver-aligned classification: consistent with export/runtime behavior + try: + resolved_dof_number = resolve_dof_number(obj) + except Exception: + resolved_dof_number = None + if persistent_id < 0: # No persistent ID assigned - if list_index > max_dof_index: - # List index is out of range - XML mismatch issue + if resolved_dof_number is None: + # Cannot be resolved by any means - truly unresolvable out_of_range_objects.append(obj) else: - # Valid list index but no persistent ID - migration needed + # Resolvable via scene cache or XML but no persistent ID - migration needed missing_persistent_id_objects.append(obj) # Create issues for out-of-range objects if out_of_range_objects: description = ( - f"Found {len(out_of_range_objects)} DOF(s) referencing XML entries " - f"not found in current DOF.xml (max index: {max_dof_index}). " - "This usually means your DOF.xml file is outdated." + f"Found {len(out_of_range_objects)} DOF(s) that cannot be resolved to valid DOF numbers. " + "These objects have no persistent ID and their list indices don't match any XML entries. " + "Export will use fallback DOF number 0." ) issues.append(ValidationIssue( ValidationIssueType.DOF_OUT_OF_RANGE, @@ -204,23 +210,28 @@ def _check_switch_issues(self, context, objects: Optional[Iterable[bpy.types.Obj persistent_number = getattr(obj, "bml_switch_number", -1) persistent_branch = getattr(obj, "bml_switch_branch", -1) - list_index = getattr(obj, "switch_list_index", 0) + + # Use resolver-aligned classification: consistent with export/runtime behavior + try: + resolved_switch_number, resolved_branch = resolve_switch_id(obj) + except Exception: + resolved_switch_number, resolved_branch = None, None if persistent_number < 0 or persistent_branch < 0: # No persistent ID assigned - if list_index > max_switch_index: - # List index is out of range - XML mismatch issue + if resolved_switch_number is None or resolved_branch is None: + # Cannot be resolved by any means - truly unresolvable out_of_range_objects.append(obj) else: - # Valid list index but no persistent ID - migration needed + # Resolvable via scene cache or XML but no persistent ID - migration needed missing_persistent_id_objects.append(obj) # Create issues for out-of-range objects if out_of_range_objects: description = ( - f"Found {len(out_of_range_objects)} Switch(es) referencing XML entries " - f"not found in current Switch.xml (max index: {max_switch_index}). " - "This usually means your Switch.xml file is outdated." + f"Found {len(out_of_range_objects)} Switch(es) that cannot be resolved to valid switch numbers. " + "These objects have no persistent IDs and their list indices don't match any XML entries. " + "Export will use fallback switch number 0:0." ) issues.append(ValidationIssue( ValidationIssueType.SWITCH_OUT_OF_RANGE, diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index 8bf3352..a8a123c 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -8,6 +8,7 @@ from bms_blender_plugin.common.hotspot import Hotspot, MouseButton, ButtonType from bms_blender_plugin.common.util import get_bml_type, get_objcenter, get_switches, get_dofs, \ get_non_translate_dof_parent +from bms_blender_plugin.common.resolve_ids import resolve_dof_number, resolve_switch_id from bms_blender_plugin.exporter.bml_mesh import get_bml_mesh_data, get_pbr_light_data from bms_blender_plugin.common.coordinates import to_bms_coords @@ -184,57 +185,39 @@ def parse_slot(obj, nodes): def parse_switch(obj, nodes): """Adds a BML Switch to the BML node list. - Uses persistent properties (bml_switch_number / bml_switch_branch) when present, otherwise falls back to legacy index lookup.""" - print(f"{obj.name} is a SWITCH") - persistent_number = getattr(obj, "bml_switch_number", -1) - persistent_branch = getattr(obj, "bml_switch_branch", -1) - if persistent_number is None: - persistent_number = -1 - if persistent_branch is None: - persistent_branch = -1 - - if persistent_number >= 0 and persistent_branch >= 0: - switch_number = persistent_number - branch = persistent_branch - else: - # Legacy fallback - try: - sw_enum = get_switches()[obj.switch_list_index] - switch_number = sw_enum.switch_number - branch = sw_enum.branch - except Exception: - switch_number = 0 - branch = 0 - nodes.append( - Switch(len(nodes), switch_number, branch, obj.switch_default_on) - ) + Resolution order delegated to resolve_switch_id(). If unresolved defaults to (0,0). + """ + print(f"{obj.name} is a SWITCH") + try: + switch_number, branch = resolve_switch_id(obj) + except Exception: + switch_number, branch = None, None + if switch_number is None or branch is None: + switch_number, branch = 0, 0 + nodes.append(Switch(len(nodes), switch_number, branch, obj.switch_default_on)) return ParsedNodes(vertex_data=[], vertices_length=0, vertices_size=0) def parse_dof(obj, nodes): """Adds a BML DOF to the BML node list. - Uses persistent property (bml_dof_number) when present, otherwise falls back to legacy index lookup.""" + + Resolution order delegated to resolve_dof_number(); default 0 if unresolved. + """ print(f"{obj.name} is a DOF") - # Determine DOF enum/number - persistent_number = getattr(obj, "bml_dof_number", -1) - if persistent_number is None: - persistent_number = -1 - if persistent_number >= 0: - class _TmpDof: # minimal shim to satisfy downstream attribute access - def __init__(self, dof_number): - self.dof_number = dof_number - self.name = f"DOF {dof_number}" - dof = _TmpDof(persistent_number) - else: - try: - dof = get_dofs()[obj.dof_list_index] - except Exception: - class _TmpDof: - def __init__(self): - self.dof_number = 0 - self.name = "DOF 0" - dof = _TmpDof() + try: + resolved_number = resolve_dof_number(obj) + except Exception: + resolved_number = None + if resolved_number is None: + resolved_number = 0 + + class _TmpDof: + def __init__(self, dof_number): + self.dof_number = dof_number + self.name = f"DOF {dof_number}" + + dof = _TmpDof(resolved_number) obj_orig_rotation_mode = obj.rotation_mode obj.rotation_mode = "QUATERNION" diff --git a/bms_blender_plugin/exporter/validation_dialogs.py b/bms_blender_plugin/exporter/validation_dialogs.py index b784d90..fe9f0af 100644 --- a/bms_blender_plugin/exporter/validation_dialogs.py +++ b/bms_blender_plugin/exporter/validation_dialogs.py @@ -137,47 +137,52 @@ def _cleanup_scene_properties(self, context): class BML_OT_ValidationMissingIDDialog(Operator): - """Dialog for handling missing persistent ID issues.""" + """Dialog for handling missing persistent ID issues. + + Updated: Inline confirmation (no secondary pop-up) and clearer, action-focused labels. + """ bl_idname = "bml.validation_missing_id_dialog" - bl_label = "Legacy DOF/Switch Migration" - bl_description = "Resolve missing persistent ID issues" + bl_label = "DOF/Switch IDs Missing" + bl_description = "Assign persistent DOF / Switch IDs before continuing export" use_lods: BoolProperty(default=False) # type: ignore[misc] action: EnumProperty( # type: ignore[misc] name="Action", description="Choose how to handle missing persistent IDs", items=[ - ('SELECT', 'Select Objects & Cancel', 'Select objects and cancel export for manual ID assignment'), - ('AUTO_ASSIGN', 'Auto-assign IDs & Continue', 'Automatically assign persistent IDs and continue export'), - ('CONTINUE', 'Continue Legacy Mode', 'Continue with legacy list-index behavior') + ('SELECT', 'Select & Cancel', 'Select objects and cancel export so you can assign IDs manually'), + ('AUTO_ASSIGN', 'Assign IDs & Continue', 'Automatically assign persistent IDs (recommended) and continue export'), + ('IGNORE', 'Ignore & Continue', 'Continue export without assigning (falls back to legacy index resolution; risky)') ], - default='AUTO_ASSIGN' + default='SELECT' ) def draw(self, context): layout = self.layout - - layout.label(text="Legacy DOF/Switch Migration", icon='INFO') + layout.label(text="Persistent IDs Required", icon='INFO') layout.separator() - + # Recompute issues to reflect current state issues = validate_export_readiness(context, _export_scope_objects(context, self.use_lods)) missing_id_issues = get_missing_persistent_id_issues(issues) - + if missing_id_issues: total_objects = sum(len(issue.objects) for issue in missing_id_issues) layout.label(text=f"Found {total_objects} objects using legacy indices without persistent IDs:") - + box = layout.box() for issue in missing_id_issues: issue_type = issue.issue_type.value.replace('_', ' ').title() box.label(text=f"• {issue_type}: {len(issue.objects)} objects") - - layout.separator() - layout.label(text="These may work for export but may break if XML files are incomplete.") + layout.separator() - + col = layout.column(align=True) + col.label(text="Objects are still using legacy list indices.", icon='ERROR') + col.label(text="Assigning persistent IDs prevents future XML changes from breaking exports.") + col.label(text="Recommended: Assign IDs & Continue.") + layout.separator() + layout.prop(self, "action", expand=True) def invoke(self, context, event): @@ -196,7 +201,10 @@ def execute(self, context): total_objects = sum(len(issue.objects) for issue in missing_id_issues) switch_count, dof_count = self._auto_assign_persistent_ids(missing_id_issues) total_assigned = switch_count + dof_count - + + # Console summary (acts as log) + print(f"[BML][AUTO_ASSIGN] Target Objects: {total_objects} | Switches Assigned: {switch_count} | DOFs Assigned: {dof_count}") + if total_assigned == total_objects: self.report({'INFO'}, f"Successfully assigned persistent IDs to all {total_assigned} objects") elif total_assigned > 0: @@ -205,8 +213,8 @@ def execute(self, context): self.report({'ERROR'}, "Failed to assign any persistent IDs - check console for details") # Continue with export - elif self.action == 'CONTINUE': - self.report({'INFO'}, "Continuing with legacy list-index behavior") + elif self.action == 'IGNORE': + self.report({'WARNING'}, "Continuing without assigning persistent IDs (legacy index fallback)") # Continue with export return {'FINISHED'} diff --git a/bms_blender_plugin/nodes_editor/util.py b/bms_blender_plugin/nodes_editor/util.py index 515779e..7c4e03a 100644 --- a/bms_blender_plugin/nodes_editor/util.py +++ b/bms_blender_plugin/nodes_editor/util.py @@ -189,12 +189,24 @@ def get_bml_node_tree_type(obj): def dof_nodes_have_equal_dof_numbers(node_1, node_2): - """Returns if 2 nodes have equal DOF numbers. Returns false if either of the nodes or their parent DOFs are None""" + """Returns if 2 nodes have equal RESOLVED DOF numbers. Returns false if either node, parent DOF, or DOF number cannot be resolved.""" if (get_bml_node_type(node_1) != BlenderEditorNodeType.DOF_MODEL or not node_1.parent_dof or get_bml_node_type(node_2) != BlenderEditorNodeType.DOF_MODEL or not node_2.parent_dof): return False + # Fast path (same node or same DOF object) if node_1 == node_2 or node_1.parent_dof == node_2.parent_dof: return True - return node_1.parent_dof.dof_list_index == node_2.parent_dof.dof_list_index + # Compare resolved DOF numbers instead of list indices + try: + dof_number_1 = resolve_dof_number(node_1.parent_dof) + dof_number_2 = resolve_dof_number(node_2.parent_dof) + + # Both must resolve to valid numbers to be considered equal + if dof_number_1 is not None and dof_number_2 is not None: + return dof_number_1 == dof_number_2 + else: + return False + except Exception: + return False diff --git a/bms_blender_plugin/preferences.py b/bms_blender_plugin/preferences.py index 1a2b5f8..1474afe 100644 --- a/bms_blender_plugin/preferences.py +++ b/bms_blender_plugin/preferences.py @@ -19,15 +19,13 @@ def execute(self, context): import bms_blender_plugin.common.util as util_module util_module.dofs = [] - # Clear the scene cache + # Clear the cache + util_module._dofs_hydrated = False context.scene.dof_list.clear() - - # Repopulate the scene cache immediately - for dof in get_dofs(): + for dof in get_dofs(force_disk=True): # force_disk to bypass scene cache 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'} @@ -44,16 +42,14 @@ def execute(self, context): import bms_blender_plugin.common.util as util_module util_module.switches = [] - # Clear the scene cache + # Clear the cache + util_module._switches_hydrated = False context.scene.switch_list.clear() - - # Repopulate the scene cache immediately - for switch in get_switches(): + for switch in get_switches(force_disk=True): # force_disk to bypass scene cache 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'} @@ -106,6 +102,17 @@ class ExporterPreferences(bpy.types.AddonPreferences): default=True, ) + prefer_scene_snapshot: BoolProperty( + name="Prefer Scene Snapshot", + description="Use scene-cached switch/DOF lists if present. (Recommended)", + default=True, + ) + warn_xml_mismatch: BoolProperty( + name="Warn on XML Mismatch", + description="Print a console warning if disk XML differs from scene cache.", + default=True, + ) + copy_to_clipboard_command: StringProperty( name="Alternative 'Copy to Clipboard' command", description="Override command to copy text to the clipboard (especially useful on Linux)", @@ -196,6 +203,9 @@ def draw(self, context): box.operator(ReloadDofList.bl_idname, icon="FILE_REFRESH") box.operator(ReloadSwitchList.bl_idname, icon="FILE_REFRESH") box.operator(ReloadCallbackList.bl_idname, icon="FILE_REFRESH") + box.separator() + box.prop(self, "prefer_scene_snapshot") + box.prop(self, "warn_xml_mismatch") layout.separator() layout.row().label(text="Debug options") diff --git a/bms_blender_plugin/ui_tools/dof_behaviour.py b/bms_blender_plugin/ui_tools/dof_behaviour.py index 178ab99..5e7f3e0 100644 --- a/bms_blender_plugin/ui_tools/dof_behaviour.py +++ b/bms_blender_plugin/ui_tools/dof_behaviour.py @@ -19,7 +19,10 @@ get_dofs, reset_dof, get_parent_dof_or_switch, + lookup_switch_label, + lookup_dof_label, ) +from bms_blender_plugin.common.resolve_ids import resolve_dof_number, resolve_switch_id from bms_blender_plugin.nodes_editor.util import get_bml_node_type, get_bml_node_tree_type @@ -47,10 +50,20 @@ def rebuild_cache(cls): @classmethod def subscribe(cls, dof): - """Subscribes a DOF to dof_input updates for his DOF number""" + """Subscribes a DOF to dof_input updates for using resolved DOF number. + + Uses resolve_dof_number() to tolerate legacy index-only DOFs. If the DOF + can't be resolved (returns None) we skip subscription silently rather than + raising an exception that could spam the depsgraph handler while the user + is mid-migration.""" if get_bml_type(dof) != BlenderNodeType.DOF: return - dof_number = get_dofs()[dof.dof_list_index].dof_number + try: + dof_number = resolve_dof_number(dof) + except Exception: + dof_number = None + if dof_number is None: + return # first time subscription if dof not in cls.dof_dof_number.keys(): @@ -81,9 +94,21 @@ def subscribe(cls, dof): @classmethod def unsubscribe(cls, dof): """Unsubscribes a DOF from all subscriptions""" - dof_number = get_dofs()[dof.dof_list_index].dof_number - cls.dof_number_dofs[dof_number].remove(dof) - cls.dof_dof_number.pop(dof) + try: + dof_number = resolve_dof_number(dof) + except Exception: + dof_number = None + if dof_number is None: + # Fallback: attempt legacy index if still valid + try: + dof_number = get_dofs()[dof.dof_list_index].dof_number + except Exception: + dof_number = None + if dof_number is None: + return + if dof_number in cls.dof_number_dofs and dof in cls.dof_number_dofs[dof_number]: + cls.dof_number_dofs[dof_number].remove(dof) + cls.dof_dof_number.pop(dof, None) @classmethod def post_new_dof_value(cls, dof): @@ -94,12 +119,25 @@ def post_new_dof_value(cls, dof): if dof not in cls.dof_dof_number: cls.rebuild_cache() - dof_number = get_dofs()[dof.dof_list_index].dof_number + try: + dof_number = resolve_dof_number(dof) + except Exception: + dof_number = None + if dof_number is None: + # Legacy fallback + try: + dof_number = get_dofs()[dof.dof_list_index].dof_number + except Exception: + return + + if dof_number not in cls.dof_number_dofs: + # Nothing to propagate to + return dofs_to_cleanup = [] new_dof_input = dof.dof_input - for other_dof in cls.dof_number_dofs[dof_number]: + for other_dof in list(cls.dof_number_dofs[dof_number]): if ( len(other_dof.users_collection) > 0 and other_dof.dof_input != new_dof_input @@ -109,60 +147,60 @@ def post_new_dof_value(cls, dof): dofs_to_cleanup.append(other_dof) # clean up orphaned DOFs which might have accumulated - for dof in dofs_to_cleanup: - cls.dof_number_dofs[dof_number].remove(dof) - cls.dof_dof_number.pop(dof) - bpy.data.objects.remove(dof) + for cleanup_dof in dofs_to_cleanup: + cls.dof_number_dofs[dof_number].remove(cleanup_dof) + cls.dof_dof_number.pop(cleanup_dof, None) + try: + bpy.data.objects.remove(cleanup_dof) + except Exception: + pass def update_switch_or_dof_name(obj, context): - """Updates the name of a DOF or Switch when their respective DOF/Switch values are changed. Overwrites any previous - name updates by the user.""" - if get_bml_type(obj) == BlenderNodeType.SWITCH: - # Prefer persistent properties + """Update object.name for Switch/DOF using persistent IDs, preferring scene-cached list first. + Fallback order per type: + Persistent IDs -> resolver -> direct index -> Unset + """ + node_type = get_bml_type(obj) + if node_type == BlenderNodeType.SWITCH: sw_num = getattr(obj, "bml_switch_number", -1) sw_branch = getattr(obj, "bml_switch_branch", -1) - label_name = None - if sw_num is not None and sw_num >= 0 and sw_branch is not None and sw_branch >= 0: - # Try to find matching enum (to display its name) but tolerate absence - try: - for sw in get_switches(): - if sw.switch_number == sw_num and sw.branch == sw_branch: - label_name = sw.name - break - except Exception: - pass - if label_name is None: - label_name = "Custom" - obj.name = f"Switch - {label_name} ({sw_num}:{sw_branch})" + if sw_num >= 0 and sw_branch >= 0: + label = lookup_switch_label(sw_num, sw_branch) or "Custom" + obj.name = f"Switch - {label} ({sw_num}:{sw_branch})" else: - # Legacy fallback try: - active_switch = get_switches()[obj.switch_list_index] - obj.name = f"Switch - {active_switch.name} ({active_switch.switch_number})" + resolved_num, resolved_branch = resolve_switch_id(obj) except Exception: - obj.name = "Switch - Unset" - elif get_bml_type(obj) == BlenderNodeType.DOF: + resolved_num, resolved_branch = None, None + if resolved_num is not None and resolved_branch is not None: + label = lookup_switch_label(resolved_num, resolved_branch) or "Custom" + obj.name = f"Switch - {label} ({resolved_num}:{resolved_branch})" + else: + try: + sw = get_switches()[getattr(obj, 'switch_list_index', -1)] + obj.name = f"Switch - {sw.name} ({sw.switch_number}:{sw.branch})" + except Exception: + obj.name = "Switch - Unset" + elif node_type == BlenderNodeType.DOF: dof_num = getattr(obj, "bml_dof_number", -1) - if dof_num is not None and dof_num >= 0: - # Try resolve name for consistency - dof_name = None - try: - for de in get_dofs(): - if de.dof_number == dof_num: - dof_name = de.name - break - except Exception: - pass - if dof_name is None: - dof_name = "Custom" - obj.name = f"DOF - {dof_name} ({dof_num})" + if dof_num >= 0: + label = lookup_dof_label(dof_num) or "Custom" + obj.name = f"DOF - {label} ({dof_num})" else: try: - active_dof = get_dofs()[obj.dof_list_index] - obj.name = f"DOF - {active_dof.name} ({active_dof.dof_number})" + resolved = resolve_dof_number(obj) except Exception: - obj.name = "DOF - Unset" + resolved = None + if resolved is not None: + label = lookup_dof_label(resolved) or "Custom" + obj.name = f"DOF - {label} ({resolved})" + else: + try: + de = get_dofs()[getattr(obj, 'dof_list_index', -1)] + obj.name = f"DOF - {de.name} ({de.dof_number})" + except Exception: + obj.name = "DOF - Unset" for tree in bpy.data.node_groups.values(): if isinstance(tree, nodes_editor.dof_editor.DofNodeTree): diff --git a/bms_blender_plugin/ui_tools/operators/__init__.py b/bms_blender_plugin/ui_tools/operators/__init__.py index 4c2ce81..3310a48 100644 --- a/bms_blender_plugin/ui_tools/operators/__init__.py +++ b/bms_blender_plugin/ui_tools/operators/__init__.py @@ -9,7 +9,7 @@ update_switch_or_dof_name, dof_set_input, dof_get_input, ) from bms_blender_plugin.ui_tools.slot_behaviour import update_slot_number -from bms_blender_plugin.common.util import get_switches, get_dofs +from bms_blender_plugin.common.util import get_switches, get_dofs, get_bml_type from bms_blender_plugin.common.constants import ( BMS_MAX_SWITCH_NUMBER, BMS_MAX_SWITCH_BRANCH, @@ -25,6 +25,15 @@ def _update_switch_list_index(obj, context): sw = switches[obj.switch_list_index] obj.bml_switch_number = sw.switch_number obj.bml_switch_branch = sw.branch + try: + print(f"[DEBUG] _update_switch_list_index: obj={getattr(obj,'name',None)} index={obj.switch_list_index} -> {sw.switch_number}:{sw.branch}") + except Exception: + pass + else: + try: + print(f"[DEBUG] _update_switch_list_index: obj={getattr(obj,'name',None)} index={getattr(obj,'switch_list_index',None)} out_of_range (len={len(switches)})") + except Exception: + pass except Exception: pass update_switch_or_dof_name(obj, context) @@ -61,15 +70,23 @@ def _tag_redraw(ctx): sw_num = getattr(obj, "bml_switch_number", -1) sw_branch = getattr(obj, "bml_switch_branch", -1) if sw_num >= 0 and sw_branch >= 0: - switches = get_switches() - for i, sw in enumerate(switches): - if sw.switch_number == sw_num and sw.branch == sw_branch: - if getattr(obj, "switch_list_index", -1) != i: - # Will not recurse persistent update since indices handler only sets IDs if unset - obj.switch_list_index = i - _tag_redraw(context) - break - # If either is unset (<0), do nothing: legacy index remains visible + scene_list = getattr(bpy.context.scene, 'switch_list', None) + found_index = None + if scene_list: + for i, item in enumerate(scene_list): + if item.switch_number == sw_num and item.branch_number == sw_branch: + found_index = i + break + if found_index is None: + switches = get_switches() + for i, sw in enumerate(switches): + if sw.switch_number == sw_num and sw.branch == sw_branch: + found_index = i + break + if found_index is not None and getattr(obj, 'switch_list_index', -1) != found_index: + obj.switch_list_index = found_index + _tag_redraw(context) + # If unset leave legacy index except Exception: pass @@ -90,13 +107,23 @@ def _tag_redraw(ctx): try: dof_num = getattr(obj, "bml_dof_number", -1) if dof_num >= 0: - dofs = get_dofs() - for i, de in enumerate(dofs): - if de.dof_number == dof_num: - if getattr(obj, "dof_list_index", -1) != i: - obj.dof_list_index = i - _tag_redraw(context) - break + scene_list = getattr(bpy.context.scene, 'dof_list', None) + found_index = None + if scene_list: + for i, item in enumerate(scene_list): + if item.dof_number == dof_num: + found_index = i + break + if found_index is None: + dofs = get_dofs() + for i, de in enumerate(dofs): + if de.dof_number == dof_num: + found_index = i + break + if found_index is not None and getattr(obj, 'dof_list_index', -1) != found_index: + obj.dof_list_index = found_index + _tag_redraw(context) + # Unset -> leave legacy index except Exception: pass @@ -311,29 +338,38 @@ def register_blender_properties(): update=dof_update_input, ) - # Migration: fill persistent properties for legacy scenes + # Silent legacy migration disabled: manual validation-driven assignment required. try: for obj in bpy.data.objects: - if getattr(obj, "bml_switch_number", -1) < 0 and hasattr(obj, "switch_list_index"): - try: - switches = get_switches() - if 0 <= obj.switch_list_index < len(switches): - sw = switches[obj.switch_list_index] - obj.bml_switch_number = sw.switch_number - obj.bml_switch_branch = sw.branch - except Exception: - pass - if getattr(obj, "bml_dof_number", -1) < 0 and hasattr(obj, "dof_list_index"): - try: - dofs = get_dofs() - if 0 <= obj.dof_list_index < len(dofs): - de = dofs[obj.dof_list_index] - obj.bml_dof_number = de.dof_number - except Exception: - pass update_switch_or_dof_name(obj, None) except Exception: pass +class BML_OT_reconcile_dof_switch_indices(bpy.types.Operator): + bl_idname = "bml.reconcile_dof_switch_indices" + bl_label = "Reconcile DOF/Switch Indices" + bl_description = "Synchronize xml list indices with current persistent IDs. Persistent ID -> List Index. (Prioritize scene cached list.)" + bl_options = {"UNDO"} + + def execute(self, context): + count = 0 + for obj in bpy.data.objects: + t = get_bml_type(obj) + if t == BlenderNodeType.SWITCH: + _update_persistent_switch_ids(obj, context) + count += 1 + elif t == BlenderNodeType.DOF: + _update_persistent_dof_number(obj, context) + count += 1 + self.report({'INFO'}, f"Reconciled indices for {count} DOF/Switch objects") + return {'FINISHED'} + + register_blender_properties() + +# Explicit registration for reconciliation operator (others auto-executed above) +try: + bpy.utils.register_class(BML_OT_reconcile_dof_switch_indices) +except Exception: + pass diff --git a/bms_blender_plugin/ui_tools/operators/assign_from_index.py b/bms_blender_plugin/ui_tools/operators/assign_from_index.py index 64c1b09..f3c702c 100644 --- a/bms_blender_plugin/ui_tools/operators/assign_from_index.py +++ b/bms_blender_plugin/ui_tools/operators/assign_from_index.py @@ -29,13 +29,25 @@ def execute(self, context): obj = get_parent_dof_or_switch(context.active_object) switches = get_switches() idx = getattr(obj, "switch_list_index", -1) + try: + print(f"[DEBUG] assign_switch_from_index.pre: obj={getattr(obj,'name',None)} index={idx} switches_len={len(switches)}") + except Exception: + pass if 0 <= idx < len(switches): sw = switches[idx] obj.bml_switch_number = sw.switch_number obj.bml_switch_branch = sw.branch update_switch_or_dof_name(obj, context) + try: + print(f"[DEBUG] assign_switch_from_index.post: obj={getattr(obj,'name',None)} assigned={sw.switch_number}:{sw.branch} from_index={idx}") + except Exception: + pass self.report({'INFO'}, f"Assigned Switch #{sw.switch_number} Branch {sw.branch} from index {idx}") return {'FINISHED'} + try: + print(f"[DEBUG] assign_switch_from_index.out_of_range: obj={getattr(obj,'name',None)} index={idx} len={len(switches)}") + except Exception: + pass self.report({'WARNING'}, ( f"Switch list index {idx} out of range; no assignment performed. " f"List may be stale or truncated – reload switch.xml (disable/enable addon) or refresh definitions." diff --git a/bms_blender_plugin/ui_tools/panels/switch_panel.py b/bms_blender_plugin/ui_tools/panels/switch_panel.py index e5e6668..1018f25 100644 --- a/bms_blender_plugin/ui_tools/panels/switch_panel.py +++ b/bms_blender_plugin/ui_tools/panels/switch_panel.py @@ -56,8 +56,11 @@ def filter_items(self, context, data, propname): if switch_branch_text.startswith(filter_text): flt_flags[i] |= self.bitflag_filter_item else: - # No filter, sort by name - flt_neworder = bpy.types.UI_UL_list.sort_items_by_name(switches, "name") + # No filter: preserve original insertion (XML) order which is already numeric (switch_number, branch_number) + if switches: + # Flag all items visible; no reordering + flt_flags = [self.bitflag_filter_item] * len(switches) + flt_neworder = [] # empty => keep original order return flt_flags, flt_neworder