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 01/25] 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 02/25] 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 03/25] 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 04/25] 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 05/25] 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 06/25] 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 07/25] 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 08/25] 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 09/25] 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 From be706a17528479a5c74b776e09472cb07ab7a7f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:45:54 +0000 Subject: [PATCH 10/25] Add export profiling and packing optimizations Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/7b05b8fd-880b-48c3-80dc-4c5f35b3d6b5 Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 199 ++++++---- bms_blender_plugin/exporter/bml_mesh.py | 342 +++++++++++------- bms_blender_plugin/exporter/bml_output.py | 60 +-- bms_blender_plugin/exporter/export_lods.py | 187 +++++----- .../exporter/export_profiler.py | 34 ++ bms_blender_plugin/exporter/parser.py | 65 ++-- 6 files changed, 556 insertions(+), 331 deletions(-) create mode 100644 bms_blender_plugin/exporter/export_profiler.py diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index a97da47..1f10f7c 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -249,7 +249,7 @@ def get_non_translate_dof_parent(obj): def copy_collection_flat( - from_collection, to_collection, excluded_collections, scale_factor + from_collection, to_collection, excluded_collections, scale_factor, export_profiler=None ): """Copies a collection and all of its objects but not its child-collections. Also applies a scale factor to its objects""" @@ -260,12 +260,12 @@ def copy_collection_flat( if collection_object.parent is None: # root object - copy that copied_object = copy_object( - collection_object, None, to_collection, scale_factor + collection_object, None, to_collection, scale_factor, export_profiler ) for collection_child in from_collection.children: copy_collection_flat( - collection_child, to_collection, excluded_collections, scale_factor + collection_child, to_collection, excluded_collections, scale_factor, export_profiler ) # toggle object mode to make sure that the scaling has been applied (Blender quirk) @@ -295,102 +295,155 @@ def reset_dof(obj): obj.delta_scale.z = 1 -def copy_object(obj, parent, collection, scale_factor=1): +def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): """Recursively copies an object and all of its children and moves their copies to a given collection. Also applies a scale factor""" if not obj.hide_render and len(obj.users_collection) != 0: - copied_object = obj.copy() - copied_object.parent = parent - copied_object.matrix_parent_inverse = obj.matrix_parent_inverse.copy() - - if obj.data: - copied_object.data = copied_object.data.copy() - for k, e in obj.items(): - copied_object[k] = e - - # copy and apply all modifiers - for obj_modifier in obj.modifiers: - copied_object_modifiers = obj.modifiers.get(obj_modifier.name, None) - if not copied_object_modifiers: - copied_object_modifiers = obj.modifiers.new( - obj_modifier.name, obj_modifier.type - ) + if export_profiler: + with export_profiler.stage("collection copy: duplicate objects"): + copied_object = obj.copy() + copied_object.parent = parent + copied_object.matrix_parent_inverse = obj.matrix_parent_inverse.copy() + + if obj.data: + copied_object.data = copied_object.data.copy() + for k, e in obj.items(): + copied_object[k] = e + + for obj_modifier in obj.modifiers: + copied_object_modifiers = obj.modifiers.get(obj_modifier.name, None) + if not copied_object_modifiers: + copied_object_modifiers = obj.modifiers.new( + obj_modifier.name, obj_modifier.type + ) + + properties = [ + p.identifier + for p in obj_modifier.bl_rna.properties + if not p.is_readonly + ] + + for prop in properties: + setattr(copied_object_modifiers, prop, getattr(obj_modifier, prop)) + + if get_bml_type(obj, False) == BlenderNodeType.DOF: + reset_dof(copied_object) + + if scale_factor != 1 and obj.parent is None: + copied_object.scale *= scale_factor + copied_object.location *= scale_factor + + collection.objects.link(copied_object) + + copied_object.hide_select = False + copied_object.hide_viewport = False + copied_object.hide_set(False) + else: + copied_object = obj.copy() + copied_object.parent = parent + copied_object.matrix_parent_inverse = obj.matrix_parent_inverse.copy() + + if obj.data: + copied_object.data = copied_object.data.copy() + for k, e in obj.items(): + copied_object[k] = e - # collect names of writable properties - properties = [ - p.identifier - for p in obj_modifier.bl_rna.properties - if not p.is_readonly - ] + for obj_modifier in obj.modifiers: + copied_object_modifiers = obj.modifiers.get(obj_modifier.name, None) + if not copied_object_modifiers: + copied_object_modifiers = obj.modifiers.new( + obj_modifier.name, obj_modifier.type + ) - # copy those properties - for prop in properties: - setattr(copied_object_modifiers, prop, getattr(obj_modifier, prop)) + properties = [ + p.identifier + for p in obj_modifier.bl_rna.properties + if not p.is_readonly + ] - # set all DOFs to 0 - if get_bml_type(obj, False) == BlenderNodeType.DOF: - reset_dof(copied_object) + for prop in properties: + setattr(copied_object_modifiers, prop, getattr(obj_modifier, prop)) - # scale only the root objects - if scale_factor != 1 and obj.parent is None: - copied_object.scale *= scale_factor - copied_object.location *= scale_factor + if get_bml_type(obj, False) == BlenderNodeType.DOF: + reset_dof(copied_object) - collection.objects.link(copied_object) + if scale_factor != 1 and obj.parent is None: + copied_object.scale *= scale_factor + copied_object.location *= scale_factor - # override any selection restriction - copied_object.hide_select = False - copied_object.hide_viewport = False - copied_object.hide_set(False) + collection.objects.link(copied_object) + + copied_object.hide_select = False + copied_object.hide_viewport = False + copied_object.hide_set(False) for obj_child in obj.children: - copy_object(obj_child, copied_object, collection, scale_factor) + copy_object(obj_child, copied_object, collection, scale_factor, export_profiler) return copied_object -def apply_all_modifiers(collection): +def apply_all_modifiers(collection, export_profiler=None): """Applies all modifiers to objects which are rooted in the given collection""" for obj in collection.objects: if obj.parent is None: - apply_all_modifiers_on_obj(obj) + apply_all_modifiers_on_obj(obj, export_profiler) -def apply_all_modifiers_on_obj(obj): +def apply_all_modifiers_on_obj(obj, export_profiler=None): """Applies all modifiers to a single object. Empties (DOFs, Slots and Switches) are excepted, since applying their modifiers would reset their positions. """ if obj: - bpy.ops.object.select_all(action="DESELECT") - # apply the modifiers - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - - if obj.type == "MESH": - 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, - BlenderNodeType.HOTSPOT, - ]: - bpy.ops.object.transform_apply() + if export_profiler: + with export_profiler.stage("modifier application: apply modifiers"): + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + if obj.type == "MESH": + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.convert(target="MESH", keep_original=False) + + if (obj.type == "MESH" and + get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): + obj["bms_reference_point"] = tuple(obj.location) + + if get_bml_type(obj) not in [ + BlenderNodeType.DOF, + BlenderNodeType.SLOT, + BlenderNodeType.HOTSPOT, + ]: + bpy.ops.object.transform_apply() + else: + bpy.ops.object.transform_apply( + location=False, rotation=False, scale=True, properties=False + ) else: - # only apply scaling operations to those objects - # all other operations would reset them since they are empties - bpy.ops.object.transform_apply( - location=False, rotation=False, scale=True, properties=False - ) + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + + if obj.type == "MESH": + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.convert(target="MESH", keep_original=False) + + if (obj.type == "MESH" and + get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): + obj["bms_reference_point"] = tuple(obj.location) + + if get_bml_type(obj) not in [ + BlenderNodeType.DOF, + BlenderNodeType.SLOT, + BlenderNodeType.HOTSPOT, + ]: + bpy.ops.object.transform_apply() + else: + bpy.ops.object.transform_apply( + location=False, rotation=False, scale=True, properties=False + ) for child in obj.children: - apply_all_modifiers_on_obj(child) + apply_all_modifiers_on_obj(child, export_profiler) def uncompress_file(src, dest): diff --git a/bms_blender_plugin/exporter/bml_mesh.py b/bms_blender_plugin/exporter/bml_mesh.py index ec05817..0d7f1d9 100644 --- a/bms_blender_plugin/exporter/bml_mesh.py +++ b/bms_blender_plugin/exporter/bml_mesh.py @@ -18,21 +18,35 @@ from bms_blender_plugin.common.coordinates import to_bms_coords -def get_bml_mesh_data(obj, max_vertex_index): +def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): """Returns the raw mesh data in the BML format as a tuple of vertices and vertex indices""" mesh = obj.data - bm = bmesh.new() - bm.from_mesh(mesh) + if export_profiler: + with export_profiler.stage("mesh extraction: triangulate"): + bm = bmesh.new() + bm.from_mesh(mesh) + + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + bm.to_mesh(mesh) + bm.free() + else: + bm = bmesh.new() + bm.from_mesh(mesh) - bmesh.ops.triangulate(bm, faces=bm.faces[:]) - bm.to_mesh(mesh) - bm.free() + bmesh.ops.triangulate(bm, faces=bm.faces[:]) + bm.to_mesh(mesh) + bm.free() - uv_names = [uvlayer.name for uvlayer in mesh.uv_layers] if len(mesh.loops) > 0: - mesh.calc_normals() - for name in uv_names: - mesh.calc_tangents(uvmap=name) + if export_profiler: + with export_profiler.stage("mesh extraction: normals/tangents"): + mesh.calc_normals() + if mesh.uv_layers.active: + mesh.calc_tangents(uvmap=mesh.uv_layers.active.name) + else: + mesh.calc_normals() + if mesh.uv_layers.active: + mesh.calc_tangents(uvmap=mesh.uv_layers.active.name) pb_vertices = [] pb_vertices_per_face = [] @@ -66,56 +80,90 @@ def get_bml_mesh_data(obj, max_vertex_index): - for face in mesh.polygons: - # loop over face loop - for vert in [mesh.loops[i] for i in face.loop_indices]: - vertex_pbr = VertexPBR() - vertex_index = vert.vertex_index - vertex_indices.append(vert.index + max_vertex_index) - - # position - object_global_coord = to_bms_coords( - world_coord @ mesh.vertices[vertex_index].co - ) - vertex_pbr.position = Vector3( - object_global_coord.x, object_global_coord.y, object_global_coord.z - ) - - # normal - object_global_normal = to_bms_coords(world_normal @ vert.normal) - # normalize the vector to remove any rounding errors - object_global_normal = object_global_normal.normalized() - vertex_pbr.normal = Vector3( - object_global_normal.x, object_global_normal.y, object_global_normal.z - ) - - # tangent & uv - if mesh.uv_layers.active: - object_global_tangent = to_bms_coords(vert.tangent) - vertex_pbr.tangent = Vector3( - object_global_tangent.x, - object_global_tangent.y, - object_global_tangent.z, + active_uv_layer = mesh.uv_layers.active.data if mesh.uv_layers.active else None + + if export_profiler: + with export_profiler.stage("mesh extraction: build vertices"): + for face in mesh.polygons: + for vert in [mesh.loops[i] for i in face.loop_indices]: + vertex_pbr = VertexPBR() + vertex_index = vert.vertex_index + vertex_indices.append(vert.index + max_vertex_index) + + object_global_coord = to_bms_coords( + world_coord @ mesh.vertices[vertex_index].co + ) + vertex_pbr.position = Vector3( + object_global_coord.x, object_global_coord.y, object_global_coord.z + ) + + object_global_normal = to_bms_coords(world_normal @ vert.normal) + object_global_normal = object_global_normal.normalized() + vertex_pbr.normal = Vector3( + object_global_normal.x, object_global_normal.y, object_global_normal.z + ) + + if active_uv_layer: + object_global_tangent = to_bms_coords(vert.tangent) + vertex_pbr.tangent = Vector3( + object_global_tangent.x, + object_global_tangent.y, + object_global_tangent.z, + ) + vertex_pbr.handedness = vert.bitangent_sign + + uv = tuple(to_bms_coords(tuple(active_uv_layer[vert.index].uv))) + vertex_pbr.uv = Vector2(uv[0], uv[1]) + + pb_vertices_per_face.append(vertex_pbr) + + pb_vertices.append(pb_vertices_per_face[0]) + pb_vertices.append(pb_vertices_per_face[2]) + pb_vertices.append(pb_vertices_per_face[1]) + pb_vertices_per_face = [] + else: + for face in mesh.polygons: + for vert in [mesh.loops[i] for i in face.loop_indices]: + vertex_pbr = VertexPBR() + vertex_index = vert.vertex_index + vertex_indices.append(vert.index + max_vertex_index) + + object_global_coord = to_bms_coords( + world_coord @ mesh.vertices[vertex_index].co + ) + vertex_pbr.position = Vector3( + object_global_coord.x, object_global_coord.y, object_global_coord.z ) - vertex_pbr.handedness = vert.bitangent_sign - uv = tuple( - to_bms_coords(tuple(mesh.uv_layers.active.data[vert.index].uv)) + object_global_normal = to_bms_coords(world_normal @ vert.normal) + object_global_normal = object_global_normal.normalized() + vertex_pbr.normal = Vector3( + object_global_normal.x, object_global_normal.y, object_global_normal.z ) - vertex_pbr.uv = Vector2(uv[0], uv[1]) - pb_vertices_per_face.append(vertex_pbr) + if active_uv_layer: + object_global_tangent = to_bms_coords(vert.tangent) + vertex_pbr.tangent = Vector3( + object_global_tangent.x, + object_global_tangent.y, + object_global_tangent.z, + ) + vertex_pbr.handedness = vert.bitangent_sign - # switch the handedness by swapping the vertices - pb_vertices.append(pb_vertices_per_face[0]) - pb_vertices.append(pb_vertices_per_face[2]) - pb_vertices.append(pb_vertices_per_face[1]) - pb_vertices_per_face = [] + uv = tuple(to_bms_coords(tuple(active_uv_layer[vert.index].uv))) + vertex_pbr.uv = Vector2(uv[0], uv[1]) + + pb_vertices_per_face.append(vertex_pbr) + + pb_vertices.append(pb_vertices_per_face[0]) + pb_vertices.append(pb_vertices_per_face[2]) + pb_vertices.append(pb_vertices_per_face[1]) + pb_vertices_per_face = [] return {"vertices": pb_vertices, "vertex_indices": vertex_indices} -def get_pbr_light_data(obj, max_vertex_index): +def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): """Returns the BML specific data for a PBR billboard light (BBL)""" # lookup table for the signs of the uv2 coords in a rectangle uv2_sign_lookup = [(1, 1), (-1, 1), (-1, -1), (1, -1)] @@ -132,87 +180,137 @@ def get_pbr_light_data(obj, max_vertex_index): vertex_index = max_vertex_index - for face in mesh.polygons: - # loop over face loop - - if len(face.vertices) != 4: - raise Exception("BBLights can only consist of rectangular planes") - - # calculate width and height - face_width = ( - mesh.vertices[face.vertices[0]].co - mesh.vertices[face.vertices[1]].co - ).length - face_height = ( - mesh.vertices[face.vertices[1]].co - mesh.vertices[face.vertices[2]].co - ).length - - # load the stored colors and normals from the polygon layers - color = ( - mesh.polygon_layers_float["bml_color_r"].data[face.index].value, - mesh.polygon_layers_float["bml_color_g"].data[face.index].value, - mesh.polygon_layers_float["bml_color_b"].data[face.index].value, - mesh.polygon_layers_float["bml_color_a"].data[face.index].value, - ) - - normal = Vector( - ( - mesh.polygon_layers_float["bml_normal_x"].data[face.index].value, - mesh.polygon_layers_float["bml_normal_y"].data[face.index].value, - mesh.polygon_layers_float["bml_normal_z"].data[face.index].value, - ) - ) + if export_profiler: + with export_profiler.stage("mesh extraction: build light vertices"): + for face in mesh.polygons: + if len(face.vertices) != 4: + raise Exception("BBLights can only consist of rectangular planes") + + face_width = ( + mesh.vertices[face.vertices[0]].co - mesh.vertices[face.vertices[1]].co + ).length + face_height = ( + mesh.vertices[face.vertices[1]].co - mesh.vertices[face.vertices[2]].co + ).length + + color = ( + mesh.polygon_layers_float["bml_color_r"].data[face.index].value, + mesh.polygon_layers_float["bml_color_g"].data[face.index].value, + mesh.polygon_layers_float["bml_color_b"].data[face.index].value, + mesh.polygon_layers_float["bml_color_a"].data[face.index].value, + ) - current_light_position = world_coord @ face.center + normal = Vector( + ( + mesh.polygon_layers_float["bml_normal_x"].data[face.index].value, + mesh.polygon_layers_float["bml_normal_y"].data[face.index].value, + mesh.polygon_layers_float["bml_normal_z"].data[face.index].value, + ) + ) - # the normal will already be set to [0, 0, 0] for omnidirectional lights by join_objects_with_same_materials() - current_light_normal = to_bms_coords(world_normal @ normal) - # normalize the vector to remove any rounding errors - current_light_normal = current_light_normal.normalized() + current_light_position = world_coord @ face.center + current_light_normal = to_bms_coords(world_normal @ normal) + current_light_normal = current_light_normal.normalized() + + for i in [1, 0, 3, 1, 3, 2]: + vs_input_light = VSInputLight() + vertex_indices.append(vertex_index) + + current_light_position_bms_coords = to_bms_coords(current_light_position) + vs_input_light.position = Vector3( + current_light_position_bms_coords.x, + current_light_position_bms_coords.y, + current_light_position_bms_coords.z, + ) + vs_input_light.normal = Vector3( + current_light_normal.x, current_light_normal.y, current_light_normal.z + ) + + color_bytes = ( + (from_blender_color(color[3]) << 24) + + (from_blender_color(color[2]) << 16) + + (from_blender_color(color[1]) << 8) + + (from_blender_color(color[0]) << 0) + ) + vs_input_light.color = color_bytes + + if mesh.uv_layers.active: + uv = tuple(to_bms_coords(tuple(mesh.uv_layers.active.data[i].uv))) + vs_input_light.uv1 = Vector2(uv[0], uv[1]) + + uv2_signs = uv2_sign_lookup[i] + uv2 = Vector( + (uv2_signs[0] * face_width / 2, uv2_signs[1] * face_height / 2) + ) + vs_input_light.uv2 = Vector2(uv2[0], uv2[1]) + + bbl_vertices.append(vs_input_light) + vertex_index += 1 + else: + for face in mesh.polygons: + if len(face.vertices) != 4: + raise Exception("BBLights can only consist of rectangular planes") + + face_width = ( + mesh.vertices[face.vertices[0]].co - mesh.vertices[face.vertices[1]].co + ).length + face_height = ( + mesh.vertices[face.vertices[1]].co - mesh.vertices[face.vertices[2]].co + ).length + + color = ( + mesh.polygon_layers_float["bml_color_r"].data[face.index].value, + mesh.polygon_layers_float["bml_color_g"].data[face.index].value, + mesh.polygon_layers_float["bml_color_b"].data[face.index].value, + mesh.polygon_layers_float["bml_color_a"].data[face.index].value, + ) - # for each poly, add two triangles (== 6 vertices) - # blender iterates counter-clockwise, so the following vertex indices will form 2 adjacent triangles of a - # rectangular poly + normal = Vector( + ( + mesh.polygon_layers_float["bml_normal_x"].data[face.index].value, + mesh.polygon_layers_float["bml_normal_y"].data[face.index].value, + mesh.polygon_layers_float["bml_normal_z"].data[face.index].value, + ) + ) - for i in [1, 0, 3, 1, 3, 2]: - vs_input_light = VSInputLight() - vertex_indices.append(vertex_index) + current_light_position = world_coord @ face.center + current_light_normal = to_bms_coords(world_normal @ normal) + current_light_normal = current_light_normal.normalized() - # the position is identical for all vertices of a BBL - current_light_position_bms_coords = to_bms_coords(current_light_position) - vs_input_light.position = Vector3( - current_light_position_bms_coords.x, - current_light_position_bms_coords.y, - current_light_position_bms_coords.z, - ) + for i in [1, 0, 3, 1, 3, 2]: + vs_input_light = VSInputLight() + vertex_indices.append(vertex_index) - # ... same as the normal - vs_input_light.normal = Vector3( - current_light_normal.x, current_light_normal.y, current_light_normal.z - ) + current_light_position_bms_coords = to_bms_coords(current_light_position) + vs_input_light.position = Vector3( + current_light_position_bms_coords.x, + current_light_position_bms_coords.y, + current_light_position_bms_coords.z, + ) + vs_input_light.normal = Vector3( + current_light_normal.x, current_light_normal.y, current_light_normal.z + ) - # ... and its color - color_bytes = ( - (from_blender_color(color[3]) << 24) - + (from_blender_color(color[2]) << 16) - + (from_blender_color(color[1]) << 8) - + (from_blender_color(color[0]) << 0) - ) - vs_input_light.color = color_bytes + color_bytes = ( + (from_blender_color(color[3]) << 24) + + (from_blender_color(color[2]) << 16) + + (from_blender_color(color[1]) << 8) + + (from_blender_color(color[0]) << 0) + ) + vs_input_light.color = color_bytes - # uv1 - just a regular texture uv - if mesh.uv_layers.active: - uv = tuple(to_bms_coords(tuple(mesh.uv_layers.active.data[i].uv))) - vs_input_light.uv1 = Vector2(uv[0], uv[1]) + if mesh.uv_layers.active: + uv = tuple(to_bms_coords(tuple(mesh.uv_layers.active.data[i].uv))) + vs_input_light.uv1 = Vector2(uv[0], uv[1]) - # uv2 - extrude the 4 corners of the vertex from the center point as origin - uv2_signs = uv2_sign_lookup[i] - uv2 = Vector( - (uv2_signs[0] * face_width / 2, uv2_signs[1] * face_height / 2) - ) - vs_input_light.uv2 = Vector2(uv2[0], uv2[1]) + uv2_signs = uv2_sign_lookup[i] + uv2 = Vector( + (uv2_signs[0] * face_width / 2, uv2_signs[1] * face_height / 2) + ) + vs_input_light.uv2 = Vector2(uv2[0], uv2[1]) - bbl_vertices.append(vs_input_light) - vertex_index += 1 + bbl_vertices.append(vs_input_light) + vertex_index += 1 return {"vertices": bbl_vertices, "vertex_indices": vertex_indices} diff --git a/bms_blender_plugin/exporter/bml_output.py b/bms_blender_plugin/exporter/bml_output.py index 473d106..d68f952 100644 --- a/bms_blender_plugin/exporter/bml_output.py +++ b/bms_blender_plugin/exporter/bml_output.py @@ -1,5 +1,6 @@ import datetime import math +from time import perf_counter import bpy @@ -17,6 +18,7 @@ ) 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_profiler import ExportProfiler from mathutils import Vector @@ -31,6 +33,8 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo """ start_time = datetime.datetime.now() + start_perf_counter = perf_counter() + export_profiler = ExportProfiler() print(f"Starting BML export at {start_time}\n") # blender uses meters as base unit, BMS works in feet @@ -56,7 +60,10 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo """ # Gather all bounding boxes into an array of bounding_box objects. - BBox_Array = [BoundingBox(obj) for obj in context.scene.objects if get_bml_type(obj) == BlenderNodeType.BBOX] + with export_profiler.stage("scene: gather bounding boxes"): + BBox_Array = [ + BoundingBox(obj) for obj in context.scene.objects if get_bml_type(obj) == BlenderNodeType.BBOX + ] """ Get the first bounding box min and max coordinates. A bit arbitrary at this point, but in instances of a single bbox, no harm and I suspect that these will be in named order. @@ -81,16 +88,18 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo elif len(lods) == 0: raise Exception("No active collection and no LODs - can not export") - all_exported_bmls, all_material_names, all_hotspots = export_lods( - context, file_directory, file_prefix, lods, scale_factor, export_settings - ) + with export_profiler.stage("scene: export lods"): + all_exported_bmls, all_material_names, all_hotspots = export_lods( + context, file_directory, file_prefix, lods, scale_factor, export_settings, export_profiler + ) if export_settings.export_materials_file or export_settings.export_textures: - export_materials( - all_material_names, - file_directory, - export_settings, - ) + with export_profiler.stage("scene: export materials"): + export_materials( + all_material_names, + file_directory, + export_settings, + ) if export_settings.export_materials_sets and len(context.scene.bml_material_sets) > 1: number_of_texture_sets = len(context.scene.bml_material_sets) @@ -98,27 +107,30 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo number_of_texture_sets = 1 if export_settings.export_parent_dat: - export_parent_dat( - context, - file_directory, - file_prefix, - bounding_box_1_min_coords, - bounding_box_1_max_coords, - scale_factor, - number_of_texture_sets, - get_slots(context.scene), - lods - ) + with export_profiler.stage("scene: export parent.dat"): + export_parent_dat( + context, + file_directory, + file_prefix, + bounding_box_1_min_coords, + bounding_box_1_max_coords, + scale_factor, + number_of_texture_sets, + get_slots(context.scene), + lods + ) if export_settings.export_hotspots: - export_hotspots(all_hotspots, file_directory) + with export_profiler.stage("scene: export hotspots"): + export_hotspots(all_hotspots, file_directory) # If there is more than one bounding box defined, output all to a file. if len(BBox_Array) > 1: - export_bounding_boxes(BBox_Array, file_directory) + with export_profiler.stage("scene: export bounding boxes"): + export_bounding_boxes(BBox_Array, file_directory) - elapsed = datetime.datetime.now() - start_time + elapsed = datetime.timedelta(seconds=perf_counter() - start_perf_counter) elapsed_minutes = divmod(elapsed.total_seconds(), 60) success_message = ( @@ -128,4 +140,6 @@ def export_bml(context, lods, file_directory, file_prefix, export_settings: Expo print(success_message) + if export_profiler.has_records(): + print(export_profiler.format_summary()) return success_message, all_exported_bmls diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index c500a63..7da0dcc 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -1,12 +1,6 @@ -""" -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 +from contextlib import nullcontext import bpy @@ -42,7 +36,7 @@ def export_lods( - context, file_directory, file_prefix, lod_list, scale_factor, export_settings: ExportSettings + context, file_directory, file_prefix, lod_list, scale_factor, export_settings: ExportSettings, export_profiler=None ): """Exports multiple LODs to single *.bml files and their material sets to *.mti files. Returns a list of exported files, a list of all material names and @@ -56,7 +50,7 @@ def export_lods( bml_file_path = os.path.join(file_directory, file_prefix + lod.file_suffix + ".bml") material_names, hotspots = export_single_collection( - context, lod.collection, scale_factor, export_settings, bml_file_path + context, lod.collection, scale_factor, export_settings, bml_file_path, export_profiler ) material_set_filepath = bml_file_path.replace(".bml", ".mti") @@ -79,59 +73,68 @@ def export_lods( def export_single_collection( - context, collection, scale_factor, export_settings: ExportSettings, file_path + context, collection, scale_factor, export_settings: ExportSettings, file_path, export_profiler=None ): """Exports a single Blender collection to a BML file.""" # create a temporary collection and copy the current collection's visible objects into it collection_copy_root = bpy.data.collections.new(collection.name + "_export") bpy.context.scene.collection.children.link(collection_copy_root) - copy_collection_flat( - collection, - collection_copy_root, - [collection_copy_root], - scale_factor, - ) + with export_profiler.stage("lod: copy collection") if export_profiler else nullcontext(): + copy_collection_flat( + collection, + collection_copy_root, + [collection_copy_root], + scale_factor, + export_profiler, + ) - apply_all_modifiers(collection_copy_root) + with export_profiler.stage("lod: apply modifiers") if export_profiler else nullcontext(): + apply_all_modifiers(collection_copy_root, export_profiler) # make sure we are on the base texture set - revert_to_base_material_set(context, collection_copy_root) + with export_profiler.stage("lod: revert material set") if export_profiler else nullcontext(): + revert_to_base_material_set(context, collection_copy_root) # get the data of the root collection - nodes_output = get_nodes( - context, - collection_copy_root, - export_settings.script, - export_settings.auto_smooth_value, - ) + with export_profiler.stage("lod: build payload") if export_profiler else nullcontext(): + nodes_output = get_nodes( + context, + collection_copy_root, + export_settings.script, + export_settings.auto_smooth_value, + export_profiler, + ) payload = nodes_output["data"] material_names = nodes_output["material_names"] hotspots = nodes_output["hotspots"] payload_size = len(payload) - if export_settings.compression == Compression.NONE: - payload_compressed_size = payload_size - elif export_settings.compression == Compression.LZ_4: - payload = compress_lz_4(payload) - payload_compressed_size = len(payload) - elif export_settings.compression == Compression.LZMA: - payload = compress_lzma(payload) - payload_compressed_size = len(payload) - else: - raise Exception("Unknown compression exception") + with export_profiler.stage("lod: final compression") if export_profiler else nullcontext(): + if export_settings.compression == Compression.NONE: + payload_compressed_size = payload_size + elif export_settings.compression == Compression.LZ_4: + payload = compress_lz_4(payload) + payload_compressed_size = len(payload) + elif export_settings.compression == Compression.LZMA: + payload = compress_lzma(payload) + payload_compressed_size = len(payload) + else: + raise Exception("Unknown compression exception") - header = Header( - 2, payload_size, payload_compressed_size, export_settings.compression - ) - data = header.to_data() + payload + with export_profiler.stage("lod: assemble file") if export_profiler else nullcontext(): + header = Header( + 2, payload_size, payload_compressed_size, export_settings.compression + ) + data = header.to_data() + payload if export_settings.export_models: - with open(file_path, "wb") as bml_file: - bml_file.write(data) - print( - f"Finished exporting LOD with {nodes_output['nodes_amount']} nodes to {file_path}...\n" - ) + with export_profiler.stage("lod: write file") if export_profiler else nullcontext(): + with open(file_path, "wb") as bml_file: + bml_file.write(data) + print( + f"Finished exporting LOD with {nodes_output['nodes_amount']} nodes to {file_path}...\n" + ) # delete the copied collection and its children if ( @@ -140,23 +143,25 @@ def export_single_collection( "bms_blender_plugin" ].preferences.do_not_delete_export_collection ): - for obj in collection_copy_root.objects: - bpy.data.objects.remove(obj, do_unlink=True) - bpy.data.collections.remove(collection_copy_root) + with export_profiler.stage("lod: cleanup temp collection") if export_profiler else nullcontext(): + for obj in collection_copy_root.objects: + bpy.data.objects.remove(obj, do_unlink=True) + bpy.data.collections.remove(collection_copy_root) return material_names, hotspots -def get_nodes(context, root_collection, script, auto_smooth_value): +def get_nodes(context, root_collection, script, auto_smooth_value, export_profiler=None): """Recursively builds the BML node list for a given collection with all of its elements (refer to the BMLv2 format definition). Returns a triple of the nodes in binary format, the material list and the amount of nodes """ material_names = [] + material_lookup = {} nodes = [] current_vertices_index = 0 current_vertices_size = 0 - vertices_data = [] # raw byte data + vertices_data = [] vertex_indices = [] hotspots = dict() @@ -177,9 +182,10 @@ def _recursively_parse_nodes(objects): ): prepared_objects = objects else: - prepared_objects = join_objects_with_same_materials( - objects, dict(), auto_smooth_value - ) + with export_profiler.stage("nodes: join by material") if export_profiler else nullcontext(): + prepared_objects = join_objects_with_same_materials( + objects, dict(), auto_smooth_value + ) # parse all objects of the current collection for obj in prepared_objects: @@ -190,8 +196,10 @@ def _recursively_parse_nodes(objects): nodes, vertex_indices, material_names, + material_lookup, current_vertices_index, current_vertices_size, + export_profiler, ) elif get_bml_type(obj) == BlenderNodeType.PBR_LIGHT: @@ -200,8 +208,10 @@ def _recursively_parse_nodes(objects): nodes, vertex_indices, material_names, + material_lookup, current_vertices_index, current_vertices_size, + export_profiler, ) elif get_bml_type(obj) == BlenderNodeType.SLOT: # Slots can be empty @@ -257,44 +267,43 @@ def _recursively_parse_nodes(objects): script_no = int(script) material_count = len(material_names) - data = struct.pack(" 0 + + def format_summary(self): + total_duration = sum(self._durations.values()) + lines = ["Export timing summary:"] + for stage_name, duration_seconds in self._durations.items(): + percent = (duration_seconds / total_duration * 100) if total_duration else 0.0 + lines.append(f" {stage_name}: {duration_seconds:.3f}s ({percent:.1f}%)") + lines.append(f" total tracked: {total_duration:.3f}s") + return "\n".join(lines) diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index d832227..519343d 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -6,7 +6,7 @@ from bms_blender_plugin.common.bml_structs import Primitive, PrimitiveTopology, Vector3, Slot, D3DMatrix, Switch, \ DofType, Dof 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, \ +from bms_blender_plugin.common.util import get_bml_type, get_switches, get_dofs, \ get_non_translate_dof_parent 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 @@ -25,14 +25,23 @@ def __init__(self, vertex_data, vertices_length, vertices_size): self.vertices_size = vertices_size +def _get_material_index(material_name, material_names, material_lookup): + material_index = material_lookup.get(material_name) + if material_index is None: + material_index = len(material_names) + material_lookup[material_name] = material_index + material_names.append(material_name) + return material_index + + def parse_mesh( - obj, nodes, vertex_indices, material_names, vertex_index_offset, vertex_start_offset + obj, nodes, vertex_indices, material_names, material_lookup, vertex_index_offset, vertex_start_offset, export_profiler=None ): """Adds a mesh to the BML node list""" print(f"parsing mesh {obj.name}") # Prepare the mesh - obj_data = get_bml_mesh_data(obj, vertex_index_offset) + obj_data = get_bml_mesh_data(obj, vertex_index_offset, export_profiler) obj_vertices = obj_data["vertices"] obj_indices = obj_data["vertex_indices"] @@ -42,17 +51,21 @@ def parse_mesh( else: material_name = "BML-Default" - try: - material_index = material_names.index(material_name) - except ValueError: - material_index = len(material_names) - material_names.append(material_name) + material_index = _get_material_index(material_name, material_names, material_lookup) vertex_size = 48 # since we only support v2 Primitives - obj_vertices_data = [] - for obj_vertex in obj_vertices: - obj_vertices_data += obj_vertex.to_data() + if export_profiler: + with export_profiler.stage("mesh: pack vertex/index data"): + obj_vertices_data = b"".join( + chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() + ) + vertex_indices.extend(obj_indices) + else: + obj_vertices_data = b"".join( + chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() + ) + vertex_indices.extend(obj_indices) # 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 @@ -83,10 +96,9 @@ def parse_mesh( ) nodes.append(node) - vertex_indices += obj_indices return ParsedNodes( - vertex_data=obj_vertices_data, + vertex_data=[obj_vertices_data], vertices_length=len(obj_vertices), vertices_size=len(obj_vertices) * vertex_size, ) @@ -97,14 +109,16 @@ def parse_bbl_light( nodes, vertex_indices, material_names, + material_lookup, vertex_index_offset, vertex_start_offset, + export_profiler=None, ): """Adds a PBR billboard light to the BML node list""" print(f"parsing PBR BB light {obj.name}") # Prepare the mesh - obj_data = get_pbr_light_data(obj, vertex_index_offset) + obj_data = get_pbr_light_data(obj, vertex_index_offset, export_profiler) obj_vertices = obj_data["vertices"] obj_indices = obj_data["vertex_indices"] @@ -114,17 +128,21 @@ def parse_bbl_light( else: material_name = "BML-BillboardGlowLight" - try: - material_index = material_names.index(material_name) - except ValueError: - material_index = len(material_names) - material_names.append(material_name) + material_index = _get_material_index(material_name, material_names, material_lookup) vertex_size = 44 # size for PBR BB light - obj_vertices_data = [] - for obj_vertex in obj_vertices: - obj_vertices_data += obj_vertex.to_data() + if export_profiler: + with export_profiler.stage("mesh: pack vertex/index data"): + obj_vertices_data = b"".join( + chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() + ) + vertex_indices.extend(obj_indices) + else: + obj_vertices_data = b"".join( + chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() + ) + vertex_indices.extend(obj_indices) # Use stored reference point if available, otherwise fall back to world translation # All objects now use their origins for reference points, including DOF children @@ -154,10 +172,9 @@ def parse_bbl_light( ) nodes.append(node) - vertex_indices += obj_indices return ParsedNodes( - vertex_data=obj_vertices_data, + vertex_data=[obj_vertices_data], vertices_length=len(obj_vertices), vertices_size=len(obj_vertices) * vertex_size, ) From 592d4ab4282223bf3b5208d4a9b093a60f0ea664 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:47:08 +0000 Subject: [PATCH 11/25] Address export review feedback Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/7b05b8fd-880b-48c3-80dc-4c5f35b3d6b5 Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/exporter/bml_mesh.py | 127 +-------------------- bms_blender_plugin/exporter/export_lods.py | 4 +- bms_blender_plugin/exporter/parser.py | 4 +- 3 files changed, 9 insertions(+), 126 deletions(-) diff --git a/bms_blender_plugin/exporter/bml_mesh.py b/bms_blender_plugin/exporter/bml_mesh.py index 0d7f1d9..8ecb10a 100644 --- a/bms_blender_plugin/exporter/bml_mesh.py +++ b/bms_blender_plugin/exporter/bml_mesh.py @@ -1,6 +1,7 @@ import bpy import bmesh import math +from contextlib import nullcontext from mathutils import Vector from bms_blender_plugin.common.bml_structs import ( @@ -21,15 +22,7 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): """Returns the raw mesh data in the BML format as a tuple of vertices and vertex indices""" mesh = obj.data - if export_profiler: - with export_profiler.stage("mesh extraction: triangulate"): - bm = bmesh.new() - bm.from_mesh(mesh) - - bmesh.ops.triangulate(bm, faces=bm.faces[:]) - bm.to_mesh(mesh) - bm.free() - else: + with export_profiler.stage("mesh extraction: triangulate") if export_profiler else nullcontext(): bm = bmesh.new() bm.from_mesh(mesh) @@ -38,12 +31,7 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): bm.free() if len(mesh.loops) > 0: - if export_profiler: - with export_profiler.stage("mesh extraction: normals/tangents"): - mesh.calc_normals() - if mesh.uv_layers.active: - mesh.calc_tangents(uvmap=mesh.uv_layers.active.name) - else: + with export_profiler.stage("mesh extraction: normals/tangents") if export_profiler else nullcontext(): mesh.calc_normals() if mesh.uv_layers.active: mesh.calc_tangents(uvmap=mesh.uv_layers.active.name) @@ -82,46 +70,7 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): active_uv_layer = mesh.uv_layers.active.data if mesh.uv_layers.active else None - if export_profiler: - with export_profiler.stage("mesh extraction: build vertices"): - for face in mesh.polygons: - for vert in [mesh.loops[i] for i in face.loop_indices]: - vertex_pbr = VertexPBR() - vertex_index = vert.vertex_index - vertex_indices.append(vert.index + max_vertex_index) - - object_global_coord = to_bms_coords( - world_coord @ mesh.vertices[vertex_index].co - ) - vertex_pbr.position = Vector3( - object_global_coord.x, object_global_coord.y, object_global_coord.z - ) - - object_global_normal = to_bms_coords(world_normal @ vert.normal) - object_global_normal = object_global_normal.normalized() - vertex_pbr.normal = Vector3( - object_global_normal.x, object_global_normal.y, object_global_normal.z - ) - - if active_uv_layer: - object_global_tangent = to_bms_coords(vert.tangent) - vertex_pbr.tangent = Vector3( - object_global_tangent.x, - object_global_tangent.y, - object_global_tangent.z, - ) - vertex_pbr.handedness = vert.bitangent_sign - - uv = tuple(to_bms_coords(tuple(active_uv_layer[vert.index].uv))) - vertex_pbr.uv = Vector2(uv[0], uv[1]) - - pb_vertices_per_face.append(vertex_pbr) - - pb_vertices.append(pb_vertices_per_face[0]) - pb_vertices.append(pb_vertices_per_face[2]) - pb_vertices.append(pb_vertices_per_face[1]) - pb_vertices_per_face = [] - else: + with export_profiler.stage("mesh extraction: build vertices") if export_profiler else nullcontext(): for face in mesh.polygons: for vert in [mesh.loops[i] for i in face.loop_indices]: vertex_pbr = VertexPBR() @@ -180,73 +129,7 @@ def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): vertex_index = max_vertex_index - if export_profiler: - with export_profiler.stage("mesh extraction: build light vertices"): - for face in mesh.polygons: - if len(face.vertices) != 4: - raise Exception("BBLights can only consist of rectangular planes") - - face_width = ( - mesh.vertices[face.vertices[0]].co - mesh.vertices[face.vertices[1]].co - ).length - face_height = ( - mesh.vertices[face.vertices[1]].co - mesh.vertices[face.vertices[2]].co - ).length - - color = ( - mesh.polygon_layers_float["bml_color_r"].data[face.index].value, - mesh.polygon_layers_float["bml_color_g"].data[face.index].value, - mesh.polygon_layers_float["bml_color_b"].data[face.index].value, - mesh.polygon_layers_float["bml_color_a"].data[face.index].value, - ) - - normal = Vector( - ( - mesh.polygon_layers_float["bml_normal_x"].data[face.index].value, - mesh.polygon_layers_float["bml_normal_y"].data[face.index].value, - mesh.polygon_layers_float["bml_normal_z"].data[face.index].value, - ) - ) - - current_light_position = world_coord @ face.center - current_light_normal = to_bms_coords(world_normal @ normal) - current_light_normal = current_light_normal.normalized() - - for i in [1, 0, 3, 1, 3, 2]: - vs_input_light = VSInputLight() - vertex_indices.append(vertex_index) - - current_light_position_bms_coords = to_bms_coords(current_light_position) - vs_input_light.position = Vector3( - current_light_position_bms_coords.x, - current_light_position_bms_coords.y, - current_light_position_bms_coords.z, - ) - vs_input_light.normal = Vector3( - current_light_normal.x, current_light_normal.y, current_light_normal.z - ) - - color_bytes = ( - (from_blender_color(color[3]) << 24) - + (from_blender_color(color[2]) << 16) - + (from_blender_color(color[1]) << 8) - + (from_blender_color(color[0]) << 0) - ) - vs_input_light.color = color_bytes - - if mesh.uv_layers.active: - uv = tuple(to_bms_coords(tuple(mesh.uv_layers.active.data[i].uv))) - vs_input_light.uv1 = Vector2(uv[0], uv[1]) - - uv2_signs = uv2_sign_lookup[i] - uv2 = Vector( - (uv2_signs[0] * face_width / 2, uv2_signs[1] * face_height / 2) - ) - vs_input_light.uv2 = Vector2(uv2[0], uv2[1]) - - bbl_vertices.append(vs_input_light) - vertex_index += 1 - else: + with export_profiler.stage("mesh extraction: build light vertices") if export_profiler else nullcontext(): for face in mesh.polygons: if len(face.vertices) != 4: raise Exception("BBLights can only consist of rectangular planes") diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 7da0dcc..46dfc3c 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -228,7 +228,7 @@ def _recursively_parse_nodes(objects): # end of parsing, append parsed data to the nodes list if parsed_nodes: - vertices_data.extend(parsed_nodes.vertex_data) + vertices_data.append(parsed_nodes.vertex_data) current_vertices_index += parsed_nodes.vertices_length current_vertices_size += parsed_nodes.vertices_size @@ -268,7 +268,7 @@ def _recursively_parse_nodes(objects): material_count = len(material_names) with export_profiler.stage("nodes: pack index buffer") if export_profiler else nullcontext(): - if len(vertex_indices) < 256: + if len(vertex_indices) < 65536: index_buffer_format = IndexBufferFormat.FORMAT_16 vertex_indices_data = struct.pack("%sH" % len(vertex_indices), *vertex_indices) vertex_indices_data_size = 2 * len(vertex_indices) diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index 519343d..4ea7517 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -98,7 +98,7 @@ def parse_mesh( nodes.append(node) return ParsedNodes( - vertex_data=[obj_vertices_data], + vertex_data=obj_vertices_data, vertices_length=len(obj_vertices), vertices_size=len(obj_vertices) * vertex_size, ) @@ -174,7 +174,7 @@ def parse_bbl_light( nodes.append(node) return ParsedNodes( - vertex_data=[obj_vertices_data], + vertex_data=obj_vertices_data, vertices_length=len(obj_vertices), vertices_size=len(obj_vertices) * vertex_size, ) From de3840630cc2f142e821d9db62bd9e38f04eae40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:47:53 +0000 Subject: [PATCH 12/25] Deduplicate profiled export utility paths Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/7b05b8fd-880b-48c3-80dc-4c5f35b3d6b5 Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 68 ++----------------------------- 1 file changed, 3 insertions(+), 65 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index 1f10f7c..da8d889 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -4,6 +4,7 @@ import os import struct +from contextlib import nullcontext import lzma @@ -299,46 +300,7 @@ def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): """Recursively copies an object and all of its children and moves their copies to a given collection. Also applies a scale factor""" if not obj.hide_render and len(obj.users_collection) != 0: - if export_profiler: - with export_profiler.stage("collection copy: duplicate objects"): - copied_object = obj.copy() - copied_object.parent = parent - copied_object.matrix_parent_inverse = obj.matrix_parent_inverse.copy() - - if obj.data: - copied_object.data = copied_object.data.copy() - for k, e in obj.items(): - copied_object[k] = e - - for obj_modifier in obj.modifiers: - copied_object_modifiers = obj.modifiers.get(obj_modifier.name, None) - if not copied_object_modifiers: - copied_object_modifiers = obj.modifiers.new( - obj_modifier.name, obj_modifier.type - ) - - properties = [ - p.identifier - for p in obj_modifier.bl_rna.properties - if not p.is_readonly - ] - - for prop in properties: - setattr(copied_object_modifiers, prop, getattr(obj_modifier, prop)) - - if get_bml_type(obj, False) == BlenderNodeType.DOF: - reset_dof(copied_object) - - if scale_factor != 1 and obj.parent is None: - copied_object.scale *= scale_factor - copied_object.location *= scale_factor - - collection.objects.link(copied_object) - - copied_object.hide_select = False - copied_object.hide_viewport = False - copied_object.hide_set(False) - else: + with export_profiler.stage("collection copy: duplicate objects") if export_profiler else nullcontext(): copied_object = obj.copy() copied_object.parent = parent copied_object.matrix_parent_inverse = obj.matrix_parent_inverse.copy() @@ -394,31 +356,7 @@ def apply_all_modifiers_on_obj(obj, export_profiler=None): Empties (DOFs, Slots and Switches) are excepted, since applying their modifiers would reset their positions. """ if obj: - if export_profiler: - with export_profiler.stage("modifier application: apply modifiers"): - bpy.ops.object.select_all(action="DESELECT") - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - - if obj.type == "MESH": - bpy.ops.object.mode_set(mode="OBJECT") - bpy.ops.object.convert(target="MESH", keep_original=False) - - if (obj.type == "MESH" and - get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): - obj["bms_reference_point"] = tuple(obj.location) - - if get_bml_type(obj) not in [ - BlenderNodeType.DOF, - BlenderNodeType.SLOT, - BlenderNodeType.HOTSPOT, - ]: - bpy.ops.object.transform_apply() - else: - bpy.ops.object.transform_apply( - location=False, rotation=False, scale=True, properties=False - ) - else: + with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) bpy.context.view_layer.objects.active = obj From 96a34e089ff0fc85d97cb1fbb049f8691552845b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 18:49:21 +0000 Subject: [PATCH 13/25] Finalize export packing fixes Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/7b05b8fd-880b-48c3-80dc-4c5f35b3d6b5 Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/exporter/export_lods.py | 3 ++- bms_blender_plugin/exporter/parser.py | 17 +++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 46dfc3c..f5ddef9 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -268,7 +268,8 @@ def _recursively_parse_nodes(objects): material_count = len(material_names) with export_profiler.stage("nodes: pack index buffer") if export_profiler else nullcontext(): - if len(vertex_indices) < 65536: + # FORMAT_16 uses unsigned 16-bit indices, so it is valid while the largest vertex index fits in 0..65535. + if current_vertices_index <= 65536: index_buffer_format = IndexBufferFormat.FORMAT_16 vertex_indices_data = struct.pack("%sH" % len(vertex_indices), *vertex_indices) vertex_indices_data_size = 2 * len(vertex_indices) diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index 4ea7517..b7fa33a 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -1,3 +1,4 @@ +from contextlib import nullcontext import math from mathutils import Matrix, Vector @@ -55,13 +56,7 @@ def parse_mesh( vertex_size = 48 # since we only support v2 Primitives - if export_profiler: - with export_profiler.stage("mesh: pack vertex/index data"): - obj_vertices_data = b"".join( - chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() - ) - vertex_indices.extend(obj_indices) - else: + with export_profiler.stage("mesh: pack vertex/index data") if export_profiler else nullcontext(): obj_vertices_data = b"".join( chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() ) @@ -132,13 +127,7 @@ def parse_bbl_light( vertex_size = 44 # size for PBR BB light - if export_profiler: - with export_profiler.stage("mesh: pack vertex/index data"): - obj_vertices_data = b"".join( - chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() - ) - vertex_indices.extend(obj_indices) - else: + with export_profiler.stage("mesh: pack vertex/index data") if export_profiler else nullcontext(): obj_vertices_data = b"".join( chunk for obj_vertex in obj_vertices for chunk in obj_vertex.to_data() ) From 292f5ae0688dc31d09f5af86c08f6a04b5490195 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 19:20:20 +0000 Subject: [PATCH 14/25] Restore comments removed during export optimization Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/d85804fd-fd39-47c0-a3a4-34fa92602caf Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 12 +++++++++++ bms_blender_plugin/exporter/bml_mesh.py | 23 ++++++++++++++++++++++ bms_blender_plugin/exporter/export_lods.py | 15 +++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index da8d889..ac20a44 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -310,6 +310,7 @@ def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): for k, e in obj.items(): copied_object[k] = e + # copy and apply all modifiers for obj_modifier in obj.modifiers: copied_object_modifiers = obj.modifiers.get(obj_modifier.name, None) if not copied_object_modifiers: @@ -317,24 +318,29 @@ def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): obj_modifier.name, obj_modifier.type ) + # collect names of writable properties properties = [ p.identifier for p in obj_modifier.bl_rna.properties if not p.is_readonly ] + # copy those properties for prop in properties: setattr(copied_object_modifiers, prop, getattr(obj_modifier, prop)) + # set all DOFs to 0 if get_bml_type(obj, False) == BlenderNodeType.DOF: reset_dof(copied_object) + # scale only the root objects if scale_factor != 1 and obj.parent is None: copied_object.scale *= scale_factor copied_object.location *= scale_factor collection.objects.link(copied_object) + # override any selection restriction copied_object.hide_select = False copied_object.hide_viewport = False copied_object.hide_set(False) @@ -358,6 +364,7 @@ def apply_all_modifiers_on_obj(obj, export_profiler=None): if obj: with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): bpy.ops.object.select_all(action="DESELECT") + # apply the modifiers obj.select_set(True) bpy.context.view_layer.objects.active = obj @@ -365,10 +372,13 @@ def apply_all_modifiers_on_obj(obj, export_profiler=None): 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, @@ -376,6 +386,8 @@ def apply_all_modifiers_on_obj(obj, export_profiler=None): ]: bpy.ops.object.transform_apply() else: + # only apply scaling operations to those objects + # all other operations would reset them since they are empties bpy.ops.object.transform_apply( location=False, rotation=False, scale=True, properties=False ) diff --git a/bms_blender_plugin/exporter/bml_mesh.py b/bms_blender_plugin/exporter/bml_mesh.py index 8ecb10a..08bad88 100644 --- a/bms_blender_plugin/exporter/bml_mesh.py +++ b/bms_blender_plugin/exporter/bml_mesh.py @@ -72,11 +72,13 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): with export_profiler.stage("mesh extraction: build vertices") if export_profiler else nullcontext(): for face in mesh.polygons: + # loop over face loop for vert in [mesh.loops[i] for i in face.loop_indices]: vertex_pbr = VertexPBR() vertex_index = vert.vertex_index vertex_indices.append(vert.index + max_vertex_index) + # position object_global_coord = to_bms_coords( world_coord @ mesh.vertices[vertex_index].co ) @@ -84,12 +86,15 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): object_global_coord.x, object_global_coord.y, object_global_coord.z ) + # normal object_global_normal = to_bms_coords(world_normal @ vert.normal) + # normalize the vector to remove any rounding errors object_global_normal = object_global_normal.normalized() vertex_pbr.normal = Vector3( object_global_normal.x, object_global_normal.y, object_global_normal.z ) + # tangent & uv if active_uv_layer: object_global_tangent = to_bms_coords(vert.tangent) vertex_pbr.tangent = Vector3( @@ -104,6 +109,7 @@ def get_bml_mesh_data(obj, max_vertex_index, export_profiler=None): pb_vertices_per_face.append(vertex_pbr) + # switch the handedness by swapping the vertices pb_vertices.append(pb_vertices_per_face[0]) pb_vertices.append(pb_vertices_per_face[2]) pb_vertices.append(pb_vertices_per_face[1]) @@ -131,9 +137,12 @@ def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): with export_profiler.stage("mesh extraction: build light vertices") if export_profiler else nullcontext(): for face in mesh.polygons: + # loop over face loop + if len(face.vertices) != 4: raise Exception("BBLights can only consist of rectangular planes") + # calculate width and height face_width = ( mesh.vertices[face.vertices[0]].co - mesh.vertices[face.vertices[1]].co ).length @@ -141,6 +150,7 @@ def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): mesh.vertices[face.vertices[1]].co - mesh.vertices[face.vertices[2]].co ).length + # load the stored colors and normals from the polygon layers color = ( mesh.polygon_layers_float["bml_color_r"].data[face.index].value, mesh.polygon_layers_float["bml_color_g"].data[face.index].value, @@ -157,23 +167,34 @@ def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): ) current_light_position = world_coord @ face.center + + # the normal will already be set to [0, 0, 0] for omnidirectional lights by join_objects_with_same_materials() current_light_normal = to_bms_coords(world_normal @ normal) + # normalize the vector to remove any rounding errors current_light_normal = current_light_normal.normalized() + # for each poly, add two triangles (== 6 vertices) + # blender iterates counter-clockwise, so the following vertex indices will form 2 adjacent triangles of a + # rectangular poly + for i in [1, 0, 3, 1, 3, 2]: vs_input_light = VSInputLight() vertex_indices.append(vertex_index) + # the position is identical for all vertices of a BBL current_light_position_bms_coords = to_bms_coords(current_light_position) vs_input_light.position = Vector3( current_light_position_bms_coords.x, current_light_position_bms_coords.y, current_light_position_bms_coords.z, ) + + # ... same as the normal vs_input_light.normal = Vector3( current_light_normal.x, current_light_normal.y, current_light_normal.z ) + # ... and its color color_bytes = ( (from_blender_color(color[3]) << 24) + (from_blender_color(color[2]) << 16) @@ -182,10 +203,12 @@ def get_pbr_light_data(obj, max_vertex_index, export_profiler=None): ) vs_input_light.color = color_bytes + # uv1 - just a regular texture uv if mesh.uv_layers.active: uv = tuple(to_bms_coords(tuple(mesh.uv_layers.active.data[i].uv))) vs_input_light.uv1 = Vector2(uv[0], uv[1]) + # uv2 - extrude the 4 corners of the vertex from the center point as origin uv2_signs = uv2_sign_lookup[i] uv2 = Vector( (uv2_signs[0] * face_width / 2, uv2_signs[1] * face_height / 2) diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index f5ddef9..5b851b8 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 from contextlib import nullcontext @@ -161,7 +168,7 @@ def get_nodes(context, root_collection, script, auto_smooth_value, export_profil nodes = [] current_vertices_index = 0 current_vertices_size = 0 - vertices_data = [] + vertices_data = [] # raw byte data vertex_indices = [] hotspots = dict() @@ -290,6 +297,7 @@ def _recursively_parse_nodes(objects): data_parts.append(struct.pack(" Date: Sun, 10 May 2026 20:08:51 -0400 Subject: [PATCH 15/25] panel corrections --- bms_blender_plugin/common/DOF.xml | 306 +- bms_blender_plugin/common/switch.xml | 5515 +++++++++++------ .../ui_tools/operators/__init__.py | 2 +- .../ui_tools/panels/dof_panel.py | 2 +- .../ui_tools/panels/switch_panel.py | 4 +- 5 files changed, 3970 insertions(+), 1859 deletions(-) diff --git a/bms_blender_plugin/common/DOF.xml b/bms_blender_plugin/common/DOF.xml index d8351ed..aea172c 100644 --- a/bms_blender_plugin/common/DOF.xml +++ b/bms_blender_plugin/common/DOF.xml @@ -34,6 +34,7 @@ 8 + ChocksFR/SimpRudder1 9 @@ -57,7 +58,7 @@ 14 - + AWACSdome/SimpSwingWing4 15 @@ -109,7 +110,7 @@ 27 - + Canards 28 @@ -200,7 +201,7 @@ 49 - + RudderToeIn 50 @@ -402,6 +403,7 @@ 99 + PilotVisor 100 @@ -481,15 +483,15 @@ 119 - HSI Distance To Beacon Digit 1 + HSI DME Digit 1 120 - HSI Distance To Beacon Digit 2 + HSI DME Digit 2 121 - HSI Distance To Beacon Digit 3 + HSI DME Digit 3 122 @@ -553,23 +555,23 @@ 137 - Fuel Digit 1 + Fuel Qty Total 1 138 - Fuel Digit 2 + Fuel Qty Total 2 139 - Fuel Digit 3 + Fuel Qty Total 3 140 - Fuel Digit 4 + Fuel Qty Left 1 141 - Fuel Digit 5 + Fuel Qty Left 2 142 @@ -577,7 +579,7 @@ 143 - Fuel Forward + Fuel Forward/F15 FF right 144 @@ -735,4 +737,284 @@ 182 Oil Needle 2 + + 183 + HSI DME Digit 4 (only for EHSI) + + + 184 + F15C Fuel Total Needle + + + 185 + Fuel Qty Right 1 + + + 186 + Fuel Qty Right 2 + + + 187 + F15C Fuel Bingo Bug + + + 188 + ALT tens digits (F15C) + + + 189 + Temp digit 1 left + + + 190 + Temp digit 2 left + + + 191 + Temp digit 3 left + + + 192 + Temp digit 1 right + + + 193 + Temp digit 2 right + + + 194 + Temp digit 3 right + + + 195 + RPM left digit 1 + + + 196 + RPM left digit 2 + + + 197 + RPM left digit 3 + + + 198 + RPM right digit 1 + + + 199 + RPM right digit 2 + + + 200 + JTDS left 0-1 + + + 201 + JTDS center 0-9 + + + 202 + JTDS right 0-9 + + + 203 + RPM right digit 3 + + + 204 + IFF MCODE left + + + 205 + IFF MCODE right + + + 206 + IFF MAN FREQ 1 + + + 207 + IFF MAN FREQ 2 + + + 208 + IFF MAN FREQ 3 + + + 209 + IFF MAN FREQ 4 + + + 210 + AAI MODE DIGIT 1 + + + 211 + AAI CODE DIGIT 1 + + + 212 + AAI CODE DIGIT 2 + + + 213 + AAI CODE DIGIT 3 + + + 214 + AAI CODE DIGIT 4 + + + 215 + ILS Freq Digit 5 + + + 216 + ILS Freq Digit 4 + + + 217 + ILS Freq Digit 3 + + + 218 + ILS Freq Digit 2 + + + 219 + F15 Backup ASI Needle + + + 220 + unused + + + 221 + unused + + + 222 + F15 Light Knob L CONSOLE + + + 223 + F15 Light Knob R CONSOLE + + + 224 + F15 Light Knob AUX INST + + + 225 + F15 Light Knob FLT INST + + + 226 + F15 Light Knob ENG INST + + + 227 + F15 Light Knob STORM FLOOD + + + 228 + F15 TEWS INT knob + + + 229 + F15 G-Meter MIN G + + + 230 + F15 G-Meter MAX G + + + 231 + unused + + + 232 + unused + + + 233 + unused + + + 234 + unused + + + 235 + unused + + + 236 + unused + + + 237 + unused + + + 238 + Turn Rate DegS + + + 239 + unused + + + 240 + unused + + + 241 + unused + + + 242 + unused + + + 243 + unused + + + 244 + unused + + + 245 + unused + + + 246 + unused + + + 247 + unused + + + 248 + unused + + + 249 + unused + + + 250 + Radio2channelLeft + + + 251 + Radio2channelRight + + + 255 + Sideslip Angle Deg + \ No newline at end of file diff --git a/bms_blender_plugin/common/switch.xml b/bms_blender_plugin/common/switch.xml index 3984718..3157cba 100644 --- a/bms_blender_plugin/common/switch.xml +++ b/bms_blender_plugin/common/switch.xml @@ -1,4 +1,4 @@ - + 0 @@ -39,44 +39,12 @@ 7 0 - Tail Strobe 0 - Off - - - 7 - 1 - Tail Strobe 1 - Normal - - - 7 - 2 - Tail Strobe 2 - Covert + Tail Strobe 8 0 - Position Lights Wing 0 - Off - - - 8 - 1 - Position Lights Wing 1 - Normal - - - 8 - 2 - Position Lights Wing 2 - Dim - - - 8 - 3 - Position Lights Wing 3 - Covert + Nav Lights 9 @@ -276,26 +244,7 @@ 47 0 - Position Lights Intake Tail 0 - Off - - - 47 - 1 - Position Lights Intake Tail 1 - Normal - - - 47 - 2 - Position Lights Intake Tail 2 - Dim - - - 47 - 3 - Position Lights Intake Tail 3 - Covert + Nav Lights Flash 48 @@ -385,8 +334,7 @@ 62 0 - Formation lights coverable - Number of positions define in aircraft dat file + Formation lights 63 @@ -403,34 +351,14 @@ 66 0 - Tail Flood Light 0 - - - 66 - 1 - Tail Flood Light 1 - - - 66 - 2 - Tail Flood Light 2 - - - 66 - 3 - Tail Flood Light 3 67 0 - AAR Flood Light - Number of positions define in aircraft dat file 68 0 - Formation lights not coverable - Number of positions define in aircraft dat file 69 @@ -605,12 +533,27 @@ 109 0 - Caution Light ENG FIRE + Caution LEFT ENG FIRE + + + 109 + 1 + Caution RIGHT ENG FIRE + + + 109 + 2 + Caution AMAD FIRE 110 0 - Caution Light Engine + Caution L BURN THRU + + + 110 + 1 + Caution R BURN THRU 111 @@ -660,7 +603,7 @@ 120 0 - HSI Off Flag + Canopy Unlocked Light 121 @@ -705,3421 +648,5307 @@ 129 0 - Emergency Jettison Button + EmergJettNotPush - 130 - 0 - Alt Gear Handle + 129 + 1 + EmergJettPushed - 131 - 0 - Nose Gear Light + 129 + 2 + Jettison OFF - 132 - 0 - Main Left Gear Light + 129 + 3 + Jettison COMBAT - 133 - 0 - Main Right Gear Light + 129 + 4 + Jettison A/A - 134 - 0 - ICP Data Command Switch + 129 + 5 + Jett not poshed - 135 - 0 - ICP Drift Cutoff Switch + 129 + 6 + Jett Pushed - 136 - 0 - ICP Nextprev Switch + 129 + 7 + Null - 137 - 0 - JFS Switch + 129 + 8 + Null - 138 - 0 - JFS Light + 129 + 9 + Null - 139 - 0 - MPO Switch + 129 + 10 + Null - 140 - 0 - EPU Light + 129 + 11 + Null - 141 - 0 - EPU Air Light + 129 + 12 + Null - 142 - 0 - EPU Hydrazine Light + 129 + 13 + Null - 143 - 0 - Elec FLCS pmg Light + 129 + 14 + Null - 144 - 0 - Elec Main gen Light + 129 + 15 + Null - 145 - 0 - Elec Stby gen Light + 129 + 16 + Null - 146 - 0 - Elec EPU gen Light + 129 + 17 + Null - 147 - 0 - Elec EPU pmg Light + 129 + 18 + Null - 148 - 0 - Elec FLCS rly Light + 129 + 19 + Null - 149 - 0 - Elec Toflcs Light + 129 + 20 + Null - 150 - 0 - Elec Bat fail Light + 129 + 21 + Null - 151 - 0 - Elec Main Power Switch + 129 + 22 + Null - 152 - 0 - EPU Switch + 129 + 23 + Null - 153 - 0 - Caution Panel FLCS Fault Light + 129 + 24 + Null - 154 - 0 - Caution Panel Elec Sys Light + 129 + 25 + Null - 155 - 0 - Caution Panel Probe Heat Light + 129 + 26 + Null - 156 - 0 - Caution Panel C ADC Light + 129 + 27 + Null - 157 - 0 - Caution Panel Stores Config Light + 129 + 28 + Null - 158 - 0 - Caution Panel Fwd Fuel Low Light + 129 + 29 + DIS FREQ down - 159 - 0 - Caution Panel Aft Fuel Low Light + 129 + 30 + DIS FREQ middle - 160 - 0 - Caution Panel Engine Fault Light + 129 + 31 + DIS FREQ up - 161 + 130 0 - Caution Panel Sec Light + Alt Gear Handle - 162 + 131 0 - Caution Panel Fuel Oil Hot Light + Nose Gear Light - 163 - 0 - Caution Panel Overheat Light + 131 + 1 + flaps lghtTrans yellow - 164 - 0 - Caution Panel Buc Light + 131 + 2 + flaps LghtFull green - 165 - 0 - Caution Panel Avionics Fault Light + 131 + 3 + Caution SP BK OUT - 166 - 0 - Caution Panel Radar Alt Light + 131 + 4 + Caution AUTO PLT - 167 - 0 - Caution Panel IFF Light + 131 + 5 + Caution PITCH RATIO - 168 - 0 - Caution Panel Seat Not Armed Light + 131 + 6 + Caution ROLL RATIO - 169 - 0 - Caution Panel NWS Fail Light + 131 + 7 + Caution CAS YAW - 170 - 0 - Caution Panel Anti Skid Light + 131 + 8 + Caution CAS ROLL - 171 - 0 - Caution Panel Hook Light - + 131 + 9 + Caution CAS PITCH + - 172 + 131 + 10 + Null + + + 131 + 11 + Null + + + 131 + 12 + Null + + + 131 + 13 + Null + + + 131 + 14 + Null + + + 131 + 15 + Null + + + 131 + 16 + Null + + + 131 + 17 + Null + + + 131 + 18 + Null + + + 131 + 19 + Null + + + 131 + 20 + Null + + + 131 + 21 + Null + + + 131 + 22 + Null + + + 131 + 23 + Null + + + 131 + 24 + Null + + + 131 + 25 + Null + + + 131 + 26 + Null + + + 131 + 27 + Null + + + 131 + 28 + Null + + + 131 + 29 + Null + + + 131 + 30 + F15 Flap Retracted + + + 131 + 31 + F15 Flap Down + + + 132 0 - Caution Panel Oxy_Low Light + Left Gear Light - 173 + 133 0 - Caution Panel Cabin Press Light + Right Gear Light - 174 + 134 0 - Test For Bogus Unimplemented Lights + ICP Data Command Switch - 175 + 135 0 - Instruments Light + ICP Drift Cutoff Switch - 176 + 136 0 - TWS Launch + ICP Nextprev Switch - 177 + 137 0 - TWS Priority Mode + JFS Switch - 178 + 138 0 - TWS Open Mode + STARTER READY LIGHT - 179 + 138 + 1 + Caution JFS LOW + + + 139 0 - TWS Handoff + MPO Switch - 180 + 140 0 - TWS Target Separation + EPU Light - 181 + 141 0 - TWS U + EPU Air Light - 182 + 142 0 - TWS Naval + EPU Hydrazine Light - 183 + 143 0 - TWS Unknown + Elec FLCS pmg Light - 184 + 144 0 - TWS System On + Elec Main gen Light - 185 + 145 0 - TWS Aux Search + Elec Stby gen Light - 186 + 146 0 - TWS Aux Activity + Elec EPU gen Light - 187 + 147 0 - TWS Aux Low + Elec EPU pmg Light - 188 + 148 0 - TWS Aux System + Elec FLCS rly Light - 189 + 149 0 - RWS Have Pwr + Elec Toflcs Light - 190 + 150 0 - ECM Power + Elec Bat fail Light - 191 + 151 0 - ECM Fail + Elec Main Power Switch - 192 + 152 0 - TFR Stby + EPU Switch - 193 + 153 0 - TFR Engaged + Caution Panel FLCS Fault Light - 194 + 154 0 - AVTR + Caution L GEN OUT - 195 + 154 + 1 + Caution R GEN OUT + + + 155 0 - Outermarker + Caution Panel Probe Heat Light - 196 + 156 0 - Middlemarker + Caution Panel C ADC Light - 197 + 157 0 - CMDS Flare Count Digit 1 - 0 + Caution Panel Stores Config Light - 197 + 158 + 0 + Caution EMER BST ON + + + 158 1 - CMDS Flare Count Digit 1 - 1 + Caution BST SYS MAL - 197 + 158 2 - CMDS Flare Count Digit 1 - 2 + FuelQtySelector BIT - 197 + 158 3 - CMDS Flare Count Digit 1 - 3 + FuelQtySelector FEED - 197 + 158 4 - CMDS Flare Count Digit 1 - 4 + FuelQtySelector INTLWING - 197 + 158 5 - CMDS Flare Count Digit 1 - 5 + FuelQtySelector TANK1 - 197 + 158 6 - CMDS Flare Count Digit 1 - 6 + FuelQtySelector EXTWING - 197 + 158 7 - CMDS Flare Count Digit 1 - 7 + FuelQtySelector FXTCTRL - 197 + 158 8 - CMDS Flare Count Digit 1 - 8 + FuelQtySelector CONFTANK - 197 + 158 9 - CMDS Flare Count Digit 1 - 9 + Fuel Flag OFF - 198 + 159 0 - CMDS Flare Count Digit 2 - 0 + Caution Panel Aft Fuel Low Light - 198 + 160 + 0 + Caution L EEC + + + 160 1 - CMDS Flare Count Digit 2 - 1 + Caution R EEC - 198 + 160 2 - CMDS Flare Count Digit 2 - 2 + Caution L INLET - 198 + 160 3 - CMDS Flare Count Digit 2 - 3 + Caution R INLET - 198 + 160 4 - CMDS Flare Count Digit 2 - 4 + Caution R INLET - 198 + 160 5 - CMDS Flare Count Digit 2 - 5 + Caution R INLET - 198 - 6 - CMDS Flare Count Digit 2 - 6 - + 161 + 0 + Caution Panel Sec Light + - 198 - 7 - CMDS Flare Count Digit 2 - 7 + 162 + 0 + Caution Panel Fuel Oil Hot Light - 198 - 8 - CMDS Flare Count Digit 2 - 8 + 163 + 0 + Caution Panel Overheat Light - 198 - 9 - CMDS Flare Count Digit 2 - 9 + 164 + 0 + Caution Panel Buc Light - 199 + 165 0 - CMDS Flare Count Digit 3 - 0 + Caution Panel Avionics Fault Light - 199 - 1 - CMDS Flare Count Digit 3 - 1 + 166 + 0 + Caution Panel Radar Alt Light - 199 - 2 - CMDS Flare Count Digit 3 - 2 + 167 + 0 + Caution Panel IFF Light - 199 - 3 - CMDS Flare Count Digit 3 - 3 + 168 + 0 + Caution Panel Seat Not Armed Light - 199 - 4 - CMDS Flare Count Digit 3 - 4 + 169 + 0 + Caution Panel NWS Fail Light - 199 - 5 - CMDS Flare Count Digit 3 - 5 + 170 + 0 + Caution ANTI SKID - 199 - 6 - CMDS Flare Count Digit 3 - 6 + 171 + 0 + Caution Panel Hook Light - 199 - 7 - CMDS Flare Count Digit 3 - 7 + 172 + 0 + Caution Panel Oxy_Low Light - 199 - 8 - CMDS Flare Count Digit 3 - 8 + 173 + 0 + Caution Panel Cabin Press Light - 199 - 9 - CMDS Flare Count Digit 3 - 9 + 174 + 0 + Test For Bogus Unimplemented Lights - 200 + 175 0 - CMDS Chaff Count Digit 1 - 0 + Standby Instruments Light 0 - 200 + 175 1 - CMDS Chaff Count Digit 1 - 1 + Standby Instruments Light 1 - 200 + 175 2 - CMDS Chaff Count Digit 1 - 2 + Lights Test 0 - 200 + 175 3 - CMDS Chaff Count Digit 1 - 3 + Lights Test 1 - 200 + 175 4 - CMDS Chaff Count Digit 1 - 4 + LeftConsoleLight - 200 + 175 5 - CMDS Chaff Count Digit 1 - 5 + Null - 200 + 175 6 - CMDS Chaff Count Digit 1 - 6 + Null - 200 + 175 7 - CMDS Chaff Count Digit 1 - 7 + Null - 200 + 175 8 - CMDS Chaff Count Digit 1 - 8 + Null - 200 + 175 9 - CMDS Chaff Count Digit 1 - 9 + RightConsoleLight - 201 - 0 - CMDS Chaff Count Digit 2 - 0 + 175 + 10 + F15 NCI RDY Light - 201 - 1 - CMDS Chaff Count Digit 2 - 1 + 175 + 11 + Null - 201 - 2 - CMDS Chaff Count Digit 2 - 2 + 175 + 12 + Null - 201 - 3 - CMDS Chaff Count Digit 2 - 3 + 175 + 13 + Null - 201 - 4 - CMDS Chaff Count Digit 2 - 4 + 175 + 14 + AuxInstr.Light - 201 - 5 - CMDS Chaff Count Digit 2 - 5 + 175 + 15 + Null - 201 - 6 - CMDS Chaff Count Digit 2 - 6 + 175 + 16 + Null - 201 - 7 - CMDS Chaff Count Digit 2 - 7 + 175 + 17 + Null - 201 - 8 - CMDS Chaff Count Digit 2 - 8 + 175 + 18 + Null - 201 - 9 - CMDS Chaff Count Digit 2 - 9 + 175 + 19 + FlightInstr.Light - 202 - 0 - CMDS Chaff Count Digit 3 - 0 + 175 + 20 + Null - 202 - 1 - CMDS Chaff Count Digit 3 - 1 + 175 + 21 + Null - 202 - 2 - CMDS Chaff Count Digit 3 - 2 + 175 + 22 + Null - 202 - 3 - CMDS Chaff Count Digit 3 - 3 + 175 + 23 + Null - 202 - 4 - CMDS Chaff Count Digit 3 - 4 + 175 + 24 + EngineInstr.Light - 202 - 5 - CMDS Chaff Count Digit 3 - 5 + 175 + 25 + Null - 202 - 6 - CMDS Chaff Count Digit 3 - 6 + 175 + 26 + Null - 202 - 7 - CMDS Chaff Count Digit 3 - 7 + 175 + 27 + Null - 202 - 8 - CMDS Chaff Count Digit 3 - 8 + 175 + 28 + Null - 202 - 9 - CMDS Chaff Count Digit 3 - 9 + 175 + 29 + Stanby Instruments + - 203 + 176 0 - CMDS Go + TWS Launch - 204 + 177 0 - CMDS Nogo + TWS Priority Mode - 205 + 178 0 - CMDS Dispense Rdy + TWS Open Mode - 206 + 179 0 - CMDS Auto Degr + TWS Handoff - 207 + 180 0 - CMDS Flare Bingo + TWS Target Separation - 208 + 181 0 - CMDS Chaff Bingo + TWS U - 209 + 182 0 - DL Power Down + TWS Naval - 209 - 1 - DL Power Up + 183 + 0 + TWS Unknown - 209 - 2 - GPS Power Down + 184 + 0 + TWS System On - 209 - 3 - GPS Power Up + 185 + 0 + TWS Aux Search - 209 - 4 + 186 + 0 + TWS Aux Activity + + + 187 + 0 + TWS Aux Low + + + 188 + 0 + TWS Aux System + + + 189 + 0 + RWS Have Pwr + + + 190 + 0 + ECM Power + + + 191 + 0 + ECM Fail + + + 192 + 0 + TFR Stby + + + 193 + 0 + TFR Engaged + + + 194 + 0 + AVTR + + + 195 + 0 + Outermarker + + + 196 + 0 + Middlemarker + + + 197 + 0 + CMDS Flare Count Digit 1 - 0 + + + 197 + 1 + CMDS Flare Count Digit 1 - 1 + + + 197 + 2 + CMDS Flare Count Digit 1 - 2 + + + 197 + 3 + CMDS Flare Count Digit 1 - 3 + + + 197 + 4 + CMDS Flare Count Digit 1 - 4 + + + 197 + 5 + CMDS Flare Count Digit 1 - 5 + + + 197 + 6 + CMDS Flare Count Digit 1 - 6 + + + 197 + 7 + CMDS Flare Count Digit 1 - 7 + + + 197 + 8 + CMDS Flare Count Digit 1 - 8 + + + 197 + 9 + CMDS Flare Count Digit 1 - 9 + + + 198 + 0 + CMDS Flare Count Digit 2 - 0 + + + 198 + 1 + CMDS Flare Count Digit 2 - 1 + + + 198 + 2 + CMDS Flare Count Digit 2 - 2 + + + 198 + 3 + CMDS Flare Count Digit 2 - 3 + + + 198 + 4 + CMDS Flare Count Digit 2 - 4 + + + 198 + 5 + CMDS Flare Count Digit 2 - 5 + + + 198 + 6 + CMDS Flare Count Digit 2 - 6 + + + 198 + 7 + CMDS Flare Count Digit 2 - 7 + + + 198 + 8 + CMDS Flare Count Digit 2 - 8 + + + 198 + 9 + CMDS Flare Count Digit 2 - 9 + + + 199 + 0 + CMDS Flare Count Digit 3 - 0 + + + 199 + 1 + CMDS Flare Count Digit 3 - 1 + + + 199 + 2 + CMDS Flare Count Digit 3 - 2 + + + 199 + 3 + CMDS Flare Count Digit 3 - 3 + + + 199 + 4 + CMDS Flare Count Digit 3 - 4 + + + 199 + 5 + CMDS Flare Count Digit 3 - 5 + + + 199 + 6 + CMDS Flare Count Digit 3 - 6 + + + 199 + 7 + CMDS Flare Count Digit 3 - 7 + + + 199 + 8 + CMDS Flare Count Digit 3 - 8 + + + 199 + 9 + CMDS Flare Count Digit 3 - 9 + + + 200 + 0 + CMDS Chaff Count Digit 1 - 0 + + + 200 + 1 + CMDS Chaff Count Digit 1 - 1 + + + 200 + 2 + CMDS Chaff Count Digit 1 - 2 + + + 200 + 3 + CMDS Chaff Count Digit 1 - 3 + + + 200 + 4 + CMDS Chaff Count Digit 1 - 4 + + + 200 + 5 + CMDS Chaff Count Digit 1 - 5 + + + 200 + 6 + CMDS Chaff Count Digit 1 - 6 + + + 200 + 7 + CMDS Chaff Count Digit 1 - 7 + + + 200 + 8 + CMDS Chaff Count Digit 1 - 8 + + + 200 + 9 + CMDS Chaff Count Digit 1 - 9 + + + 201 + 0 + CMDS Chaff Count Digit 2 - 0 + + + 201 + 1 + CMDS Chaff Count Digit 2 - 1 + + + 201 + 2 + CMDS Chaff Count Digit 2 - 2 + + + 201 + 3 + CMDS Chaff Count Digit 2 - 3 + + + 201 + 4 + CMDS Chaff Count Digit 2 - 4 + + + 201 + 5 + CMDS Chaff Count Digit 2 - 5 + + + 201 + 6 + CMDS Chaff Count Digit 2 - 6 + + + 201 + 7 + CMDS Chaff Count Digit 2 - 7 + + + 201 + 8 + CMDS Chaff Count Digit 2 - 8 + + + 201 + 9 + CMDS Chaff Count Digit 2 - 9 + + + 202 + 0 + CMDS Chaff Count Digit 3 - 0 + + + 202 + 1 + CMDS Chaff Count Digit 3 - 1 + + + 202 + 2 + CMDS Chaff Count Digit 3 - 2 + + + 202 + 3 + CMDS Chaff Count Digit 3 - 3 + + + 202 + 4 + CMDS Chaff Count Digit 3 - 4 + + + 202 + 5 + CMDS Chaff Count Digit 3 - 5 + + + 202 + 6 + CMDS Chaff Count Digit 3 - 6 + + + 202 + 7 + CMDS Chaff Count Digit 3 - 7 + + + 202 + 8 + CMDS Chaff Count Digit 3 - 8 + + + 202 + 9 + CMDS Chaff Count Digit 3 - 9 + + + 203 + 0 + CMDS Go + + + 204 + 0 + CMDS Nogo + + + 205 + 0 + CMDS Dispense Rdy + + + 206 + 0 + Oxygene green OFF + + + 206 + 1 + Oxygene green ON + + + 207 + 0 + CMDS Flare Bingo + + + 208 + 0 + CMDS Chaff Bingo + + + 209 + 0 + DL Power Down + + + 209 + 1 + DL Power Up + + + 209 + 2 + GPS Power Down + + + 209 + 3 + GPS Power Up + + + 209 + 4 UFC Power Down - 209 + 209 + 5 + UFC Power Up + + + 209 + 6 + MFD Power Down + + + 209 + 7 + MFD Power Up + + + 209 + 8 + SMS Power Down + + + 209 + 9 + SMS Power Up + + + 209 + 10 + FCC Power Down + + + 209 + 11 + FCC Power Up + + + 209 + 12 + Left HP Power Down + + + 209 + 13 + Left HP Power Up + + + 209 + 14 + Right HP Power Down + + + 209 + 15 + Right HP Power Up + + + 209 + 16 + FCR Power Down + + + 209 + 17 + FCR Power Up + + + 209 + 18 + Yaw off + + + 209 + 19 + Yaw reset + + + 209 + 20 + Yaw on + + + 209 + 21 + Roll off + + + 209 + 22 + Roll reset + + + 209 + 23 + Roll on + + + 209 + 24 + Pitch Off + + + 209 + 25 + Pitch reset + + + 209 + 26 + Pitch On + + + 209 + 27 + T/O not pressed + + + 209 + 28 + T/O pressed + + + 209 + 29 + T/O light + + + 209 + 30 + AP Disc Down + + + 209 + 31 + AP Disc Up + + + 210 + 0 + Tank Inserting 0 + + + 210 + 1 + Tank Inserting 1 + + + 210 + 2 + Refuel Door 0 + + + 210 + 3 + Refuel Door 1 + + + 210 + 4 + Aux Comm TR AA 0 + + + 210 + 5 + Aux Comm TR AA 1 + + + 210 + 6 + Anti Collision 0 + + + 210 + 7 + Anti Collision 1 + + + 210 + 8 + Flash 0 + + + 210 + 9 + Flash 1 + + + 210 + 10 + Wing Left/Right 0 + + + 210 + 11 + Wing Left/Right 1 + + + 210 + 12 + Light Master Power 0 + + + 210 + 13 + Light Master Power 1 + + + 210 + 14 + ECM Power 0 + + + 210 + 15 + ECM Power 1 + + + 210 + 16 + Max Power 0 + + + 210 + 17 + Max Power 1 + + + 210 + 18 + MWS Off + + + 210 + 19 + MWS On + + + 210 + 20 + Jammer Off + + + 210 + 21 + Jammer On + + + 210 + 22 + RWR Off + + + 210 + 23 + RWR On + + + 210 + 24 + 01 CMDS 0 + + + 210 + 25 + 01 CMDS 1 + + + 210 + 26 + 02 CMDS 0 + + + 210 + 27 + 02 CMDS 2 + + + 210 + 28 + Chaff 0 + + + 210 + 29 + Chaff 1 + + + 210 + 30 + Flare 0 + + + 210 + 31 + Flare 1 + + + 211 + 0 + Jettison CMDS Off + + + 211 + 1 + Jettison CMDS On + + + 211 + 2 + Cat Off + + + 211 + 3 + Cat On + + + 211 + 4 + Ground Jettison Off + + + 211 + 5 + Ground Jettison On + + + 211 + 6 + Brake Channel Off + + + 211 + 7 + Brake Channel On + + + 211 + 8 + + + 211 + 9 + + + 211 + 10 + Laser Arm Off + + + 211 + 11 + Laser Arm On + + + 211 + 12 + Fuel Wing First Off + + + 211 + 13 + Fuel Wing First On + + + 211 + 14 + MPO Off + + + 211 + 15 + MPO On + + + 211 + 16 + + + 211 + 17 + + + 211 + 18 + JSF Off + + + 211 + 19 + JSF On + + + 211 + 20 + IFF Monitor Off + + + 211 + 21 + IFF Monitor On + + + 212 + 0 + Alt Radar Power 0 + + + 212 + 1 + Alt Radar Power 1 + + + 212 + 2 + Alt Radar Power 2 + + + 212 + 3 + Depr Ret 0 + + + 212 + 4 + Depr Ret 1 + + + 212 + 5 + Depr Ret 2 + + + 212 + 6 + DED Data 0 + + + 212 + 7 + DED Data 1 + + + 212 + 8 + DED Data 2 + + + 212 + 9 + HUD Att Fpm 0 + + + 212 + 10 + HUD Att Fpm 1 + + + 212 + 11 + HUD Att Fpm 2 + + + 212 + 12 + HUD Vah Fpm 0 + + + 212 + 13 + HUD Vah Fpm 1 + + + 212 + 14 + HUD Vah Fpm 2 + + + 212 + 15 + Test Step 0 + + + 212 + 16 + Test Step 1 + + + 212 + 17 + Test Step 2 + + + 212 + 18 + HUD Bright 0 + + + 212 + 19 + HUD Bright 1 + + + 212 + 20 + HUD Bright 2 + + + 212 + 21 + HUD Alt Radar 0 + + + 212 + 22 + HUD Alt Radar 1 + + + 212 + 23 + HUD Alt Radar 2 + + + 212 + 24 + HUD Speed 0 + + + 212 + 25 + HUD Speed 1 + + + 212 + 26 + HUD Speed 2 + + + 212 + 27 + Anti Ice 0 + + + 212 + 28 + Anti Ice 1 + + + 212 + 29 + Anti Ice 2 + + + 213 + 0 + IFF Mode4 + ZERO + + + 213 + 1 + IFF Mode4 + NORM + + + 213 + 2 + IFF Mode4 + HOLD + + + 213 + 3 + IFF mode4 + OUT + + + 213 + 4 + IFF mode4 + A + + + 213 + 5 + IFF mode4 + B + + + 213 + 6 + F15 VIDEO RECORD OFF + IFF Master EMERG + + + 213 + 7 + F15 VIDEO RECORD AUTO + IFF Master NORM + + + 213 + 8 + F15 VIDEO RECORD HUD + IFF Master LOW + + + 213 + 9 + IFF MC + OUT + + + 213 + 10 + IFF MC + ON + + + 213 + 11 + IFF M3/A + OUT + + + 213 + 12 + IFF M3/A + ON + + + 213 + 13 + IFF M2 + OUT + + + 213 + 14 + IFF M2 + ON + + + 213 + 15 + IFF M1 + OUT + + + 213 + 16 + IFF M1 + ON + + + 213 + 17 + IFF REPLY + LIGHT BRIGHT + + + 213 + 18 + IFF REPLY + OFF + + + 213 + 19 + IFF REPLY + AUDIO REC + + + 213 + 20 + IFF REPLY + LIGHT SWITCH + + + 213 + 21 + ATT Hold Off + + + 213 + 22 + ATT hold On + + + 213 + 23 + Null + + + 213 + 24 + ALT Hold Off + + + 213 + 25 + Null + + + 213 + 26 + ALT Hold On + + + 213 + 27 + Master Arm 0 + + + 213 + 28 + Master Arm 1 + + + 213 + 29 + Master Arm 2 + + + 214 + 0 + RF 0 + + + 214 + 1 + RF 1 + + + 214 + 2 + RF 2 + + + 214 + 3 + C/O Drift 0 + + + 214 + 4 + C/O Drift 1 + + + 214 + 5 + C/O Drift 2 + + + 214 + 6 + FLIR Gain 0 + + + 214 + 7 + FLIR Gain 1 + + + 214 + 8 + FLIR Gain 2 + + + 214 + 9 + Test 0 + + + 214 + 10 + Test 1 + + + 214 + 11 + Test 2 + + + 214 + 12 + EPU 0 + + + 214 + 13 + EPU 1 + + + 214 + 14 + EPU 2 + + + 214 + 15 + Zeroize 0 + + + 214 + 16 + Zeroize 1 + + + 214 + 17 + Zeroize 2 + + + 214 + 18 + Nuclear Consent 0 + + + 214 + 19 + Nuclear Consent 1 + + + 214 + 20 + Nuclear Consent 2 + + + 214 + 21 + UHF Main 0 + + + 214 + 22 + UHF Main 1 + + + 214 + 23 + UHF Main 2 + + + 214 + 24 + Probe Heat 0 + + + 214 + 25 + Probe Heat 1 + + + 214 + 26 + Probe Heat 2 + + + 214 + 27 + IFF Reply Off + + + 214 + 28 + IFF Reply Alpha + + + 214 + 29 + IFF Reply Bravo + + + 215 + 0 + AAI MASTER OFF + + + 215 + 1 + AAI MASTER AUTO + + + 215 + 2 + AAI MASTER NORM + + + 215 + 3 + AAI MASTER CC + + + 215 + 4 + AAI MASTER XCC + + + 215 + 5 + AAI MASTER XNORM + + + 216 + 0 + Caution EECS + + + 216 + 1 + Caution WNDSHLD HOT + + + 216 + 2 + Caution FUEL HOT + + + 217 + 0 + Oxy Quantity 0 + + + 217 + 1 + Oxy Quantity 1 + + + 217 + 2 + EPU/Gen 0 + + + 217 + 3 + EPU/Gen 1 + + + 217 + 4 + Master Fuel 0 + + + 217 + 5 + Master Fuel 1 + + + 217 + 6 + Eng Count 0 (Pri) + + + 217 + 7 + Eng Count 1 (Sec) + + + 217 + 8 + Voice Message Inhibit 0 + + + 217 + 9 + Voice Message Inhibit 1 + + + 217 + 10 + Plain 0 + + + 217 + 11 + Plain 1 + + + 217 + 12 + Squelch 0 + + + 217 + 13 + Squelch 1 + + + 217 + 14 + Mal & Int 0 + + + 217 + 15 + Mal & Int 1 + + + 217 + 16 + Ext Light Form 0 + + + 217 + 17 + Ext Light Form 1 + + + 217 + 18 + Ext Light Aerial Refuel 0 + + + 217 + 19 + Ext Light Aerial Refuel 1 + + + 217 + 20 + CNI Backup 0 + + + 217 + 21 + CNI Backup 1 + + + 217 + 22 + Audio Intercom 0 + + + 217 + 23 + Audio Intercom 1 + + + 217 + 24 + Audio Tacan 0 + + + 217 + 25 + Audio Tacan 1 + + + 217 + 26 + Hook Down + + + 217 + 27 + Hook Up + + + 217 + 28 + UHF Preset 0 + + + 217 + 29 + UHF Preset 1 + + + 217 + 30 + UHF Preset 2 + + + 218 + 0 + Audio ILS 0 + + + 218 + 1 + Audio ILS 1 + + + 218 + 2 + Comm 1 Sel 0 + + + 218 + 3 + Comm 1 Sel 1 + + + 218 + 4 + Comm 2 Sel 0 + + + 218 + 5 + Comm 2 Sel 1 + + + 218 + 6 + Tf 0 + + + 218 + 7 + Tf 1 + + + 218 + 8 + Voice Secure 0 + + + 218 + 9 + Voice Secure 1 + + + 218 + 10 + Horn Silencer Up + + + 218 + 11 + Horn Silencer Down + + + 218 + 12 + Fire & Overheat detect Up + + + 218 + 13 + Fire & Overheat detect Down + + + 218 + 14 + Mal & Ind LTS Up + + + 218 + 15 + Mal & Ind LTS Down + + + 218 + 16 + + + 218 + 17 + + + 218 + 18 + Launch Bar 0 + + + 218 + 19 + Launch Bar 1 + + + 219 + 0 + T23A Display Select 0 + + + 219 + 1 + T23A Display Select 1 + + + 219 + 2 + T23A Display Select 2 + + + 219 + 3 + T23A Display Select 3 + + + 219 + 4 + Air Source 0 + + + 219 + 5 + Air Source 1 + + + 219 + 6 + Air Source 2 + + + 219 + 7 + Air Source 3 + + + 219 + 8 + HSI Instr Mode 0 + + + 219 + 9 + HSI Instr Mode 1 + + + 219 + 10 + HSI Instr Mode 2 + + + 219 + 11 + HSI Instr Mode 3 + + + 219 + 12 + Avionics Power Ins 0 + + + 219 + 13 + Avionics Power Ins 1 + + + 219 + 14 + Avionics Power Ins 2 + + + 219 + 15 + Avionics Power Ins 3 + + + 219 + 16 + Eng Feed 0 + + + 219 + 17 + Eng Feed 1 + + + 219 + 18 + Eng Feed 2 + + + 219 + 19 + Eng Feed 3 + + + 219 + 20 + CMDS Prog 0 + + + 219 + 21 + CMDS Prog 1 + + + 219 + 22 + CMDS Prog 2 + + + 219 + 23 + CMDS Prog 3 + + + 219 + 24 + Manual Freq 1st Digit 0 + + + 219 + 25 + Manual Freq 1st Digit 1 + + + 219 + 26 + Manual Freq 1st Digit 2 + + + 219 + 27 + Manual Freq 1st Digit 3 + + + 219 + 28 + IFF M1 Digit 2 0 + SimIFFBackupM1Digit2_0 + + + 219 + 29 + IFF M1 Digit 2 1 + SimIFFBackupM1Digit2_1 + + + 219 + 30 + IFF M1 Digit 2 2 + SimIFFBackupM1Digit2_2 + + + 219 + 31 + IFF M1 Digit 2 3 + SimIFFBackupM1Digit2_3 + + + 220 + 0 + IFF Master 0 + SimIFFMasterOff + + + 220 + 1 + IFF Master 1 + SimIFFMasterStby + + + 220 + 2 + IFF Master 2 + SimIFFMasterLow + + + 220 + 3 + IFF Master 3 + SimIFFMasterNorm + + + 220 + 4 + IFF Master 4 + SimIFFMasterEmerg + + + 220 + 5 + HMCS Power 0 (Off) + + + 220 + 6 + HMCS Power 1 + + + 220 + 7 + HMCS Power 2 + + + 220 + 8 + HMCS Power 3 + + + 220 + 9 + HMCS Power 4 + + + 221 + 0 + CMDS Mode 0 + + + 221 + 1 + CMDS Mode 1 + + + 221 + 2 + CMDS Mode 2 + + + 221 + 3 + CMDS Mode 3 + + + 221 + 4 + CMDS Mode 4 + + + 221 5 - UFC Power Up + CMDS Mode 5 - 209 + 221 6 - MFD Power Down + Fuel Qty Sel 0 - 209 + 221 7 - MFD Power Up + Fuel Qty Sel 1 - 209 + 221 8 - SMS Power Down + Fuel Qty Sel 2 - 209 + 221 9 - SMS Power Up + Fuel Qty Sel 3 - 209 + 221 10 - FCC Power Down + Fuel Qty Sel 4 - 209 + 221 11 - FCC Power Up + Fuel Qty Sel 5 - 209 + 222 + 0 + UHF Volume 0 + + + 222 + 1 + UHF Volume 1 + + + 222 + 2 + UHF Volume 2 + + + 222 + 3 + UHF Volume 3 + + + 222 + 4 + UHF Volume 4 + + + 222 + 5 + UHF Volume 5 + + + 222 + 6 + UHF Volume 6 + + + 222 + 7 + + + 222 + 8 + IFF M1 Digit 1 0 + SimIFFBackupM1Digit1_0 + + + 222 + 9 + IFF M1 Digit 1 1 + SimIFFBackupM1Digit1_1 + + + 222 + 10 + IFF M1 Digit 1 2 + SimIFFBackupM1Digit1_2 + + + 222 + 11 + IFF M1 Digit 1 3 + SimIFFBackupM1Digit1_3 + + + 222 12 - Left HP Power Down + IFF M1 Digit 1 4 + SimIFFBackupM1Digit1_4 - 209 + 222 13 - Left HP Power Up + IFF M1 Digit 1 5 + SimIFFBackupM1Digit1_5 - 209 + 222 14 - Right HP Power Down + IFF M1 Digit 1 6 + SimIFFBackupM1Digit1_6 - 209 + 222 15 - Right HP Power Up + IFF M1 Digit 1 7 + SimIFFBackupM1Digit1_7 - 209 + 222 16 - FCR Power Down + IFF M3 Digit 1 0 + SimIFFBackupM3Digit1_0 - 209 + 222 17 - FCR Power Up + IFF M3 Digit 1 1 + SimIFFBackupM3Digit1_1 - 209 + 222 18 - Digital Backup Down + IFF M3 Digit 1 2 + SimIFFBackupM3Digit1_2 - 209 + 222 19 - Digital Backup Up + IFF M3 Digit 1 3 + SimIFFBackupM3Digit1_3 - 209 + 222 20 - Alt Flap Down + IFF M3 Digit 1 4 + SimIFFBackupM3Digit1_4 - 209 + 222 21 - Alt Flap Up + IFF M3 Digit 1 5 + SimIFFBackupM3Digit1_5 - 209 + 222 22 - FLCS Rest Down + IFF M3 Digit 1 6 + SimIFFBackupM3Digit1_6 - 209 + 222 23 - FLCS Rest Up + IFF M3 Digit 1 7 + SimIFFBackupM3Digit1_7 - 209 + 222 24 - LE Flap Lock Down + IFF M3 Digit 2 0 + SimIFFBackupM3Digit2_0 - 209 + 222 25 - LE Flap Lock Up + IFF M3 Digit 2 1 + SimIFFBackupM3Digit2_1 - 209 + 222 26 - Manual Tf Down + IFF M3 Digit 2 2 + SimIFFBackupM3Digit2_2 - 209 + 222 27 - Manual Tf Up + IFF M3 Digit 2 3 + SimIFFBackupM3Digit2_3 - 209 + 222 28 - Bit Down + IFF M3 Digit 2 4 + SimIFFBackupM3Digit2_4 - 209 + 222 29 - Bit Up + IFF M3 Digit 2 5 + SimIFFBackupM3Digit2_5 - 209 + 222 30 - AP Disc Down + IFF M3 Digit 2 6 + SimIFFBackupM3Digit2_6 - 209 + 222 31 - AP Disc Up + IFF M3 Digit 2 7 + SimIFFBackupM3Digit2_7 - 210 + 223 0 - Tank Inserting 0 + Pull 0 - 210 + 223 1 - Tank Inserting 1 + Pull 1 - 210 + 223 2 - Refuel Door 0 + Pull 2 - 210 + 223 3 - Refuel Door 1 + Pull 3 - 210 + 223 4 - Aux Comm TR AA 0 + Pull 4 - 210 + 223 5 - Aux Comm TR AA 1 + Pull 5 - 210 + 223 6 - Anti Collision 0 + Pull 6 - 210 + 223 7 - Anti Collision 1 + Pull 7 - 210 + 223 8 - Flash 0 + HSI Crs Select 0 + Shows the knob turning - 210 + 223 9 - Flash 1 + HSI Crs Select 1 - 210 + 223 10 - Unused + HSI Crs Select 2 - 210 + 223 11 - Unused + HSI Crs Select 3 - 210 + 223 12 - Light Master Power 0 + HSI Crs Select 4 - 210 + 223 13 - Light Master Power 1 + HSI Crs Select 5 - 210 + 223 14 - ECM Power 0 + HSI Crs Select 6 - 210 + 223 15 - ECM Power 1 + HSI Crs Select 7 - 210 + 223 16 - Max Power 0 + HSI Hdg Select 0 + Shows the knob turning - 210 + 223 17 - Max Power 1 + HSI Hdg Select 1 - 210 + 223 18 - MWS Off + HSI Hdg Select 2 - 210 + 223 19 - MWS On + HSI Hdg Select 3 - 210 + 223 20 - Jammer Off + HSI Hdg Select 4 - 210 + 223 21 - Jammer On + HSI Hdg Select 5 - 210 + 223 22 - RWR Off + HSI Hdg Select 6 - 210 + 223 23 - RWR On - - - 210 - 24 - 01 CMDS 0 - - - 210 - 25 - 01 CMDS 1 - - - 210 - 26 - 02 CMDS 0 - - - 210 - 27 - 02 CMDS 2 - - - 210 - 28 - Chaff 0 - - - 210 - 29 - Chaff 1 - - - 210 - 30 - Flare 0 - - - 210 - 31 - Flare 1 + HSI Hdg Select 7 - 211 + 224 0 - Jettison CMDS Off + Threat Warn Vol 0 - 211 + 224 1 - Jettison CMDS On + Threat Warn Vol 1 - 211 + 224 2 - Cat Off + Threat Warn Vol 2 - 211 + 224 3 - Cat On + Threat Warn Vol 3 - 211 + 224 4 - Ground Jettison Off + Threat Warn Vol 4 - 211 + 224 5 - Ground Jettison On + Threat Warn Vol 5 - 211 + 224 6 - Brake Channel Off + Threat Warn Vol 6 - 211 + 224 7 - Brake Channel On + Threat Warn Vol 7 - 211 + 224 8 + Threat Warn Vol 8 - 211 + 224 9 + Missile Warn Vol 0 - 211 + 224 10 - Laser Arm Off + Missile Warn Vol 1 - 211 + 224 11 - Laser Arm On + Missile Warn Vol 2 - 211 + 224 12 - Fuel Wing First Off + Missile Warn Vol 3 - 211 + 224 13 - Fuel Wing First On + Missile Warn Vol 4 - 211 + 224 14 - MPO Off + Missile Warn Vol 5 - 211 + 224 15 - MPO On + Missile Warn Vol 6 - 211 + 224 16 + Missile Warn Vol 7 - 211 + 224 17 + Missile Warn Vol 8 - 211 + 224 18 - JSF Off + Intercom Vol 0 - 211 + 224 19 - JSF On - + Intercom Vol 1 + - 211 + 224 20 - IFF Monitor Off - + Intercom Vol 2 + - 211 + 224 21 - IFF Monitor On + Intercom Vol 3 + + + 224 + 22 + Intercom Vol 4 + + + 224 + 23 + Intercom Vol 5 + + + 224 + 24 + Intercom Vol 6 - 212 + 224 + 25 + Intercom Vol 7 + + + 224 + 26 + Intercom Vol 8 + + + 225 0 - Alt Radar Power 0 + Comm1 Volume 0 - 212 + 225 1 - Alt Radar Power 1 + Comm1 Volume 1 - 212 + 225 2 - Alt Radar Power 2 + Comm1 Volume 2 - 212 + 225 3 - Depr Ret 0 + Comm1 Volume 3 - 212 + 225 4 - Depr Ret 1 + Comm1 Volume 4 - 212 + 225 5 - Depr Ret 2 + Comm1 Volume 5 - 212 + 225 6 - DED Data 0 + Comm1 Volume 6 - 212 + 225 7 - DED Data 1 + Comm1 Volume 7 - 212 + 225 8 - DED Data 2 + Comm1 Volume 8 - 212 + 225 9 - HUD Att Fpm 0 + Comm2 Volume 0 - 212 + 225 10 - HUD Att Fpm 1 + Comm2 Volume 1 - 212 + 225 11 - HUD Att Fpm 2 + Comm2 Volume 2 - 212 + 225 12 - HUD Vah Fpm 0 + Comm2 Volume 3 - 212 + 225 13 - HUD Vah Fpm 1 + Comm2 Volume 4 - 212 + 225 14 - HUD Vah Fpm 2 + Comm2 Volume 5 - 212 + 225 15 - Test Step 0 + Comm2 Volume 6 - 212 + 225 16 - Test Step 1 + Comm2 Volume 7 - 212 + 225 17 - Test Step 2 + Comm2 Volume 8 - 212 + 225 18 - HUD Bright 0 + ILS Volume 0 - 212 + 225 19 - HUD Bright 1 - + ILS Volume 1 + - 212 + 225 20 - HUD Bright 2 - + ILS Volume 2 + - 212 + 225 21 - HUD Alt Radar 0 - + ILS Volume 3 + - 212 + 225 22 - HUD Alt Radar 1 - + ILS Volume 4 + - 212 + 225 23 - HUD Alt Radar 2 - + ILS Volume 5 + - 212 + 225 24 - HUD Speed 0 - + ILS Volume 6 + - 212 + 225 25 - HUD Speed 1 - + ILS Volume 7 + - 212 + 225 26 - HUD Speed 2 - - - 212 - 27 - Anti Ice 0 - - - 212 - 28 - Anti Ice 1 - - - 212 - 29 - Anti Ice 2 + ILS Volume 8 - 213 + 226 0 - IFF Code Zero - SimIFFMode4ReplyOff  + Man Freq 2 Digit 0 - 213 + 226 1 - IFF Code AB - SimIFFMode4ReplyAlpha + Man Freq 2 Digit 1 - 213 + 226 2 - IFF Code Hold - SimIFFMode4ReplyBravo + Man Freq 2 Digit 2 - 213 + 226 3 - IFF Enable M1M3 - SimIFFEnableM1M3 + Man Freq 2 Digit 3 - 213 + 226 4 - IFF Enable Off - SimIFFEnableOff + Man Freq 2 Digit 4 - 213 + 226 5 - IFF Enable M3MS - SimIFFEnableM3MS + Man Freq 2 Digit 5 - 213 + 226 6 - AVTR 0 + Man Freq 2 Digit 6 - 213 + 226 7 - AVTR 1 + Man Freq 2 Digit 7 - 213 + 226 8 - AVTR 2 + Man Freq 2 Digit 8 - 213 + 226 9 - Landing Light 0 - Landing light off + Man Freq 2 Digit 9 - 213 + 226 10 - Landing Light 1 - Taxi light on + Man Freq 3 Digit 0 - 213 + 226 11 - Landing Light 2 - Landing light on + Man Freq 3 Digit 1 - 213 + 226 12 - ECM Xmit 0 + Man Freq 3 Digit 2 - 213 + 226 13 - ECM Xmit 1 + Man Freq 3 Digit 3 - 213 + 226 14 - ECM Xmit 2 + Man Freq 3 Digit 4 - 213 + 226 15 - Eng Data Ab 0 + Man Freq 3 Digit 5 - 213 + 226 16 - Eng Data Ab 1 + Man Freq 3 Digit 6 - 213 + 226 17 - Eng Data Ab 2 + Man Freq 3 Digit 7 - 213 + 226 18 - Hot Mike 0 + Man Freq 3 Digit 8 - 213 + 226 19 - Hot Mike 1 + Man Freq 3 Digit 9 - 213 + 226 20 - Hot Mike 2 + Man Freq 4 Digit 0 - 213 + 226 21 - AP Roll 0 (Left Down) + Man Freq 4 Digit 1 - 213 + 226 22 - AP Roll 1 (Left Mid) + Man Freq 4 Digit 2 - 213 + 226 23 - AP Roll 2 (Left Up) + Man Freq 4 Digit 3 - 213 + 226 24 - AP Pitch 0 (Right Down) + Man Freq 4 Digit 4 - 213 + 226 25 - AP Pitch 1 (Right Mid) + Man Freq 4 Digit 5 - 213 + 226 26 - AP Pitch 2 (Right Up) + Man Freq 4 Digit 6 - 213 + 226 27 - Master Arm 0 + Man Freq 4 Digit 7 - 213 + 226 28 - Master Arm 1 + Man Freq 4 Digit 8 - 213 + 226 29 - Master Arm 2 + Man Freq 4 Digit 9 - 214 + 227 0 - RF 0 + Man Freq 5 Digit 0 - 214 + 227 1 - RF 1 + Man Freq 5 Digit 1 - 214 + 227 2 - RF 2 + Man Freq 5 Digit 2 - 214 + 227 3 - C/O Drift 0 + Man Freq 5 Digit 3 - 214 + 227 4 - C/O Drift 1 + Man Freq 5 Digit 4 - 214 + 227 5 - C/O Drift 2 - - - 214 - 6 - FLIR Gain 0 - - - 214 - 7 - FLIR Gain 1 - - - 214 - 8 - FLIR Gain 2 - - - 214 - 9 - Test 0 - - - 214 - 10 - Test 1 - - - 214 - 11 - Test 2 - - - 214 - 12 - EPU 0 - - - 214 - 13 - EPU 1 - - - 214 - 14 - EPU 2 - - - 214 - 15 - Zeroize 0 - - - 214 - 16 - Zeroize 1 - - - 214 - 17 - Zeroize 2 + Man Freq 5 Digit 5 - 214 - 18 - Nuclear Consent 0 + 227 + 6 + Man Freq 5 Digit 6 - 214 - 19 - Nuclear Consent 1 + 227 + 7 + Man Freq 5 Digit 7 - 214 - 20 - Nuclear Consent 2 + 227 + 8 + Man Freq 5 Digit 8 - 214 - 21 - UHF Main 0 + 227 + 9 + Man Freq 5 Digit 9 - 214 - 22 - UHF Main 1 + 228 + 0 + unused - 214 - 23 - UHF Main 2 + 228 + 1 + FLCS A Test Light ON - 214 - 24 - Probe Heat 0 + 228 + 2 + unused - 214 - 25 - Probe Heat 1 + 228 + 3 + FLCS B Test Light ON - 214 - 26 - Probe Heat 2 + 228 + 4 + unused - 214 - 27 - IFF Reply Off + 228 + 5 + FLCS C Test Light - 214 - 28 - IFF Reply Alpha + 228 + 6 + unused - 214 - 29 - IFF Reply Bravo + 228 + 7 + FLCS D Test Light - 215 + 229 0 - Caution Light Oxy Low + Flt Control Run Light - 216 - 0 - Caution Panel Equip Hot Light + 229 + 1 + Flt Control Fail Light - 217 + 230 0 - Oxy Quantity 0 + Left console lights 0 - 217 + 230 1 - Oxy Quantity 1 + Left console lights 1 - 217 + 230 2 - EPU/Gen 0 + Left console lights 2 - 217 + 230 3 - EPU/Gen 1 + Left console lights 3 - 217 + 230 4 - Master Fuel 0 + Left console lights 4 - 217 + 230 5 - Master Fuel 1 + Right console lights 0 - 217 + 230 6 - Eng Count 0 (Pri) + Right console lights 1 - 217 + 230 7 - Eng Count 1 (Sec) + Right console lights 2 - 217 + 230 8 - Voice Message Inhibit 0 + Right console lights 3 - 217 + 230 9 - Voice Message Inhibit 1 + Right console lights 4 - 217 + 230 10 - Plain 0 + AUX inst lights 0 - 217 + 230 11 - Plain 1 + AUX inst lights 1 - 217 + 230 12 - Squelch 0 + AUX inst lights 2 - 217 + 230 13 - Squelch 1 + AUX inst lights 3 - 217 + 230 14 - Mal & Int 0 + AUX inst lights 4 - 217 + 230 15 - Mal & Int 1 + FLIGHT INSTR lights 0 - 217 + 230 16 - Ext Light Form 0 + FLIGHT INSTR lights 1 - 217 + 230 17 - Ext Light Form 1 + FLIGHT INSTR lights 2 - 217 + 230 18 - Ext Light Aerial Refuel 0 + FLIGHT INSTR lights 3 - 217 + 230 19 - Ext Light Aerial Refuel 1 + FLIGHT INSTR lights 4 - 217 + 230 20 - CNI Backup 0 + ENG INSTR lights 0 - 217 + 230 21 - CNI Backup 1 + ENG INSTR lights 1 - 217 + 230 22 + ENG INSTR lights 2 - 217 + 230 23 + ENG INSTR lights 3 - 217 + 230 24 - Audio Tacan 0 + ENG INSTR lights 4 - 217 + 230 25 - Audio Tacan 1 + Flood light 0 - 217 + 230 26 - Hook 0 + Flood light 1 - 217 + 230 27 - Hook 1 + Flood light 2 - 217 + 230 28 - UHF Preset 0 + Flood light 3 - 217 + 230 29 - UHF Preset 1 + Flood light 4 - 217 + 230 30 - UHF Preset 2 + Flood light 5 - 218 + 230 + 31 + Flood light 6 + + + 231 0 + Formation Lights 0 - 218 + 231 1 + Formation Lights 1 - 218 + 231 2 - Comm 1 Sel 0 + Formation Lights 2 - 218 + 231 3 - Comm 1 Sel 1 + Formation Lights 3 - 218 + 231 4 - Comm 2 Sel 0 + Formation Lights 4 - 218 + 231 5 - Comm 2 Sel 1 + Formation Lights 5 - 218 + 231 6 - Tf 0 + Formation Lights 6 - 218 + 231 7 - Tf 1 + Position Lights 0 - 218 + 231 8 - Voice Secure 0 + Position Lights 1 - 218 + 231 9 - Voice Secure 1 + Position Lights 2 - 218 + 231 10 - Horn Silencer Up + Position Lights 3 - 218 + 231 11 - Horn Silencer Down + Position Lights 4 - 218 + 231 12 - Fire & Overheat detect Up + Position Lights 5 - 218 + 231 13 - Fire & Overheat detect Down + Position Lights BRT - 218 + 231 14 - Mal & Ind LTS Up + Position Lights FLASH - 218 + 231 15 - Mal & Ind LTS Down + Anticollision light Off - 218 + 231 16 + Anticollision light On - 218 + 231 17 + Null - 218 + 231 18 - Launch Bar 0 + Null - 218 + 231 19 - Launch Bar 1 + Null - 219 + 231 + 20 + Null + + + 231 + 21 + Null + + + 231 + 22 + Int Dim 0 + + + 231 + 23 + Int Dim 1 + + + 231 + 24 + Int Dim 2 + + + 231 + 25 + Int Dim 3 + + + 231 + 26 + Int Dim 4 + + + 232 0 - T23A Display Select 0 + Antenna Select 0 - 219 + 232 1 - T23A Display Select 1 + Antenna Select 1 - 219 + 232 2 - T23A Display Select 2 + Antenna Select 2 - 219 + 232 3 - T23A Display Select 3 + Seat Position 0 - 219 + 232 4 - Air Source 0 + Seat Position 1> - 219 + 232 5 - Air Source 1 + Seat Position 2 - 219 + 232 6 - Air Source 2 + Engine Anti Ice 0 - 219 + 232 7 - Air Source 3 + Engine Anti Ice 1 - 219 + 232 8 - HSI Instr Mode 0 + Engine Anti Ice 2 - 219 + 232 9 - HSI Instr Mode 1 + Parkinig Brake 0 - 219 + 232 10 - HSI Instr Mode 2 + Parkinig Brake 1 - 219 + 232 11 - HSI Instr Mode 3 + Parkinig Brake 2 - 219 + 232 12 - Avionics Power Ins 0 + Landing Light 0 - 219 + 232 13 - Avionics Power Ins 1 + TAXI LIGHTS - 219 + 232 14 - Avionics Power Ins 2 + LIGH OFF - 219 + 232 15 - Avionics Power Ins 3 + LANDING LIGHT - 219 + 232 16 - Eng Feed 0 + Canopy 1 - 219 + 232 17 - Eng Feed 1 + Canopy 2 - 219 + 232 18 - Eng Feed 2 + JFS Start 0 - 219 + 232 19 - Eng Feed 3 + JFS Start 1 - 219 + 232 20 - CMDS Prog 0 + JFS Start 2 - 219 + 232 21 - CMDS Prog 1 + Wing Tail Light 0 - 219 + 232 22 - CMDS Prog 2 + Wing Tail Light 1 - 219 + 232 23 - CMDS Prog 3 + Wing Tail Light 2 - 219 + 232 24 - Manual Freq 1st Digit 0 + Antiskid OFF - 219 + 232 25 - Manual Freq 1st Digit 1 + Antiskid PULSER - 219 + 232 26 - Manual Freq 1st Digit 2 - - - 219 - 27 - Manual Freq 1st Digit 3 - - - 219 - 28 - IFF M1 Digit 2 0 - SimIFFBackupM1Digit2_0 - - - 219 - 29 - IFF M1 Digit 2 1 - SimIFFBackupM1Digit2_1 - - - 219 - 30 - IFF M1 Digit 2 2 - SimIFFBackupM1Digit2_2 - - - 219 - 31 - IFF M1 Digit 2 3 - SimIFFBackupM1Digit2_3 + Antiskid NORMAL - 220 + 233 0 - IFF Master 0 - SimIFFMasterOff - + left knee pad 0 + - 220 + 233 1 - IFF Master 1 - SimIFFMasterStby - + left knee pad 1 + - 220 + 233 2 - IFF Master 2 - SimIFFMasterLow - + left knee pad 2 + - 220 + 233 3 - IFF Master 3 - SimIFFMasterNorm - + left knee pad 3 + - 220 + 233 4 - IFF Master 4 - SimIFFMasterEmerg - + left knee pad 4 + - 220 + 233 5 - HMCS Power 0 (Off) - + left knee pad 5 + - 220 + 233 6 - HMCS Power 1 - + left knee pad 6 + - 220 + 233 7 - HMCS Power 2 - + left knee pad 7 + - 220 + 233 8 - HMCS Power 3 - + left knee pad 8 + - 220 + 233 9 - HMCS Power 4 - + left knee pad 9 + - 220 + 233 10 - Extl light main power 0 - + left knee pad 10 + - 220 + 233 11 - Extl light main power 1 - + left knee pad 11 + - 220 + 233 12 - Extl light main power 2 + left knee pad 12 - 220 + 233 13 - Extl light main power 3 - + left knee pad 13 + - 220 + 233 14 - Extl light main power 4 - - - 221 - 0 - CMDS Mode 0 - - - 221 - 1 - CMDS Mode 1 - - - 221 - 2 - CMDS Mode 2 - - - 221 - 3 - CMDS Mode 3 - + left knee pad 14 + - 221 - 4 - CMDS Mode 4 + 233 + 15 + left knee pad 15 - 221 - 5 - CMDS Mode 5 + 233 + 16 + right knee pad 0 - 221 - 6 - Fuel Qty Sel 0 + 233 + 17 + right knee pad 1 - 221 - 7 - Fuel Qty Sel 1 + 233 + 18 + right knee pad 2 - 221 - 8 - Fuel Qty Sel 2 + 233 + 19 + right knee pad 3 - 221 - 9 - Fuel Qty Sel 3 + 233 + 20 + right knee pad 4 - 221 - 10 - Fuel Qty Sel 4 + 233 + 21 + right knee pad 5 - 221 - 11 - Fuel Qty Sel 5 + 233 + 22 + right knee pad 6 - 222 - 0 - UHF Volume 0 + 233 + 23 + right knee pad 7 - 222 - 1 - UHF Volume 1 + 233 + 24 + right knee pad 8 - 222 - 2 - UHF Volume 2 + 233 + 25 + right knee pad 9 - 222 - 3 - UHF Volume 3 + 233 + 26 + right knee pad 10 - 222 - 4 - UHF Volume 4 + 233 + 27 + right knee pad 11 - 222 - 5 - UHF Volume 5 + 233 + 28 + right knee pad 12 - 222 - 6 - UHF Volume 6 + 233 + 29 + right knee pad 13 - 222 - 7 - UHF Volume 7 + 233 + 30 + right knee pad 14 - 222 - 8 - IFF M1 Digit 1 0 - SimIFFBackupM1Digit1_0 + 233 + 31 + right knee pad 15 - 222 - 9 - IFF M1 Digit 1 1 - SimIFFBackupM1Digit1_1 + 234 + 0 + Anti Collision Mode 0 - 222 - 10 - IFF M1 Digit 1 2 - SimIFFBackupM1Digit1_2 + 234 + 1 + Anti Collision Mode 1 - 222 - 11 - IFF M1 Digit 1 3 - SimIFFBackupM1Digit1_3 + 234 + 2 + Anti Collision Mode 2 - 222 - 12 - IFF M1 Digit 1 4 - SimIFFBackupM1Digit1_4 + 234 + 3 + Anti Collision Mode 3 - 222 - 13 - IFF M1 Digit 1 5 - SimIFFBackupM1Digit1_5 + 234 + 4 + Anti Collision Mode 4 - 222 - 14 - IFF M1 Digit 1 6 - SimIFFBackupM1Digit1_6 + 234 + 5 + Anti Collision Mode 5 - 222 - 15 - IFF M1 Digit 1 7 - SimIFFBackupM1Digit1_7 + 234 + 6 + Anti Collision Mode 6 + - 222 - 16 - IFF M3 Digit 1 0 - SimIFFBackupM3Digit1_0 + 234 + 7 + Anti Collision Mode 7 - 222 - 17 - IFF M3 Digit 1 1 - SimIFFBackupM3Digit1_1 + 235 + 0 + Caution Light Dbu On - 222 - 18 - IFF M3 Digit 1 2 - SimIFFBackupM3Digit1_2 + 236 + 0 + Backup Radio Digit Display - 222 - 19 - IFF M3 Digit 1 3 - SimIFFBackupM3Digit1_3 + 237 + 0 + Caution ATF not engaged - 222 - 20 - IFF M3 Digit 1 4 - SimIFFBackupM3Digit1_4 + 238 + 0 + F15 MACH OFF flag - 222 - 21 - IFF M3 Digit 1 5 - SimIFFBackupM3Digit1_5 + 239 + 0 + Caution Inlet Icing - - 222 - 22 - IFF M3 Digit 1 6 - SimIFFBackupM3Digit1_6 + + 240 + 0 + AAR light - 222 - 23 - IFF M3 Digit 1 7 - SimIFFBackupM3Digit1_7 + 241 + 0 + Fuselage Light 1 - 222 - 24 - IFF M3 Digit 2 0 - SimIFFBackupM3Digit2_0 + 242 + 0 + ECM Button 1 Unpressed Not Lit - 222 - 25 - IFF M3 Digit 2 1 - SimIFFBackupM3Digit2_1 + 242 + 1 + ECM Button 1 UnPressed All Lit - 222 - 26 - IFF M3 Digit 2 2 - SimIFFBackupM3Digit2_2 + 242 + 2 + ECM Button 1 Pressed No Lit - 222 - 27 - IFF M3 Digit 2 3 - SimIFFBackupM3Digit2_3 + 242 + 3 + ECM Button 1 Pressed Standby Lit - 222 - 28 - IFF M3 Digit 2 4 - SimIFFBackupM3Digit2_4 + 242 + 4 + ECM Button 1 Pressed Active Lit - 222 - 29 - IFF M3 Digit 2 5 - SimIFFBackupM3Digit2_5 + 242 + 5 + ECM Button 1 Pressed Transmit Lit - 222 - 30 - IFF M3 Digit 2 6 - SimIFFBackupM3Digit2_6 + 242 + 6 + ECM Button 1 Pressed Failed Lit - 222 - 31 - IFF M3 Digit 2 7 - SimIFFBackupM3Digit2_7 + 242 + 7 + ECM Button 1 Pressed All Lit - 223 + 243 0 - Pull 0 + ECM Button 2 Unpressed Not Lit - 223 + 243 1 - Pull 1 + ECM Button 2 UnPressed All Lit - 223 + 243 2 - Pull 2 + ECM Button 2 Pressed No Lit - 223 + 243 3 - Pull 3 + ECM Button 2 Pressed Standby Lit - 223 + 243 4 - Pull 4 + ECM Button 2 Pressed Active Lit - 223 + 243 5 - Pull 5 + ECM Button 2 Pressed Transmit Lit - 223 + 243 6 - Pull 6 + ECM Button 2 Pressed Failed Lit - 223 + 243 7 - Pull 7 + ECM Button 2 Pressed All Lit - 223 - 8 - HSI Crs Select 0 - Shows the knob turning + 244 + 0 + ECM Button 3 Unpressed Not Lit - 223 - 9 - HSI Crs Select 1 + 244 + 1 + ECM Button 3 UnPressed All Lit - 223 - 10 - HSI Crs Select 2 + 244 + 2 + ECM Button 3 Pressed No Lit - 223 - 11 - HSI Crs Select 3 + 244 + 3 + ECM Button 3 Pressed Standby Lit - 223 - 12 - HSI Crs Select 4 + 244 + 4 + ECM Button 3 Pressed Active Lit - 223 - 13 - HSI Crs Select 5 + 244 + 5 + ECM Button 3 Pressed Transmit Lit - 223 - 14 - HSI Crs Select 6 + 244 + 6 + ECM Button 3 Pressed Failed Lit - 223 - 15 - HSI Crs Select 7 + 244 + 7 + ECM Button 3 Pressed All Lit - 223 - 16 - HSI Hdg Select 0 - Shows the knob turning + 245 + 0 + ECM Button 4 Unpressed Not Lit - 223 - 17 - HSI Hdg Select 1 + 245 + 1 + ECM Button 4 UnPressed All Lit - 223 - 18 - HSI Hdg Select 2 + 245 + 2 + ECM Button 4 Pressed No Lit - 223 - 19 - HSI Hdg Select 3 + 245 + 3 + ECM Button 4 Pressed Standby Lit - 223 - 20 - HSI Hdg Select 4 + 245 + 4 + ECM Button 4 Pressed Active Lit - 223 - 21 - HSI Hdg Select 5 + 245 + 5 + ECM Button 4 Pressed Transmit Lit - 223 - 22 - HSI Hdg Select 6 + 245 + 6 + ECM Button 4 Pressed Failed Lit - 223 - 23 - HSI Hdg Select 7 + 245 + 7 + ECM Button 4 Pressed All Lit - 224 + 246 0 - Threat Warn Vol 0 + Radar Power OFF - 224 + 246 1 - Threat Warn Vol 1 + Radar Power STBY - 224 + 246 2 - Threat Warn Vol 2 + Radar Power OPR - 224 + 246 3 - Threat Warn Vol 3 + Radar Power EMERG - 224 + 246 4 - Threat Warn Vol 4 + Radar Range 10 - 224 + 246 5 - Threat Warn Vol 5 + Radar Range 20 - 224 + 246 6 - Threat Warn Vol 6 + Radar Range 40 - 224 + 246 7 - Threat Warn Vol 7 + Radar Range 80 - 224 + 246 8 - Threat Warn Vol 8 + Radar Range 160 - 224 + 246 9 - Missile Warn Vol 0 + RADAR ELSCAN 1 - 224 + 246 10 - Missile Warn Vol 1 + RADAR ELSCAN 2 - 224 + 246 11 - Missile Warn Vol 2 + RADAR ELSCAN 4 - 224 + 246 12 - Missile Warn Vol 3 + RADAR ELSCAN 6 - 224 + 246 13 - Missile Warn Vol 4 + RADAR ELSCAN 8 - 224 + 246 14 - Missile Warn Vol 5 + RADAR AZSCAN 20 - 224 + 246 15 - Missile Warn Vol 6 + RADAR AZSCAN 60 - 224 + 246 16 - Missile Warn Vol 7 + RADAR AZSCAN 120 - 224 + 246 17 - Missile Warn Vol 8 + Radar Frame LEFT0 - 224 + 246 18 - Intercom Vol 0 + Radar Frame LEFT1 - 224 + 246 19 - Intercom Vol 1 + Radar Frame LEFT2 - 224 + 246 20 - Intercom Vol 2 + Radar Frame LEFT3 - 224 + 246 21 - Intercom Vol 3 + Radar Frame RIGHT0 - 224 + 246 22 - Intercom Vol 4 + Radar Frame RIGHT1 - 224 + 246 23 - Intercom Vol 5 + Radar Frame RIGHT2 - 224 + 246 24 - Intercom Vol 6 + Radar Frame RIGHT3 - 224 + 246 25 - Intercom Vol 7 + Radar BAND NULL - 224 + 246 26 - Intercom Vol 8 + Radar BAND A - 225 + 246 + 27 + Radar BAND B + + + 246 + 28 + Radar BAND C + + + 246 + 29 + Radar CHAN 0 + + + 246 + 30 + Radar CHAN 1 + + + 246 + 31 + Radar CHAN 2 + + + 247 0 - Comm1 Volume 0 + SplMode OFF - 225 + 247 1 - Comm1 Volume 1 + SplMode MANTRK - 225 + 247 2 - Comm1 Volume 2 + SplMode SI - 225 + 247 3 - Comm1 Volume 3 + SplMode FLOOD - 225 + 247 4 - Comm1 Volume 4 + ModeCtrl MAN - 225 + 247 5 - Comm1 Volume 5 + ModeCtrl AUTO - 225 + 247 6 - Comm1 Volume 6 + NctrEnable ON - 225 + 247 7 - Comm1 Volume 7 + NctrEnable OFF - 225 + 247 8 - Comm1 Volume 8 + ModeSel A/A LRS - 225 + 247 9 - Comm2 Volume 0 + ModeSel A/A VS - 225 + 247 10 - Comm2 Volume 1 + ModeSel A/A SRS - 225 + 247 11 - Comm2 Volume 2 + ModeSel A/A PULSE - 225 + 247 12 - Comm2 Volume 3 + ModeSel BCN - 225 + 247 13 - Comm2 Volume 4 + ModeSel A/G DPLR - 225 + 247 14 - Comm2 Volume 5 + ModeSel A/G RNG - 225 + 247 15 - Comm2 Volume 6 - - - 225 - 16 - Comm2 Volume 7 - - - 225 - 17 - Comm2 Volume 8 - - - 225 - 18 - ILS Volumn 0 - - - 225 - 19 - ILS Volumn 1 - - - 225 - 20 - ILS Volumn 2 - - - 225 - 21 - ILS Volumn 3 - - - 225 - 22 - ILS Volumn 4 + ModeSel A/G MAP - 225 - 23 - ILS Volumn 5 - - - 225 - 24 - ILS Volumn 6 - - - 225 - 25 - ILS Volumn 7 - - - 225 - 26 - ILS Volumn 8 - - - 226 + 248 0 - Man Freq 2 Digit 0 + EWR/ICS TRNG - 226 + 248 1 - Man Freq 2 Digit 1 + EWR/ICS COMBAT - 226 + 248 2 - Man Freq 2 Digit 2 + PODS STBY - 226 + 248 3 - Man Freq 2 Digit 3 + PODS XMIT + - 226 + 248 4 - Man Freq 2 Digit 4 + ICS STBY - 226 + 248 5 - Man Freq 2 Digit 5 + ICS AUTO - 226 + 248 6 - Man Freq 2 Digit 6 + ICS MAN - 226 + 248 7 - Man Freq 2 Digit 7 + ICS OFF - 226 + 248 8 - Man Freq 2 Digit 8 + ICS ON - 226 + 248 9 - Man Freq 2 Digit 9 + SET-1 AUTO - 226 + 248 10 - Man Freq 3 Digit 0 + SET-1 MAN - 226 + 248 11 - Man Freq 3 Digit 1 + SET-2 AUTO - 226 + 248 12 - Man Freq 3 Digit 2 + SET-2 MAN - 226 + 248 13 - Man Freq 3 Digit 3 + SET-3 AUTO - 226 + 248 14 - Man Freq 3 Digit 4 + SET-3 MAN - 226 + 248 15 - Man Freq 3 Digit 5 + RWR OFF - 226 + 248 16 - Man Freq 3 Digit 6 + RWR ON - 226 + 248 17 - Man Freq 3 Digit 7 + EWWS OFF - 226 + 248 18 - Man Freq 3 Digit 8 + EWWS ON - 226 + 248 19 - Man Freq 3 Digit 9 + EWWS DEFEAT - 226 + 248 20 - Man Freq 4 Digit 0 + EWWS TONE - 226 + 248 21 - Man Freq 4 Digit 1 + light set-1 - 226 + 248 22 - Man Freq 4 Digit 2 + light set-2 - 226 + 248 23 - Man Freq 4 Digit 3 + light set-3 - 226 + 248 24 - Man Freq 4 Digit 4 + EwWrnLgt PROGRAM - 226 + 248 25 - Man Freq 4 Digit 5 + EwWrnLgt MINIMUM - 226 + 248 26 - Man Freq 4 Digit 6 + EwWrnLgt CHAFF + + + 248 + 27 + EwWrnLgt FLARE + + + 248 + 28 + EwWrnLgt SPRCHAFF + + + 248 + 29 + EwWrnLgt SPRFLARE + + + 248 + 30 + EwWrnLgt wtf1 + + + 248 + 31 + EwWrnLgt wtf2 + + + 249 + 0 + ECM HAF OFF + + + 249 + 1 + ECM HAF STBY + + + 249 + 2 + ECM HAF OPER + + + 249 + 3 + Radio2 Digit 1 + + + 249 + 4 + Radio2 Digit 1 + + + 249 + 5 + Radio2 Digit 1 + + + 249 + 6 + Radio2 Digit 1 + + + 249 + 7 + Radio2 Digit 2 + + + 249 + 8 + Radio2 Digit 2 + + + 249 + 9 + Radio2 Digit 2 + + + 249 + 10 + Radio2 Digit 2 + + + 249 + 11 + Radio2 Digit 2 + + + 249 + 12 + Radio2 Digit 2 + + + 249 + 13 + Radio2 Digit 2 + + + 249 + 14 + Radio2 Digit 2 - 226 - 27 - Man Freq 4 Digit 7 + 249 + 15 + Radio2 Digit 2 - 226 - 28 - Man Freq 4 Digit 8 + 249 + 16 + Radio2 Digit 2 - 226 - 29 - Man Freq 4 Digit 9 + 249 + 17 + Radio2 Digit 3 - 227 - 0 - Man Freq 5 Digit 0 + 249 + 18 + Radio2 Digit 3 - 227 - 1 - Man Freq 5 Digit 1 + 249 + 19 + Radio2 Digit 3 - 227 - 2 - Man Freq 5 Digit 2 + 249 + 20 + Radio2 Digit 3 - 227 - 3 - Man Freq 5 Digit 3 + 249 + 21 + Radio2 Digit 3 - 227 - 4 - Man Freq 5 Digit 4 + 249 + 22 + Radio2 Digit 3 - 227 - 5 - Man Freq 5 Digit 5 + 249 + 23 + Radio2 Digit 3 - 227 - 6 - Man Freq 5 Digit 6 + 249 + 24 + Radio2 Digit 3 - 227 - 7 - Man Freq 5 Digit 7 + 249 + 25 + Radio2 Digit 3 - 227 - 8 - Man Freq 5 Digit 8 + 249 + 26 + Radio2 Digit 3 - 227 - 9 - Man Freq 5 Digit 9 + 250 + 0 + unused - 228 + 251 0 - Unused + JTIDS Mode Off - 228 + 251 1 - FLCS A Test Light + JTIDS Mode Poll - 228 + 251 2 - Unused + JTIDS Mode Norm - 228 + 251 3 - FLCS B Test Light + JTIDS Mode Sil - 228 + 251 4 - Unused + JTIDS Mode Hold - 228 + 251 5 - FLCS C Test Light + JTIDS Voice A - 228 + 251 6 - Unused + JTIDS Voice B - 228 + 251 7 - FLCS D Test Light + JTIDS CipherNorm - 229 - 6 - Flt Control Run Light - - - 229 - 7 - Flt Control Fail Light + 251 + 8 + JTIDS CipherZero - 230 + 252 0 - Pri Light Console 0 + Radio2 Digit 4 - 230 + 252 1 - Pri Light Console 1 + Radio2 Digit 4 - 230 + 252 2 - Pri Light Console 2 + Radio2 Digit 4 - 230 + 252 3 - Pri Light Instruments 0 + Radio2 Digit 4 - 230 + 252 4 - Pri Light Instruments 1 + Radio2 Digit 4 - 230 + 252 5 - Pri Light Instruments 2 + Radio2 Digit 4 - 230 + 252 6 - DED Display 0 + Radio2 Digit 4 - 230 + 252 7 - DED Display 1 + Radio2 Digit 4 - 230 + 252 8 - DED Display 2 + Radio2 Digit 4 - 230 + 252 9 - DED Display 3 + Radio2 Digit 4 - 230 + 252 10 - DED Display 4 + Radio2 Digit 5 - 230 + 252 11 - DED Display 5 + Radio2 Digit 5 - 230 + 252 12 - DED Display 6 + Radio2 Digit 5 - 230 + 252 13 - Flood Light Console 0 + Radio2 Digit 5 - 230 + 252 14 - Flood Light Console 1 + Radio2 Digit 5 - 230 + 252 15 - Flood Light Console 2 + Radio2 Digit 5 - 230 + 252 16 - Flood Light Console 3 + Radio2 Digit 5 - 230 + 252 17 - Flood Light Console 4 + Radio2 Digit 5 - 230 + 252 18 - Flood Light Console 5 + Radio2 Digit 5 - 230 + 252 19 - Flood Light Console 6 + Radio2 Digit 5 - 230 + 252 20 - Flood Light Console 7 + Radio2 mode Guard/Off - 230 + 252 21 - Flood Light Inst 0 + Radio2 mode Man - 230 + 252 22 - Flood Light Inst 1 + Radio2 mode Chan - 230 + 252 23 - Flood Light Inst 2 + Mirrors Off - 231 - 0 - Formation Lights + 252 + 24 + Mirrors On - 232 + 253 0 - Antenna Select 0 + Master Arm 0 - 232 + 253 1 - Antenna Select 1 + Master Arm 1 - 232 + 253 2 - Antenna Select 2 + AG mode - 232 + 253 3 - Seat Position 0 + ADI mode - 232 + 253 4 - Seat Position 1 + VI mode + + + - 232 + 253 5 - Seat Position 2 + Flare Switch - 232 + 253 6 - Engine Anti Ice 0 + Both Switch - 232 + 253 7 - Engine Anti Ice 1 + Chaff Switch - 232 + 253 8 - Engine Anti Ice 2 + F15 CMDS Mode Off - 232 + 253 9 - Parkinig Brake 0 + F15 CMDS Mode Stby - 232 + 253 10 - Parkinig Brake 1 + F15 CMDS Mode Man Only - 232 + 253 11 - Parkinig Brake 2 + F15 CMDS Mode Semi Auto - 232 + 253 12 - Landing Light 0 + F15 CMDS Mode Auto - 232 + 253 13 - Landing Light 1 + F15 Flare Jett Cover closed - 232 + 253 14 - Landing Light 2 + F15 Flare Jett Cover open - 232 + 253 15 - Canopy 0 + F15 Flare Jettison - 232 + 253 16 - Canopy 1 + MCC AI Light - 232 + 253 17 - Canopy 2 + MCC SAM Light - 232 + 253 18 - JFS Start 0 - Down - - - 232 - 19 - JFS Start 1 - Middle - - - 232 - 20 - JFS Start 2 - Up - - - 232 - 21 - Wing Tail Light 0 - Down - - - 232 - 22 - Wing Tail Light 1 - Middle - - - 232 - 23 - Wing Tail Light 2 - Up + F15 Shoot Lights - 233 + 254 0 - Cockpit knee pad 0 + Gen toggle left off - 233 + 254 1 - Cockpit knee pad 1 + Gen toggle left on - 233 + 254 2 - Cockpit knee pad 2 + Gen toggle right off - 233 + 254 3 - Cockpit knee pad 3 + Gen toggle right on - 233 + 254 4 - Cockpit knee pad 4 + Eec toggle left off - 233 + 254 5 - Cockpit knee pad 5 + Eec toggle left on - 233 + 254 6 - Cockpit knee pad 6 + Eec toggle right off - 233 + 254 7 - Cockpit knee pad 7 + Eec toggle right on - 233 + 254 8 - Cockpit knee pad 8 + LeftMasterEng OffOpen - 233 + 254 9 - Cockpit knee pad 9 + LeftMasterEng OnOpen - 233 + 254 10 - Cockpit knee pad 10 + LeftMasterEng OnClosed - 233 + 254 11 - Cockpit knee pad 11 + RightMasterEng OffOpen - 233 + 254 12 - Cockpit knee pad 12 + RightMasterEng OnOpen - 233 + 254 13 - Cockpit knee pad 13 + RightMasterEng OnClosed - 233 + 254 14 - Cockpit knee pad 14 + Starter off - 233 + 254 15 - Cockpit knee pad 15 + Starter on - 233 + 254 16 - Cockpit knee pad 0 + MFD bright OFF - 233 + 254 17 - Cockpit knee pad 1 + MPCD bright NIGHT - 233 + 254 18 - Cockpit knee pad 2 + MPCD bright DAY - 233 + 254 19 - Cockpit knee pad 3 + VSD bright OFF - 233 + 254 20 - Cockpit knee pad 4 + VSD bright CONT + - 233 + 254 21 - Cockpit knee pad 5 + NCI DATA CCC - 233 + 254 22 - Cockpit knee pad 6 + NCI DATA WIND - 233 + 254 23 - Cockpit knee pad 7 + NCI DATA VIS - 233 + 254 24 - Cockpit knee pad 8 + NCI DATA PP - 233 + 254 25 - Cockpit knee pad 9 + NCI DATA DEST - 233 + 254 26 - Cockpit knee pad 10 + NCI DATA O/S - 233 + 254 27 - Cockpit knee pad 11 + SELECT DATA CCC - 233 + 254 28 - Cockpit knee pad 12 + SELECT DATA OFF - 233 + 254 29 - Cockpit knee pad 13 + SELECT DATA GC - 233 + 254 30 - Cockpit knee pad 14 + SELECT DATA INS - 233 + 254 31 - Cockpit knee pad 15 - - - 234 - 0 - Anti Collision Mode 0 - - - 234 - 1 - Anti Collision Mode 1 - - - 234 - 2 - Anti Collision Mode 2 - - - 234 - 3 - Anti Collision Mode 3 - - - 234 - 4 - Anti Collision Mode 4 - - - 234 - 5 - Anti Collision Mode 5 - - - 234 - 6 - Anti Collision Mode 6 - - - 235 - 0 - Caution Light Dbu On - - - 236 - 0 - Backup Radio Digit Display - - - 237 - 0 - Caution ATF not engaged - - - 238 - 0 - Cockpit Legs - - - 239 - 0 - Caution Inlet Icing - - - 240 - 0 - Caution Inlet Icing + SELECT DATA TCN \ No newline at end of file diff --git a/bms_blender_plugin/ui_tools/operators/__init__.py b/bms_blender_plugin/ui_tools/operators/__init__.py index 3310a48..1f93868 100644 --- a/bms_blender_plugin/ui_tools/operators/__init__.py +++ b/bms_blender_plugin/ui_tools/operators/__init__.py @@ -189,7 +189,7 @@ def register_blender_properties(): 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 + name="ON by default", description="This 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( diff --git a/bms_blender_plugin/ui_tools/panels/dof_panel.py b/bms_blender_plugin/ui_tools/panels/dof_panel.py index ab6a34f..17619b4 100644 --- a/bms_blender_plugin/ui_tools/panels/dof_panel.py +++ b/bms_blender_plugin/ui_tools/panels/dof_panel.py @@ -101,7 +101,7 @@ def draw(self, context): row = layout.row() # Persistent ID box shown first box_ids = layout.box() - box_ids.label(text="Persistent ID for Export") + box_ids.label(text="Persistent DOF Properties:") box_ids.prop(dof, "bml_dof_number") dof_num = getattr(dof, "bml_dof_number", -1) if dof_num < 0: diff --git a/bms_blender_plugin/ui_tools/panels/switch_panel.py b/bms_blender_plugin/ui_tools/panels/switch_panel.py index 1018f25..ea0a830 100644 --- a/bms_blender_plugin/ui_tools/panels/switch_panel.py +++ b/bms_blender_plugin/ui_tools/panels/switch_panel.py @@ -114,7 +114,7 @@ def draw(self, context): layout.label(text=comment) box = layout.box() - box.label(text="Persistent IDs for Export") + box.label(text="Persistent Switch Properties:") row_ids = box.row(align=True) row_ids.prop(switch, "bml_switch_number") row_ids.prop(switch, "bml_switch_branch") @@ -139,7 +139,7 @@ def draw(self, context): if not found: box.label(text="Warning: IDs not found in switch.xml (still exported)", icon="INFO") - layout.prop(switch, "switch_default_on") + box.prop(switch, "switch_default_on") def register(): From ee40f9e2fdd199335c0a2190ef129a0f44eab581 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 00:28:25 +0000 Subject: [PATCH 16/25] Fix 16-bit index buffer boundary condition Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/b5aa89eb-de48-44d8-bbdc-0d1050470e9c Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/exporter/export_lods.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 5b851b8..8a09f6e 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -276,7 +276,7 @@ def _recursively_parse_nodes(objects): material_count = len(material_names) with export_profiler.stage("nodes: pack index buffer") if export_profiler else nullcontext(): # FORMAT_16 uses unsigned 16-bit indices, so it is valid while the largest vertex index fits in 0..65535. - if current_vertices_index <= 65536: + if current_vertices_index < 65536: index_buffer_format = IndexBufferFormat.FORMAT_16 vertex_indices_data = struct.pack("%sH" % len(vertex_indices), *vertex_indices) vertex_indices_data_size = 2 * len(vertex_indices) From 19d456e293255741d0bd991cf96b3b1ba2b5a03d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 00:29:05 +0000 Subject: [PATCH 17/25] Clarify 16-bit index threshold comment Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/b5aa89eb-de48-44d8-bbdc-0d1050470e9c Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/exporter/export_lods.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index 8a09f6e..fdddcf1 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -275,7 +275,8 @@ def _recursively_parse_nodes(objects): material_count = len(material_names) with export_profiler.stage("nodes: pack index buffer") if export_profiler else nullcontext(): - # FORMAT_16 uses unsigned 16-bit indices, so it is valid while the largest vertex index fits in 0..65535. + # FORMAT_16 uses unsigned 16-bit indices (0..65535); current_vertices_index is the next index (vertex count), + # so FORMAT_16 is valid while that next index is still below 65536. if current_vertices_index < 65536: index_buffer_format = IndexBufferFormat.FORMAT_16 vertex_indices_data = struct.pack("%sH" % len(vertex_indices), *vertex_indices) From 4ca2913cb851a026b069d1c554be9f56470f85aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 01:02:11 +0000 Subject: [PATCH 18/25] perf: batch modifier application to O(1) depsgraph flushes Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/4e1f88c8-890d-4861-b4d8-665b99a70076 Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 82 ++++++++++++---------- bms_blender_plugin/exporter/export_lods.py | 9 ++- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index fbb6dbf..6c25ac3 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -473,49 +473,59 @@ def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): def apply_all_modifiers(collection, export_profiler=None): - """Applies all modifiers to objects which are rooted in the given collection""" - for obj in collection.objects: - if obj.parent is None: - apply_all_modifiers_on_obj(obj, export_profiler) + """Applies all modifiers and transforms to every object in the collection. - -def apply_all_modifiers_on_obj(obj, export_profiler=None): - """Applies all modifiers to a single object. - Empties (DOFs, Slots and Switches) are excepted, since applying their modifiers would reset their positions. + After copy_collection_flat all objects (including children) reside in the same + flat collection, so we can batch the two expensive bpy.ops calls instead of + issuing a select/deselect + operator call per object, which forces a full + depsgraph evaluation each time. """ - if obj: - with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): - bpy.ops.object.select_all(action="DESELECT") - # apply the modifiers - obj.select_set(True) - bpy.context.view_layer.objects.active = obj + all_objs = list(collection.objects) - if obj.type == "MESH": - bpy.ops.object.mode_set(mode="OBJECT") - bpy.ops.object.convert(target="MESH", keep_original=False) + with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): + # --- batch convert (applies modifiers) for all mesh objects at once --- + mesh_objs = [obj for obj in all_objs if obj.type == "MESH"] + bpy.ops.object.select_all(action="DESELECT") + for obj in mesh_objs: + obj.select_set(True) + if mesh_objs: + bpy.context.view_layer.objects.active = mesh_objs[0] + 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 + # Store reference points after convert (modifiers resolved) but before + # transform_apply zeroes the location. + for obj in all_objs: 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 + get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): obj["bms_reference_point"] = tuple(obj.location) - # Apply transforms using original logic (restored) - if get_bml_type(obj) not in [ - BlenderNodeType.DOF, - BlenderNodeType.SLOT, - BlenderNodeType.HOTSPOT, - ]: - bpy.ops.object.transform_apply() - else: - # only apply scaling operations to those objects - # all other operations would reset them since they are empties - bpy.ops.object.transform_apply( - location=False, rotation=False, scale=True, properties=False - ) - - for child in obj.children: - apply_all_modifiers_on_obj(child, export_profiler) + # --- batch transform_apply for regular objects (full: loc + rot + scale) --- + non_special_objs = [ + obj for obj in all_objs + if get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT] + ] + bpy.ops.object.select_all(action="DESELECT") + for obj in non_special_objs: + obj.select_set(True) + if non_special_objs: + bpy.context.view_layer.objects.active = non_special_objs[0] + bpy.ops.object.transform_apply() + + # --- batch transform_apply (scale only) for DOF/Slot/Hotspot empties --- + # Applying loc/rot to empties would reset their pivot positions. + special_objs = [ + obj for obj in all_objs + if get_bml_type(obj) in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT] + ] + bpy.ops.object.select_all(action="DESELECT") + for obj in special_objs: + obj.select_set(True) + if special_objs: + bpy.context.view_layer.objects.active = special_objs[0] + bpy.ops.object.transform_apply( + location=False, rotation=False, scale=True, properties=False + ) def uncompress_file(src, dest): diff --git a/bms_blender_plugin/exporter/export_lods.py b/bms_blender_plugin/exporter/export_lods.py index fdddcf1..36d05e7 100644 --- a/bms_blender_plugin/exporter/export_lods.py +++ b/bms_blender_plugin/exporter/export_lods.py @@ -1,8 +1,11 @@ """ 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 +- Modifier application was the dominant cost (~92% of export time): per-object + bpy.ops calls each trigger a full depsgraph evaluation. apply_all_modifiers() + now batches convert + transform_apply into O(1) operator calls regardless of + scene size. +- Material batching gives ~10-12% improvement in DOF/switch-heavy scenes. +- Further perf improvements: batch DOF processing, reduce object selection calls. """ import os From 480a78e339626091556872e04b6e28f874946741 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 01:24:42 +0000 Subject: [PATCH 19/25] fix: preserve hierarchy transforms during export apply step Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/cda83d56-97ab-4098-a5e9-50638cdd76ba Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 54 +++++++++++++++++-------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index 6c25ac3..bbde65b 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -500,32 +500,36 @@ def apply_all_modifiers(collection, export_profiler=None): get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): obj["bms_reference_point"] = tuple(obj.location) - # --- batch transform_apply for regular objects (full: loc + rot + scale) --- - non_special_objs = [ - obj for obj in all_objs - if get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT] - ] - bpy.ops.object.select_all(action="DESELECT") - for obj in non_special_objs: - obj.select_set(True) - if non_special_objs: - bpy.context.view_layer.objects.active = non_special_objs[0] - bpy.ops.object.transform_apply() - - # --- batch transform_apply (scale only) for DOF/Slot/Hotspot empties --- - # Applying loc/rot to empties would reset their pivot positions. - special_objs = [ - obj for obj in all_objs - if get_bml_type(obj) in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT] - ] - bpy.ops.object.select_all(action="DESELECT") - for obj in special_objs: + # --- apply transforms one object at a time, parent before children --- + # Batched transform_apply over parent/child selections can corrupt relative + # transforms in hierarchies (mixed rotations/scales after export). + def _apply_transforms_recursively(obj): + if not obj: + return + + bpy.ops.object.select_all(action="DESELECT") obj.select_set(True) - if special_objs: - bpy.context.view_layer.objects.active = special_objs[0] - bpy.ops.object.transform_apply( - location=False, rotation=False, scale=True, properties=False - ) + bpy.context.view_layer.objects.active = obj + + if get_bml_type(obj) not in [ + BlenderNodeType.DOF, + BlenderNodeType.SLOT, + BlenderNodeType.HOTSPOT, + ]: + bpy.ops.object.transform_apply() + else: + # only apply scaling operations to those objects + # all other operations would reset them since they are empties + bpy.ops.object.transform_apply( + location=False, rotation=False, scale=True, properties=False + ) + + for child in obj.children: + _apply_transforms_recursively(child) + + root_objs = [obj for obj in all_objs if obj.parent is None] + for root_obj in root_objs: + _apply_transforms_recursively(root_obj) def uncompress_file(src, dest): From 7065e210d2978dce50f8bb13c9b7456301204b14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 01:25:28 +0000 Subject: [PATCH 20/25] chore: address review naming feedback in transform helper Agent-Logs-Url: https://github.com/avan069/bms-blender-plugin/sessions/cda83d56-97ab-4098-a5e9-50638cdd76ba Co-authored-by: avan069 <14366399+avan069@users.noreply.github.com> --- bms_blender_plugin/common/util.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index bbde65b..6583c74 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -503,7 +503,7 @@ def apply_all_modifiers(collection, export_profiler=None): # --- apply transforms one object at a time, parent before children --- # Batched transform_apply over parent/child selections can corrupt relative # transforms in hierarchies (mixed rotations/scales after export). - def _apply_transforms_recursively(obj): + def apply_transforms_recursively(obj): if not obj: return @@ -525,11 +525,11 @@ def _apply_transforms_recursively(obj): ) for child in obj.children: - _apply_transforms_recursively(child) + apply_transforms_recursively(child) root_objs = [obj for obj in all_objs if obj.parent is None] for root_obj in root_objs: - _apply_transforms_recursively(root_obj) + apply_transforms_recursively(root_obj) def uncompress_file(src, dest): From 661e5b7ca26c69bc6a6831274362144d9417b9f7 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 10 May 2026 22:09:42 -0400 Subject: [PATCH 21/25] perf: optimize modifier and transform application by hierarchy depth --- bms_blender_plugin/common/util.py | 167 ++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 45 deletions(-) diff --git a/bms_blender_plugin/common/util.py b/bms_blender_plugin/common/util.py index 6583c74..f61e3d8 100644 --- a/bms_blender_plugin/common/util.py +++ b/bms_blender_plugin/common/util.py @@ -475,61 +475,138 @@ def copy_object(obj, parent, collection, scale_factor=1, export_profiler=None): def apply_all_modifiers(collection, export_profiler=None): """Applies all modifiers and transforms to every object in the collection. - After copy_collection_flat all objects (including children) reside in the same - flat collection, so we can batch the two expensive bpy.ops calls instead of - issuing a select/deselect + operator call per object, which forces a full - depsgraph evaluation each time. + After copy_collection_flat all objects (including children) reside in a flat + collection, but parent-child transform order still matters. We batch modifier + conversion globally, then batch transform application by hierarchy depth so a + selected batch never contains both a parent and one of its descendants. """ all_objs = list(collection.objects) + special_types = (BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT) + transform_epsilon = 1e-7 + + def _stage(stage_name): + return export_profiler.stage(stage_name) if export_profiler else nullcontext() + + def _vector_nearly_equal(vector, expected): + return all(abs(vector[index] - expected[index]) <= transform_epsilon for index in range(3)) + + def _matrix_nearly_identity(matrix): + for row_index in range(4): + for column_index in range(4): + expected = 1.0 if row_index == column_index else 0.0 + if abs(matrix[row_index][column_index] - expected) > transform_epsilon: + return False + return True + + def _needs_full_transform_apply(obj): + return not _matrix_nearly_identity(obj.matrix_basis) + + def _needs_scale_transform_apply(obj): + return not ( + _vector_nearly_equal(obj.scale, (1.0, 1.0, 1.0)) + and _vector_nearly_equal(obj.delta_scale, (1.0, 1.0, 1.0)) + ) - with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): - # --- batch convert (applies modifiers) for all mesh objects at once --- - mesh_objs = [obj for obj in all_objs if obj.type == "MESH"] + def _hierarchy_levels(objects): + object_names = {obj.name for obj in objects} + levels = [] + visited = set() + + def add_obj(obj, depth): + if obj.name in visited or obj.name not in object_names: + return + visited.add(obj.name) + while len(levels) <= depth: + levels.append([]) + levels[depth].append(obj) + for child in obj.children: + add_obj(child, depth + 1) + + for obj in objects: + if obj.parent is None or obj.parent.name not in object_names: + add_obj(obj, 0) + + for obj in objects: + add_obj(obj, 0) + + return levels + + def _batch_transform_apply(objects, **kwargs): + if not objects: + return bpy.ops.object.select_all(action="DESELECT") - for obj in mesh_objs: + for obj in objects: obj.select_set(True) - if mesh_objs: - bpy.context.view_layer.objects.active = mesh_objs[0] - bpy.ops.object.mode_set(mode="OBJECT") - bpy.ops.object.convert(target="MESH", keep_original=False) + bpy.context.view_layer.objects.active = objects[0] + bpy.ops.object.transform_apply(**kwargs) + + with export_profiler.stage("modifier application: apply modifiers") if export_profiler else nullcontext(): + with _stage("modifier application: batch mesh convert"): + mesh_objs = [obj for obj in all_objs if obj.type == "MESH"] + bpy.ops.object.select_all(action="DESELECT") + for obj in mesh_objs: + obj.select_set(True) + if mesh_objs: + bpy.context.view_layer.objects.active = mesh_objs[0] + bpy.ops.object.mode_set(mode="OBJECT") + bpy.ops.object.convert(target="MESH", keep_original=False) # Store reference points after convert (modifiers resolved) but before # transform_apply zeroes the location. - for obj in all_objs: - if (obj.type == "MESH" and - get_bml_type(obj) not in [BlenderNodeType.DOF, BlenderNodeType.SLOT, BlenderNodeType.HOTSPOT]): - obj["bms_reference_point"] = tuple(obj.location) - - # --- apply transforms one object at a time, parent before children --- - # Batched transform_apply over parent/child selections can corrupt relative - # transforms in hierarchies (mixed rotations/scales after export). - def apply_transforms_recursively(obj): - if not obj: - return - - bpy.ops.object.select_all(action="DESELECT") - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - - if get_bml_type(obj) not in [ - BlenderNodeType.DOF, - BlenderNodeType.SLOT, - BlenderNodeType.HOTSPOT, - ]: - bpy.ops.object.transform_apply() - else: - # only apply scaling operations to those objects - # all other operations would reset them since they are empties - bpy.ops.object.transform_apply( - location=False, rotation=False, scale=True, properties=False - ) + with _stage("modifier application: store reference points"): + for obj in all_objs: + if obj.type == "MESH" and get_bml_type(obj) not in special_types: + obj["bms_reference_point"] = tuple(obj.location) + + regular_applied = 0 + regular_skipped = 0 + regular_batches = 0 + special_applied = 0 + special_skipped = 0 + special_batches = 0 + + for level_objs in _hierarchy_levels(all_objs): + regular_objs = [] + special_objs = [] + + for obj in level_objs: + if get_bml_type(obj) in special_types: + if _needs_scale_transform_apply(obj): + special_objs.append(obj) + else: + special_skipped += 1 + elif _needs_full_transform_apply(obj): + regular_objs.append(obj) + else: + regular_skipped += 1 + + if regular_objs: + with _stage("modifier application: batch regular transforms"): + _batch_transform_apply(regular_objs) + regular_applied += len(regular_objs) + regular_batches += 1 + + if special_objs: + with _stage("modifier application: batch special scale transforms"): + _batch_transform_apply( + special_objs, + location=False, + rotation=False, + scale=True, + properties=False, + ) + special_applied += len(special_objs) + special_batches += 1 - for child in obj.children: - apply_transforms_recursively(child) + if regular_objs or special_objs: + bpy.context.view_layer.update() - root_objs = [obj for obj in all_objs if obj.parent is None] - for root_obj in root_objs: - apply_transforms_recursively(root_obj) + print( + "[BML Export] Transform apply batches: " + f"{regular_batches} regular / {special_batches} special; " + f"objects applied: {regular_applied} regular / {special_applied} special; " + f"skipped: {regular_skipped} regular / {special_skipped} special" + ) def uncompress_file(src, dest): From fc1bbe34e061646ac316a6d1387d44f312e51962 Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 10 May 2026 22:31:02 -0400 Subject: [PATCH 22/25] workflow, fork plugin metadata change --- .github/workflows/release-van.yml | 45 +++++++++++++++++++++++++++++++ bms_blender_plugin/__init__.py | 8 +++--- 2 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-van.yml diff --git a/.github/workflows/release-van.yml b/.github/workflows/release-van.yml new file mode 100644 index 0000000..cb53632 --- /dev/null +++ b/.github/workflows/release-van.yml @@ -0,0 +1,45 @@ +name: Van +on: + push: + tags: + - van/v*.*.* + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install ruff + run: pip install ruff + - name: Lint with ruff + run: ruff check . + + release-van: + needs: lint + name: Create Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Build and zip folder + run: | + release_tag="${GITHUB_REF#refs/tags/}" + tag_version="${release_tag#van/}" + version_tuple=$(echo "$tag_version" | awk -F'[v.]' '{print $2", "$3", "$4}') + sed -i "s/\"version\": .*/\"version\": (${version_tuple}),/" bms_blender_plugin/__init__.py + zip -r "bms_blender_plugin-van-${tag_version}.zip" bms_blender_plugin + echo "Zip file built" + echo "RELEASE_TAG=$release_tag" >> $GITHUB_ENV + echo "TAG_VERSION=$tag_version" >> $GITHUB_ENV + - name: Release + uses: softprops/action-gh-release@v1 + with: + tag_name: "${{ env.RELEASE_TAG }}" + name: "Van ${{ env.TAG_VERSION }}" + files: | + bms_blender_plugin*.zip diff --git a/bms_blender_plugin/__init__.py b/bms_blender_plugin/__init__.py index c82f414..6bd6a34 100644 --- a/bms_blender_plugin/__init__.py +++ b/bms_blender_plugin/__init__.py @@ -6,15 +6,15 @@ from bms_blender_plugin.ext.blender_dds_addon.directx.texconv import unload_texconv bl_info = { - "name": "Falcon BMS Plugin", + "name": "Falcon BMS Plugin - Van", "author": "Benchmark Sims", - "version": (0, 0, 20250118), + "version": (1, 1, 1), "blender": (3, 6, 0), "location": "File > Export", "description": "Export as Falcon BMS BML", "warning": "", - "doc_url": "https://github.com/BenchmarkSims/bms-blender-plugin", - "tracker_url": "https://github.com/BenchmarkSims/bms-blender-plugin/issues", + "doc_url": "https://github.com/avan069/bms-blender-plugin", + "tracker_url": "https://github.com/avan069/bms-blender-plugin/issues", "support": "COMMUNITY", "category": "Import-Export", } From 4f40995c7fada6d16b7a5ea48305624b3982e32b Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 10 May 2026 22:38:47 -0400 Subject: [PATCH 23/25] workflow revision --- .github/workflows/release-van.yml | 11 ++++++++--- bms_blender_plugin/exporter/parser.py | 3 +-- bms_blender_plugin/exporter/validation_dialogs.py | 3 +-- .../nodes_editor/dof_nodes/dof_input_node.py | 1 - 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-van.yml b/.github/workflows/release-van.yml index cb53632..cdf6d3d 100644 --- a/.github/workflows/release-van.yml +++ b/.github/workflows/release-van.yml @@ -32,14 +32,19 @@ jobs: tag_version="${release_tag#van/}" version_tuple=$(echo "$tag_version" | awk -F'[v.]' '{print $2", "$3", "$4}') sed -i "s/\"version\": .*/\"version\": (${version_tuple}),/" bms_blender_plugin/__init__.py - zip -r "bms_blender_plugin-van-${tag_version}.zip" bms_blender_plugin + zip_file="bms_blender_plugin-van-${tag_version}.zip" + zip -r "$zip_file" bms_blender_plugin \ + -x "*/__pycache__/*" \ + -x "*.pyc" \ + -x "*.pyo" echo "Zip file built" echo "RELEASE_TAG=$release_tag" >> $GITHUB_ENV echo "TAG_VERSION=$tag_version" >> $GITHUB_ENV + echo "ZIP_FILE=$zip_file" >> $GITHUB_ENV - name: Release uses: softprops/action-gh-release@v1 with: tag_name: "${{ env.RELEASE_TAG }}" name: "Van ${{ env.TAG_VERSION }}" - files: | - bms_blender_plugin*.zip + body: "Install the uploaded bms_blender_plugin zip asset." + files: "${{ env.ZIP_FILE }}" diff --git a/bms_blender_plugin/exporter/parser.py b/bms_blender_plugin/exporter/parser.py index 17a262f..b98edb9 100644 --- a/bms_blender_plugin/exporter/parser.py +++ b/bms_blender_plugin/exporter/parser.py @@ -7,8 +7,7 @@ from bms_blender_plugin.common.bml_structs import Primitive, PrimitiveTopology, Vector3, Slot, D3DMatrix, Switch, \ DofType, Dof from bms_blender_plugin.common.hotspot import Hotspot, MouseButton, ButtonType -from bms_blender_plugin.common.util import get_bml_type, get_switches, get_dofs, \ - get_non_translate_dof_parent +from bms_blender_plugin.common.util import get_bml_type, 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 diff --git a/bms_blender_plugin/exporter/validation_dialogs.py b/bms_blender_plugin/exporter/validation_dialogs.py index fe9f0af..68d8f21 100644 --- a/bms_blender_plugin/exporter/validation_dialogs.py +++ b/bms_blender_plugin/exporter/validation_dialogs.py @@ -9,7 +9,6 @@ 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, @@ -246,4 +245,4 @@ def register(): def unregister(): bpy.utils.unregister_class(BML_OT_ValidationMissingIDDialog) - bpy.utils.unregister_class(BML_OT_ValidationOutOfRangeDialog) \ No newline at end of file + bpy.utils.unregister_class(BML_OT_ValidationOutOfRangeDialog) 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 57a3eb4..743da91 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 @@ -3,7 +3,6 @@ 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, From 5b1713cf4b17aef64561fe5ec632b07dc36e584b Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Sun, 10 May 2026 23:33:39 -0400 Subject: [PATCH 24/25] Increase UI panel BMS coords precision, add default switch helper size --- bms_blender_plugin/preferences.py | 48 +++++++++++++++++++ .../ui_tools/operators/create_switch.py | 6 +++ .../ui_tools/panels/tools_panel.py | 6 +-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/bms_blender_plugin/preferences.py b/bms_blender_plugin/preferences.py index 1474afe..215ebf3 100644 --- a/bms_blender_plugin/preferences.py +++ b/bms_blender_plugin/preferences.py @@ -172,6 +172,20 @@ class ExporterPreferences(bpy.types.AddonPreferences): description="The size of the Empty to display a Scale DOF as" ) + switch_empty_type: EnumProperty( + name="Switch", + description="The Empty to display a Switch as", + items=empty_enum_items, + default="PLAIN_AXES", + ) + + switch_empty_size: FloatProperty( + default=1.0, + min=0.01, + name="Size", + description="The size of the Empty to display a Switch as" + ) + def draw(self, context): layout = self.layout @@ -197,6 +211,15 @@ def draw(self, context): box.operator(ApplyEmptyDisplaysToDofs.bl_idname, icon="CHECKMARK") + layout.separator() + layout.label(text="Switch Display") + box = layout.box() + row = box.row() + row.prop(self, "switch_empty_type") + row.prop(self, "switch_empty_size") + + box.operator(ApplyEmptyDisplaysToSwitches.bl_idname, icon="CHECKMARK") + layout.separator() layout.label(text="Data Management") box = layout.box() @@ -256,16 +279,41 @@ def execute(self, context): return {"FINISHED"} +class ApplyEmptyDisplaysToSwitches(Operator): + """Applies the preferences for the Switch empties to all objects in the scene""" + bl_idname = "bml.apply_empty_displays_to_switches" + bl_label = "Apply to all Switches" + bl_description = "Applies the display preferences to all Switches in the scene" + + # noinspection PyMethodMayBeStatic + def execute(self, context): + switch_empty = context.preferences.addons[ + "bms_blender_plugin" + ].preferences.switch_empty_type + switch_empty_size = context.preferences.addons[ + "bms_blender_plugin" + ].preferences.switch_empty_size + + for obj in bpy.data.objects: + if get_bml_type(obj) == BlenderNodeType.SWITCH: + obj.empty_display_type = switch_empty + obj.empty_display_size = switch_empty_size + + return {"FINISHED"} + + 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(ApplyEmptyDisplaysToSwitches) bpy.utils.register_class(ExporterPreferences) def unregister(): bpy.utils.unregister_class(ExporterPreferences) + bpy.utils.unregister_class(ApplyEmptyDisplaysToSwitches) bpy.utils.unregister_class(ApplyEmptyDisplaysToDofs) bpy.utils.unregister_class(ReloadCallbackList) bpy.utils.unregister_class(ReloadSwitchList) diff --git a/bms_blender_plugin/ui_tools/operators/create_switch.py b/bms_blender_plugin/ui_tools/operators/create_switch.py index c77d4f6..614194c 100644 --- a/bms_blender_plugin/ui_tools/operators/create_switch.py +++ b/bms_blender_plugin/ui_tools/operators/create_switch.py @@ -28,6 +28,12 @@ def execute(self, context): f"Switch - {switch.name} ({switch.switch_number})", None ) switch_object.bml_type = str(BlenderNodeType.SWITCH) + switch_object.empty_display_type = context.preferences.addons[ + "bms_blender_plugin" + ].preferences.switch_empty_type + switch_object.empty_display_size = context.preferences.addons[ + "bms_blender_plugin" + ].preferences.switch_empty_size if context.active_object: # assumes that every object is linked to at least one collection diff --git a/bms_blender_plugin/ui_tools/panels/tools_panel.py b/bms_blender_plugin/ui_tools/panels/tools_panel.py index 2e9d6c2..f1a1949 100644 --- a/bms_blender_plugin/ui_tools/panels/tools_panel.py +++ b/bms_blender_plugin/ui_tools/panels/tools_panel.py @@ -26,9 +26,9 @@ def draw(self, context): obj_bms_coords = to_bms_coords( context.active_object.matrix_world.translation ) - x_coord_text = f"{round(obj_bms_coords.x * scale_factor, 2): .2f}" - y_coord_text = f"{round(obj_bms_coords.y * scale_factor, 2): .2f}" - z_coord_text = f"{round(obj_bms_coords.z * scale_factor, 2): .2f}" + x_coord_text = f"{obj_bms_coords.x * scale_factor: .6f}" + y_coord_text = f"{obj_bms_coords.y * scale_factor: .6f}" + z_coord_text = f"{obj_bms_coords.z * scale_factor: .6f}" layout.label(text="BMS Coordinates") box = layout.box() From 2665e55559477a40381ba5c04f55f523c7c60e2b Mon Sep 17 00:00:00 2001 From: avan069 <14366399+avan069@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:11:40 -0400 Subject: [PATCH 25/25] Return init naming/URLs to BMS original, step version --- bms_blender_plugin/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bms_blender_plugin/__init__.py b/bms_blender_plugin/__init__.py index 6bd6a34..6fe319d 100644 --- a/bms_blender_plugin/__init__.py +++ b/bms_blender_plugin/__init__.py @@ -6,15 +6,15 @@ from bms_blender_plugin.ext.blender_dds_addon.directx.texconv import unload_texconv bl_info = { - "name": "Falcon BMS Plugin - Van", + "name": "Falcon BMS Plugin", "author": "Benchmark Sims", - "version": (1, 1, 1), + "version": (1, 2, 0), "blender": (3, 6, 0), "location": "File > Export", "description": "Export as Falcon BMS BML", "warning": "", - "doc_url": "https://github.com/avan069/bms-blender-plugin", - "tracker_url": "https://github.com/avan069/bms-blender-plugin/issues", + "doc_url": "https://github.com/BenchmarkSims/bms-blender-plugin", + "tracker_url": "https://github.com/BenchmarkSims/bms-blender-plugin/issues", "support": "COMMUNITY", "category": "Import-Export", }