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/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 new file mode 100644 index 0000000..b8d942f --- /dev/null +++ b/bms_blender_plugin/common/resolve_ids.py @@ -0,0 +1,92 @@ +"""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 + + # 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 + + # 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 + print("DOF Resolution: Fallback to global DOF list for index", idx) + 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 + + # 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): + 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/common/util.py b/bms_blender_plugin/common/util.py index b2201ea..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""" @@ -273,6 +393,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 +489,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/__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_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/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_lods.py b/bms_blender_plugin/exporter/export_lods.py index 781ed96..c500a63 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,93 @@ 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 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: + 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) + # 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] diff --git a/bms_blender_plugin/exporter/export_parent_dat.py b/bms_blender_plugin/exporter/export_parent_dat.py index 752ea9c..fd9d5d1 100644 --- a/bms_blender_plugin/exporter/export_parent_dat.py +++ b/bms_blender_plugin/exporter/export_parent_dat.py @@ -8,6 +8,11 @@ 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, +) from bms_blender_plugin.common.coordinates import to_bms_coords @@ -16,34 +21,42 @@ 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.") + 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 + 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 = 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 + 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/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 new file mode 100644 index 0000000..299c754 --- /dev/null +++ b/bms_blender_plugin/exporter/export_validation.py @@ -0,0 +1,360 @@ +""" +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 +from bms_blender_plugin.common.resolve_ids import resolve_dof_number, resolve_switch_id + + +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) + + # 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 resolved_dof_number is None: + # Cannot be resolved by any means - truly unresolvable + out_of_range_objects.append(obj) + else: + # 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) 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, + 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) + + # 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 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: + # 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) 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, + 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/parser.py b/bms_blender_plugin/exporter/parser.py index 4ae27bc..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 @@ -54,11 +55,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 +127,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, @@ -171,21 +184,40 @@ 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. + + Resolution order delegated to resolve_switch_id(). If unresolved defaults to (0,0). + """ print(f"{obj.name} is a SWITCH") - switch = get_switches()[obj.switch_list_index] - nodes.append( - Switch(len(nodes), switch.switch_number, switch.branch, obj.switch_default_on) - ) + 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""" - print(f"{obj.name} is a DOF") - # add the DOF start node + """Adds a BML DOF to the BML node list. - dof = get_dofs()[obj.dof_list_index] + Resolution order delegated to resolve_dof_number(); default 0 if unresolved. + """ + print(f"{obj.name} is a DOF") + 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 new file mode 100644 index 0000000..fe9f0af --- /dev/null +++ b/bms_blender_plugin/exporter/validation_dialogs.py @@ -0,0 +1,249 @@ +""" +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. + + Updated: Inline confirmation (no secondary pop-up) and clearer, action-focused labels. + """ + + bl_idname = "bml.validation_missing_id_dialog" + 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 & 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='SELECT' + ) + + def draw(self, context): + layout = self.layout + 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() + 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): + 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 + + # 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: + 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 == 'IGNORE': + self.report({'WARNING'}, "Continuing without assigning persistent IDs (legacy index fallback)") + # 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/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..7c4e03a 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: @@ -174,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 a0f3982..1474afe 100644 --- a/bms_blender_plugin/preferences.py +++ b/bms_blender_plugin/preferences.py @@ -4,7 +4,79 @@ 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 cache + util_module._dofs_hydrated = False + context.scene.dof_list.clear() + 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'} + + +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 cache + util_module._switches_hydrated = False + context.scene.switch_list.clear() + 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'} + + +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): @@ -30,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)", @@ -114,6 +197,16 @@ 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") + box.separator() + box.prop(self, "prefer_scene_snapshot") + box.prop(self, "warn_xml_mismatch") + 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 +257,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 +267,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) diff --git a/bms_blender_plugin/ui_tools/dof_behaviour.py b/bms_blender_plugin/ui_tools/dof_behaviour.py index 9ec19b8..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,23 +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: - active_switch = get_switches()[obj.switch_list_index] - obj.name = f"Switch - {active_switch.name} ({active_switch.switch_number})" - 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 + """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) + 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: + try: + resolved_num, resolved_branch = resolve_switch_id(obj) + except Exception: + 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 >= 0: + label = lookup_dof_label(dof_num) or "Custom" + obj.name = f"DOF - {label} ({dof_num})" + else: + try: + resolved = resolve_dof_number(obj) + except Exception: + 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 a5b1d15..3310a48 100644 --- a/bms_blender_plugin/ui_tools/operators/__init__.py +++ b/bms_blender_plugin/ui_tools/operators/__init__.py @@ -9,6 +9,123 @@ 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, get_bml_type +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 + 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) + + +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: + 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 + +# 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: + 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 def register_blender_properties(): @@ -69,15 +186,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 +338,38 @@ def register_blender_properties(): update=dof_update_input, ) + # Silent legacy migration disabled: manual validation-driven assignment required. + try: + for obj in bpy.data.objects: + 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 new file mode 100644 index 0000000..f3c702c --- /dev/null +++ b/bms_blender_plugin/ui_tools/operators/assign_from_index.py @@ -0,0 +1,377 @@ +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) + 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." + )) + 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, 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 + 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) + + # 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: + 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 + + +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)" + 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..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 ): @@ -68,7 +99,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 +178,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..1018f25 100644 --- a/bms_blender_plugin/ui_tools/panels/switch_panel.py +++ b/bms_blender_plugin/ui_tools/panels/switch_panel.py @@ -25,17 +25,57 @@ 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: 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 + def draw_item( self, context, layout, data, item, icon, active_data, active_propname, index ): 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 +105,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")