diff --git a/leenkx/Sources/leenkx/logicnode/OnceNode.hx b/leenkx/Sources/leenkx/logicnode/OnceNode.hx new file mode 100644 index 0000000..960c4f8 --- /dev/null +++ b/leenkx/Sources/leenkx/logicnode/OnceNode.hx @@ -0,0 +1,23 @@ +package leenkx.logicnode; + +class OnceNode extends LogicNode { + + var triggered:Bool = false; + + public function new(tree: LogicTree) { + super(tree); + } + + override function run(from: Int) { + if(from == 1){ + triggered = false; + return; + } + + if (!triggered) { + triggered = true; + runOutput(0); + } + } + +} diff --git a/leenkx/Sources/leenkx/logicnode/SetObjectDelayedLocationNode.hx b/leenkx/Sources/leenkx/logicnode/SetObjectDelayedLocationNode.hx new file mode 100644 index 0000000..ccc8b51 --- /dev/null +++ b/leenkx/Sources/leenkx/logicnode/SetObjectDelayedLocationNode.hx @@ -0,0 +1,74 @@ +package leenkx.logicnode; + +import iron.object.Object; +import iron.math.Vec4; +import iron.math.Mat4; +import iron.system.Time; + +class SetObjectDelayedLocationNode extends LogicNode { + public var use_local_space: Bool = false; + + private var initialOffset: Vec4 = null; + private var targetPos: Vec4 = new Vec4(); + private var currentPos: Vec4 = new Vec4(); + private var deltaVec: Vec4 = new Vec4(); + private var tempVec: Vec4 = new Vec4(); + + private var lastParent: Object = null; + private var invParentMatrix: Mat4 = null; + + public function new(tree: LogicTree) { + super(tree); + } + + override function run(from: Int) { + var follower: Object = inputs[1].get(); + var target: Object = inputs[2].get(); + var delay: Float = inputs[3].get(); + + if (follower == null || target == null || delay == null) return runOutput(0); + + if (initialOffset == null) { + initialOffset = new Vec4(); + var followerPos = follower.transform.world.getLoc(); + var targetPos = target.transform.world.getLoc(); + initialOffset.setFrom(followerPos); + initialOffset.sub(targetPos); + } + + targetPos.setFrom(target.transform.world.getLoc()); + currentPos.setFrom(follower.transform.world.getLoc()); + + tempVec.setFrom(targetPos).add(initialOffset); + + deltaVec.setFrom(tempVec).sub(currentPos); + + if (deltaVec.length() < 0.001 && delay < 0.01) { + runOutput(0); + return; + } + + if (delay == 0.0) { + currentPos.setFrom(tempVec); + } else { + var smoothFactor = Math.exp(-Time.delta / Math.max(0.0001, delay)); + currentPos.x = tempVec.x + (currentPos.x - tempVec.x) * smoothFactor; + currentPos.y = tempVec.y + (currentPos.y - tempVec.y) * smoothFactor; + currentPos.z = tempVec.z + (currentPos.z - tempVec.z) * smoothFactor; + } + if (use_local_space && follower.parent != null) { + if (follower.parent != lastParent || invParentMatrix == null) { + lastParent = follower.parent; + invParentMatrix = Mat4.identity(); + invParentMatrix.getInverse(follower.parent.transform.world); + } + tempVec.setFrom(currentPos); + tempVec.applymat(invParentMatrix); + follower.transform.loc.set(tempVec.x, tempVec.y, tempVec.z); + } else { + follower.transform.loc.set(currentPos.x, currentPos.y, currentPos.z); + } + follower.transform.buildMatrix(); + runOutput(0); + } +} diff --git a/leenkx/blender/lnx/handlers.py b/leenkx/blender/lnx/handlers.py index ef47589..5d62a25 100644 --- a/leenkx/blender/lnx/handlers.py +++ b/leenkx/blender/lnx/handlers.py @@ -2,7 +2,10 @@ 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 @@ -30,6 +33,10 @@ if lnx.is_reload(__name__): 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): @@ -135,35 +142,113 @@ def always() -> float: def poll_threads() -> float: - """Polls the thread callback queue and if a thread has finished, it - is joined with the main thread and the corresponding callback is - executed in the main thread. """ + 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: - thread, callback = make.thread_callback_queue.get(block=False) + 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 - if thread.is_alive(): - try: - make.thread_callback_queue.put((thread, callback), block=False) - except queue.Full: - return 0.5 - return 0.1 + + # 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() + thread.join() # Should be instant since thread is dead callback() except Exception as e: - # If there is an exception, we can no longer return the time to - # the next call to this polling function, so to keep it running - # we re-register it and then raise the original exception. - try: - bpy.app.timers.unregister(poll_threads) - except ValueError: - pass - bpy.app.timers.register(poll_threads, first_interval=0.01, persistent=True) - # Quickly check if another thread has finished - return 0.01 + # 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] = {} diff --git a/leenkx/blender/lnx/logicnode/logic/LN_once.py b/leenkx/blender/lnx/logicnode/logic/LN_once.py new file mode 100644 index 0000000..5bb9d02 --- /dev/null +++ b/leenkx/blender/lnx/logicnode/logic/LN_once.py @@ -0,0 +1,15 @@ +from lnx.logicnode.lnx_nodes import * +import bpy + +class OnceNode(LnxLogicTreeNode): + bl_idname = 'LNOnceNode' + bl_label = 'Once' + lnx_version = 1 + + def lnx_init(self, context): + self.add_input('LnxNodeSocketAction', 'Run Once') + self.add_input('LnxNodeSocketAction', 'Reset') + + self.add_output('LnxNodeSocketAction', 'Out') + + diff --git a/leenkx/blender/lnx/logicnode/object/LN_set_object_delayed_location.py b/leenkx/blender/lnx/logicnode/object/LN_set_object_delayed_location.py new file mode 100644 index 0000000..7a40091 --- /dev/null +++ b/leenkx/blender/lnx/logicnode/object/LN_set_object_delayed_location.py @@ -0,0 +1,28 @@ +from lnx.logicnode.lnx_nodes import * +import bpy +from bpy.props import BoolProperty + +class LNSetObjectDelayedLocationNode(LnxLogicTreeNode): + bl_idname = 'LNSetObjectDelayedLocationNode' + bl_label = 'Set Object Delayed Location' + lnx_section = 'props' + lnx_version = 1 + + use_local_space: BoolProperty( + name="Use Local Space", + description="Move follower in local space instead of world space", + default=False + ) + + def lnx_init(self, context): + self.inputs.new('LnxNodeSocketAction', 'In') + self.inputs.new('LnxNodeSocketObject', 'Source Object') + self.inputs.new('LnxNodeSocketObject', 'Target Object') + self.inputs.new('LnxFloatSocket', 'Delay') + self.outputs.new('LnxNodeSocketAction', 'Out') + + def draw_buttons(self, context, layout): + layout.prop(self, 'use_local_space') + + def draw_label(self): + return "Set Object Delayed Location" diff --git a/leenkx/blender/lnx/props.py b/leenkx/blender/lnx/props.py index 7ab49fb..e8babe4 100644 --- a/leenkx/blender/lnx/props.py +++ b/leenkx/blender/lnx/props.py @@ -613,7 +613,7 @@ def update_leenkx_world(): if bpy.data.filepath != '' and (file_version < sdk_version or wrd.lnx_commit != lnx_commit): # This allows for seamless migration from earlier versions of Leenkx for rp in wrd.lnx_rplist: # TODO: deprecated - if rp.rp_gi != 'Off': + if hasattr(rp, 'rp_gi') and rp.rp_gi != 'Off': rp.rp_gi = 'Off' rp.rp_voxels = rp.rp_gi