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