Files
LNXSDK/leenkx/blender/lnx/handlers.py

407 lines
15 KiB
Python

import importlib
import os
import queue
import sys
import threading
import time
import types
from typing import Dict, Tuple, Callable, Set
import bpy
from bpy.app.handlers import persistent
import lnx
import lnx.api
import lnx.nodes_logic
import lnx.make_state as state
import lnx.utils
import lnx.utils_vs
from lnx import live_patch, log, make, props
from lnx.logicnode import lnx_nodes
if lnx.is_reload(__name__):
lnx.api = lnx.reload_module(lnx.api)
live_patch = lnx.reload_module(live_patch)
log = lnx.reload_module(log)
lnx_nodes = lnx.reload_module(lnx_nodes)
lnx.nodes_logic = lnx.reload_module(lnx.nodes_logic)
make = lnx.reload_module(make)
state = lnx.reload_module(state)
props = lnx.reload_module(props)
lnx.utils = lnx.reload_module(lnx.utils)
lnx.utils_vs = lnx.reload_module(lnx.utils_vs)
else:
lnx.enable_reload(__name__)
# Module-level storage for active threads (eliminates re-queuing overhead)
_active_threads: Dict[threading.Thread, Callable] = {}
_last_poll_time = 0.0
_consecutive_empty_polls = 0
@persistent
def on_depsgraph_update_post(self):
if state.proc_build is not None:
return
# Recache
depsgraph = bpy.context.evaluated_depsgraph_get()
for update in depsgraph.updates:
uid = update.id
if hasattr(uid, 'lnx_cached'):
# uid.lnx_cached = False # TODO: does not trigger update
if isinstance(uid, bpy.types.Mesh) and uid.name in bpy.data.meshes:
bpy.data.meshes[uid.name].lnx_cached = False
elif isinstance(uid, bpy.types.Curve) and uid.name in bpy.data.curves:
bpy.data.curves[uid.name].lnx_cached = False
elif isinstance(uid, bpy.types.MetaBall) and uid.name in bpy.data.metaballs:
bpy.data.metaballs[uid.name].lnx_cached = False
elif isinstance(uid, bpy.types.Armature) and uid.name in bpy.data.armatures:
bpy.data.armatures[uid.name].lnx_cached = False
elif isinstance(uid, bpy.types.NodeTree) and uid.name in bpy.data.node_groups:
bpy.data.node_groups[uid.name].lnx_cached = False
elif isinstance(uid, bpy.types.Material) and uid.name in bpy.data.materials:
bpy.data.materials[uid.name].lnx_cached = False
# Send last operator to Krom
wrd = bpy.data.worlds['Lnx']
if state.proc_play is not None and state.target == 'krom' and wrd.lnx_live_patch:
ops = bpy.context.window_manager.operators
if len(ops) > 0 and ops[-1] is not None:
live_patch.on_operator(ops[-1].bl_idname)
# Hacky solution to update leenkx props after operator executions.
# bpy.context.active_operator doesn't always exist, in some cases
# like marking assets for example, this code is also executed before
# the operator actually finishes and sets the variable
last_operator = getattr(bpy.context, 'active_operator', None)
if last_operator is not None:
on_operator_post(last_operator.bl_idname)
def on_operator_post(operator_id: str) -> None:
"""Called after operator execution. Does not work for operators
executed in another context. Warning: this function is also called
when the operator execution raised an exception!"""
# 3D View > Object > Rigid Body > Copy from Active
if operator_id == "RIGIDBODY_OT_object_settings_copy":
# Copy leenkx rigid body settings
source_obj = bpy.context.active_object
for target_obj in bpy.context.selected_objects:
target_obj.lnx_rb_linear_factor = source_obj.lnx_rb_linear_factor
target_obj.lnx_rb_angular_factor = source_obj.lnx_rb_angular_factor
target_obj.lnx_rb_angular_friction = source_obj.lnx_rb_angular_friction
target_obj.lnx_rb_trigger = source_obj.lnx_rb_trigger
target_obj.lnx_rb_deactivation_time = source_obj.lnx_rb_deactivation_time
target_obj.lnx_rb_ccd = source_obj.lnx_rb_ccd
target_obj.lnx_rb_interpolate = source_obj.lnx_rb_interpolate
target_obj.lnx_rb_collision_filter_mask = source_obj.lnx_rb_collision_filter_mask
elif operator_id == "NODE_OT_new_node_tree":
if bpy.context.space_data.tree_type == lnx.nodes_logic.LnxLogicTree.bl_idname:
# In Blender 3.5+, new node trees are no longer called "NodeTree"
# but follow the bl_label attribute by default. New logic trees
# are thus called "Leenkx Logic Editor" which conflicts with Haxe's
# class naming convention. To avoid this, we listen for the
# creation of a node tree and then rename it.
# Unfortunately, manually naming the tree has the unfortunate
# side effect of not basing the new name on the name of the
# previously opened node tree, as it is the case for Blender trees...
bpy.context.space_data.edit_tree.name = "LogicTree"
def send_operator(op):
if hasattr(bpy.context, 'object') and bpy.context.object is not None:
obj = bpy.context.object.name
if op.name == 'Move':
vec = bpy.context.object.location
js = 'var o = iron.Scene.active.getChild("' + obj + '"); o.transform.loc.set(' + str(vec[0]) + ', ' + str(vec[1]) + ', ' + str(vec[2]) + '); o.transform.dirty = true;'
make.write_patch(js)
elif op.name == 'Resize':
vec = bpy.context.object.scale
js = 'var o = iron.Scene.active.getChild("' + obj + '"); o.transform.scale.set(' + str(vec[0]) + ', ' + str(vec[1]) + ', ' + str(vec[2]) + '); o.transform.dirty = true;'
make.write_patch(js)
elif op.name == 'Rotate':
vec = bpy.context.object.rotation_euler.to_quaternion()
js = 'var o = iron.Scene.active.getChild("' + obj + '"); o.transform.rot.set(' + str(vec[1]) + ', ' + str(vec[2]) + ', ' + str(vec[3]) + ' ,' + str(vec[0]) + '); o.transform.dirty = true;'
make.write_patch(js)
else: # Rebuild
make.patch()
def always() -> float:
# Force ui redraw
if state.redraw_ui:
for area in bpy.context.screen.areas:
if area.type in ('NODE_EDITOR', 'PROPERTIES', 'VIEW_3D'):
area.tag_redraw()
state.redraw_ui = False
return 0.5
def poll_threads() -> float:
"""
Improved thread polling with:
- No re-queuing overhead
- Batch processing of completed threads
- Adaptive timing based on activity
- Better memory management
- Simplified logic flow
"""
global _last_poll_time, _consecutive_empty_polls
current_time = time.time()
# Process all new threads from queue at once (batch processing)
new_threads_added = 0
try:
while True:
thread, callback = make.thread_callback_queue.get(block=False)
_active_threads[thread] = callback
new_threads_added += 1
except queue.Empty:
pass
# Early return if no active threads
if not _active_threads:
_consecutive_empty_polls += 1
# Adaptive timing: longer intervals when consistently empty
if _consecutive_empty_polls > 10:
return 0.5 # Back off when no activity
return 0.25
# Reset empty poll counter when we have active threads
_consecutive_empty_polls = 0
# Find completed threads (single pass, no re-queuing)
completed_threads = []
for thread in list(_active_threads.keys()):
if not thread.is_alive():
completed_threads.append(thread)
# Batch process all completed threads
if completed_threads:
_process_completed_threads(completed_threads)
# Adaptive timing based on activity level
active_count = len(_active_threads)
if active_count == 0:
return 0.25
elif active_count <= 3:
return 0.05 # Medium frequency for low activity
else:
return 0.01 # High frequency for high activity
def _process_completed_threads(completed_threads: list) -> None:
"""Process a batch of completed threads with robust error handling."""
for thread in completed_threads:
callback = _active_threads.pop(thread) # Remove from tracking
try:
thread.join() # Should be instant since thread is dead
callback()
except Exception as e:
# Robust error recovery
_handle_callback_error(e)
continue # Continue processing other threads
# Explicit cleanup for better memory management
del thread, callback
def _handle_callback_error(exception: Exception) -> None:
"""Centralized error handling with better recovery."""
try:
# Try to unregister existing timer
bpy.app.timers.unregister(poll_threads)
except ValueError:
pass # Timer wasn't registered, that's fine
# Re-register timer with slightly longer interval for stability
bpy.app.timers.register(poll_threads, first_interval=0.1, persistent=True)
# Re-raise the original exception after ensuring timer continuity
raise exception
def cleanup_polling_system() -> None:
"""Optional cleanup function for proper shutdown."""
global _active_threads, _consecutive_empty_polls
# Wait for remaining threads to complete (with timeout)
for thread in list(_active_threads.keys()):
if thread.is_alive():
thread.join(timeout=1.0) # 1 second timeout
# Clear tracking structures
_active_threads.clear()
_consecutive_empty_polls = 0
# Unregister timer
try:
bpy.app.timers.unregister(poll_threads)
except ValueError:
pass
def get_polling_stats() -> dict:
"""Get statistics about the polling system for monitoring."""
return {
'active_threads': len(_active_threads),
'consecutive_empty_polls': _consecutive_empty_polls,
'thread_ids': [t.ident for t in _active_threads.keys()]
}
loaded_py_libraries: dict[str, types.ModuleType] = {}
context_screen = None
@persistent
def on_save_pre(context):
# Ensure that files are saved with the correct version number
# (e.g. startup files with an "Arm" world may have old version numbers)
wrd = bpy.data.worlds['Lnx']
wrd.lnx_version = props.lnx_version
wrd.lnx_commit = props.lnx_commit
@persistent
def on_load_pre(context):
unload_py_libraries()
log.clear(clear_warnings=True, clear_errors=True)
@persistent
def on_load_post(context):
global context_screen
context_screen = bpy.context.screen
props.init_properties_on_load()
reload_blend_data()
lnx.utils.fetch_bundled_script_names()
wrd = bpy.data.worlds['Lnx']
wrd.lnx_recompile = True
lnx.api.remove_drivers()
load_py_libraries()
# Show trait users as collections
lnx.utils.update_trait_collections()
props.update_leenkx_world()
def load_py_libraries():
if bpy.data.filepath == '':
# When a blend file is opened from the file explorer, Blender
# first opens the default file and then the actual blend file,
# so this function is called twice. Because the cwd is already
# that of the folder containing the blend file, libraries would
# be loaded/unloaded once for the default file which is not needed.
return
lib_path = os.path.join(lnx.utils.get_fp(), 'Libraries')
if os.path.exists(lib_path):
# Don't register nodes twice when calling register_nodes()
lnx_nodes.reset_globals()
# Make sure that Leenkx's categories are registered first (on top of the menu)
lnx.logicnode.init_categories()
libs = os.listdir(lib_path)
for lib_name in libs:
fp = os.path.join(lib_path, lib_name)
if os.path.isdir(fp):
if os.path.exists(os.path.join(fp, 'blender.py')):
sys.path.append(fp)
lib_module = importlib.import_module('blender')
importlib.reload(lib_module)
if hasattr(lib_module, 'register'):
lib_module.register()
log.debug(f'Leenkx: Loaded Python library {lib_name}')
loaded_py_libraries[lib_name] = lib_module
sys.path.remove(fp)
# Register newly added nodes and node categories
lnx.nodes_logic.register_nodes()
def unload_py_libraries():
for lib_name, lib_module in loaded_py_libraries.items():
if hasattr(lib_module, 'unregister'):
lib_module.unregister()
lnx.log.debug(f'Leenkx: Unloaded Python library {lib_name}')
loaded_py_libraries.clear()
def reload_blend_data():
leenkx_pbr = bpy.data.node_groups.get('Leenkx PBR')
if leenkx_pbr is None:
load_library('Leenkx PBR')
custom_tilesheet = bpy.data.node_groups.get('CustomTilesheet')
if custom_tilesheet is None:
load_library('CustomTilesheet')
def load_library(asset_name):
if bpy.data.filepath.endswith('lnx_data.blend'): # Prevent load in library itself
return
sdk_path = lnx.utils.get_sdk_path()
data_path = sdk_path + '/leenkx/blender/data/lnx_data.blend'
data_names = [asset_name]
# Import
data_refs = data_names.copy()
with bpy.data.libraries.load(data_path, link=False) as (data_from, data_to):
data_to.node_groups = data_refs
for ref in data_refs:
ref.use_fake_user = True
def post_register():
"""Called in start.py after all Leenkx modules have been registered.
It is also called in case of add-on reloads. Put code here that
needs to be run once at the beginning of each session.
"""
if lnx.utils.get_os_is_windows():
lnx.utils_vs.fetch_installed_vs(silent=True)
def register():
bpy.app.handlers.save_pre.append(on_save_pre)
bpy.app.handlers.load_pre.append(on_load_pre)
bpy.app.handlers.load_post.append(on_load_post)
bpy.app.handlers.depsgraph_update_post.append(on_depsgraph_update_post)
# bpy.app.handlers.undo_post.append(on_undo_post)
bpy.app.timers.register(always, persistent=True)
bpy.app.timers.register(poll_threads, persistent=True)
if lnx.utils.get_fp() != '':
# TODO: On windows, on_load_post is not called when opening .blend file from explorer
if lnx.utils.get_os() == 'win':
on_load_post(None)
else:
# load_py_libraries() is called by on_load_post(). This call makes sure that libraries are also loaded
# when a file is already opened during add-on registration
load_py_libraries()
reload_blend_data()
def unregister():
unload_py_libraries()
bpy.app.timers.unregister(poll_threads)
bpy.app.timers.unregister(always)
bpy.app.handlers.load_post.remove(on_load_post)
bpy.app.handlers.load_pre.remove(on_load_pre)
bpy.app.handlers.save_pre.remove(on_save_pre)
bpy.app.handlers.depsgraph_update_post.remove(on_depsgraph_update_post)
# bpy.app.handlers.undo_post.remove(on_undo_post)