This commit is contained in:
Nicolas Predella 2026-10-14 12:02:15 +03:00
parent 8e8a0a5993
commit 81d93dd124
7 changed files with 753 additions and 94 deletions

View file

@ -36,6 +36,11 @@ def register():
print('Nodeslicer Loaded') print('Nodeslicer Loaded')
def unregister(): def unregister():
try:
tree.cancel_timer()
except Exception as e:
print(f"Error canceling timer: {e}")
try: try:
if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post: if tree.depsgraph_update_handler in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler) bpy.app.handlers.depsgraph_update_post.remove(tree.depsgraph_update_handler)

View file

@ -3,35 +3,53 @@ from typing import Any, Callable
import bpy import bpy
from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty, FloatVectorProperty, CollectionProperty from bpy.props import PointerProperty, StringProperty, FloatProperty, IntProperty, EnumProperty, BoolProperty, FloatVectorProperty, CollectionProperty
def _is_btype(val: Any, type_name: str) -> bool:
if val is None:
return False
btype = getattr(bpy.types, type_name, None)
if isinstance(btype, type):
try:
if isinstance(val, btype):
return True
except TypeError:
pass
val_cls_name = getattr(val, "__class__", type(None)).__name__
return val_cls_name == type_name or f"Dummy{type_name}" in val_cls_name
def notify_tree_changed(self, context): def notify_tree_changed(self, context):
from . import tree from . import tree
if tree._is_updating: if getattr(tree, "_is_updating", False):
return return
tree._tree_changed = True
node = None node = None
socket = None socket = None
changed_tree = None
if isinstance(self, bpy.types.Node): if _is_btype(self, "NodeTree"):
changed_tree = self
elif _is_btype(self, "Node"):
node = self node = self
elif isinstance(self, bpy.types.NodeSocket): changed_tree = getattr(self, "id_data", None)
elif _is_btype(self, "NodeSocket"):
socket = self socket = self
node = self.node node = getattr(self, "node", None)
elif isinstance(self, bpy.types.PropertyGroup): changed_tree = getattr(self, "id_data", getattr(node, "id_data", None) if node else None)
elif _is_btype(self, "PropertyGroup"):
try: try:
id_data = self.id_data id_data = self.id_data
if isinstance(id_data, bpy.types.NodeTree): if _is_btype(id_data, "NodeTree"):
changed_tree = id_data
path = self.path_from_id() path = self.path_from_id()
if ".inputs[" in path or ".outputs[" in path: if ".inputs[" in path or ".outputs[" in path:
socket_path = path.split(".items[")[0] if ".items[" in path else path.rsplit(".", 1)[0] socket_path = path.split(".items[")[0] if ".items[" in path else path.rsplit(".", 1)[0]
resolved = id_data.path_resolve(socket_path) resolved = id_data.path_resolve(socket_path)
if isinstance(resolved, bpy.types.NodeSocket): if _is_btype(resolved, "NodeSocket"):
socket = resolved socket = resolved
node = socket.node node = socket.node
else: else:
node_path = path.rsplit(".", 1)[0] node_path = path.rsplit(".", 1)[0]
resolved = id_data.path_resolve(node_path) resolved = id_data.path_resolve(node_path)
if isinstance(resolved, bpy.types.Node): if _is_btype(resolved, "Node"):
node = resolved node = resolved
except Exception: except Exception:
pass pass
@ -41,6 +59,8 @@ def notify_tree_changed(self, context):
node = getattr(context, "node", None) node = getattr(context, "node", None)
if socket is None: if socket is None:
socket = getattr(context, "socket", None) socket = getattr(context, "socket", None)
if changed_tree is None and node:
changed_tree = getattr(node, "id_data", None)
if socket and hasattr(socket, "update"): if socket and hasattr(socket, "update"):
try: try:
@ -54,6 +74,9 @@ def notify_tree_changed(self, context):
except Exception as e: except Exception as e:
print(f"Error updating node {node.name}: {e}") print(f"Error updating node {node.name}: {e}")
if hasattr(tree, "request_auto_refresh"):
tree.request_auto_refresh(changed_tree)
class MockPropertyDescriptor: class MockPropertyDescriptor:
def __init__(self, default: Any = None): def __init__(self, default: Any = None):
self.default = default self.default = default

View file

@ -9,94 +9,303 @@ from ...registry import register_class
if TYPE_CHECKING: if TYPE_CHECKING:
from bpy.stub_internal.rna_enums import OperatorReturnItems from bpy.stub_internal.rna_enums import OperatorReturnItems
def get_referenced_objects(node, visited=None): def _get_scene_object(name: str) -> Any:
if not name:
return None
objects = getattr(bpy.data, "objects", None)
if objects is None:
return None
if isinstance(objects, dict):
return objects.get(name)
if "Mock" in type(objects).__name__:
return None
if hasattr(objects, "get"):
return objects.get(name)
if hasattr(objects, "__getitem__"):
try:
return objects[name]
except Exception:
return None
return None
def _is_btype(val: Any, type_name: str) -> bool:
if val is None:
return False
btype = getattr(bpy.types, type_name, None)
if isinstance(btype, type):
try:
if isinstance(val, btype):
return True
except TypeError:
pass
val_cls_name = getattr(val, "__class__", type(None)).__name__
return val_cls_name == type_name or f"Dummy{type_name}" in val_cls_name
def get_referenced_data(node: Any, visited: set[int] | None = None) -> tuple[set[Any], set[Any]]:
"""Traverse upstream from node and collect all referenced bpy.types.Object and bpy.types.Collection."""
if visited is None: if visited is None:
visited = set() visited = set()
ptr = node.as_pointer() ptr = node.as_pointer() if hasattr(node, "as_pointer") and callable(node.as_pointer) else id(node)
if ptr in visited:
return set(), set()
visited.add(ptr)
objs: set[Any] = set()
cols: set[Any] = set()
# 1. Group Node: check internal nodes inside the group
if getattr(node, "bl_idname", "") == "SLICENODE_Group":
group_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if group_tree and hasattr(group_tree, "nodes"):
for internal_node in group_tree.nodes:
sub_objs, sub_cols = get_referenced_data(internal_node, visited)
objs.update(sub_objs)
cols.update(sub_cols)
# 2. CollectionObjects Node
if getattr(node, "bl_idname", "") == "SLICENODE_CollectionObjects":
col = getattr(node, "collection", None)
if col:
cols.add(col)
for o in getattr(col, "all_objects", getattr(col, "objects", [])):
if _is_btype(o, "Object"):
objs.add(o)
for outp in getattr(node, "outputs", []):
obj = _get_scene_object(getattr(outp, "identifier", "")) or _get_scene_object(getattr(outp, "name", ""))
if obj:
objs.add(obj)
# 3. Check node RNA pointer properties for Object or Collection
for prop in getattr(getattr(node, "bl_rna", None), "properties", []):
if getattr(prop, "type", "") == 'POINTER':
val = getattr(node, prop.identifier, None)
if _is_btype(val, "Object"):
objs.add(val)
elif _is_btype(val, "Collection"):
cols.add(val)
for o in getattr(val, "all_objects", getattr(val, "objects", [])):
if _is_btype(o, "Object"):
objs.add(o)
# 4. Check all input sockets
for inp in getattr(node, "inputs", []):
val = getattr(inp, "value", getattr(inp, "default_value", None))
if _is_btype(val, "Object"):
objs.add(val)
elif _is_btype(val, "Collection"):
cols.add(val)
# Follow upstream links
if getattr(inp, "is_linked", False):
for link in getattr(inp, "links", []):
from_node = getattr(link, "from_node", None)
if from_node:
if getattr(from_node, "bl_idname", "") == "SLICENODE_CollectionObjects":
from_sock = getattr(link, "from_socket", None)
if from_sock:
obj = _get_scene_object(getattr(from_sock, "identifier", "")) or _get_scene_object(getattr(from_sock, "name", ""))
if obj:
objs.add(obj)
sub_objs, sub_cols = get_referenced_data(from_node, visited)
objs.update(sub_objs)
cols.update(sub_cols)
# 5. Check all output sockets
for outp in getattr(node, "outputs", []):
val = getattr(outp, "value", getattr(outp, "default_value", None))
if _is_btype(val, "Object"):
objs.add(val)
elif _is_btype(val, "Collection"):
cols.add(val)
# 6. Expand all objects to include their recursive children and parents/ancestors
from ...infra.blender.blender_bridge import get_all_children
all_objs = set(objs)
for o in list(objs):
# Children
try:
for child in get_all_children(o):
all_objs.add(child)
except Exception:
pass
# Ancestors
curr = getattr(o, "parent", None)
seen_parents = set()
while curr and curr not in seen_parents and len(seen_parents) < 100:
seen_parents.add(curr)
all_objs.add(curr)
if "Mock" in type(curr).__name__:
break
curr = getattr(curr, "parent", None)
return all_objs, cols
def get_referenced_objects(node: Any, visited: set[int] | None = None) -> set[Any]:
objs, _ = get_referenced_data(node, visited)
return objs
def get_referenced_trees(node: Any, visited: set[int] | None = None) -> set[Any]:
"""Collect all NodeTree datablocks referenced upstream (e.g. via group nodes)."""
if visited is None:
visited = set()
ptr = node.as_pointer() if hasattr(node, "as_pointer") and callable(node.as_pointer) else id(node)
if ptr in visited: if ptr in visited:
return set() return set()
visited.add(ptr) visited.add(ptr)
objs = set() trees = set()
if getattr(node, "bl_idname", "") == "SLICENODE_Group":
group_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None)
if group_tree:
trees.add(group_tree)
if hasattr(group_tree, "nodes"):
for internal_node in group_tree.nodes:
trees.update(get_referenced_trees(internal_node, visited))
# Special case: if this is a group node, we must also check nodes inside the group for inp in getattr(node, "inputs", []):
if node.bl_idname == "SLICENODE_Group" and getattr(node, "slicer_tree", None): if getattr(inp, "is_linked", False):
for internal_node in node.slicer_tree.nodes: for link in getattr(inp, "links", []):
objs.update(get_referenced_objects(internal_node, visited)) from_node = getattr(link, "from_node", None)
if from_node:
trees.update(get_referenced_trees(from_node, visited))
# Check all input sockets for objects return trees
for inp in node.inputs:
# If it's an object socket, it might have a value if not linked
if inp.bl_idname == "NodeSocketObject":
if not inp.is_linked and getattr(inp, "value", getattr(inp, "default_value", None)):
val = getattr(inp, "value", getattr(inp, "default_value", None))
if isinstance(val, bpy.types.Object):
objs.add(val)
# Always follow links to find more objects upstream
if inp.is_linked:
for link in inp.links:
# Special case: if from_node is CollectionObjects, the object is identified by the socket identifier
if link.from_node.bl_idname == "SLICENODE_CollectionObjects":
obj = bpy.data.objects.get(link.from_socket.identifier)
if obj:
objs.add(obj)
objs.update(get_referenced_objects(link.from_node, visited))
# Also check output sockets of the node itself,
# as some nodes might "hold" an object (like InputObject)
for outp in node.outputs:
if outp.bl_idname == "NodeSocketObject":
val = getattr(outp, "value", getattr(outp, "default_value", None))
if isinstance(val, bpy.types.Object):
objs.add(val)
elif node.bl_idname == "SLICENODE_CollectionObjects" and outp.is_linked:
obj = bpy.data.objects.get(outp.identifier)
if obj:
objs.add(obj)
return objs
_is_updating = False _is_updating = False
_tree_changed = False _tree_changed = False
_changed_trees: set[Any] = set()
_timer_scheduled = False
def tree_update_handler(scene, depsgraph): def _has_real_timers() -> bool:
global _tree_changed, _is_updating timers = getattr(bpy.app, "timers", None)
if timers is None:
return False
if "Mock" in type(timers).__name__:
return False
reg = getattr(timers, "register", None)
if reg is None or "Mock" in type(reg).__name__:
return False
return True
# Names of objects that have changed def cancel_timer() -> None:
updated_object_names = {update.id.name for update in depsgraph.updates if isinstance(update.id, bpy.types.Object)} global _timer_scheduled
if _timer_scheduled and hasattr(bpy.app, "timers") and hasattr(bpy.app.timers, "unregister"):
try:
bpy.app.timers.unregister(_timer_callback)
except Exception:
pass
_timer_scheduled = False
# Also check for mesh updates (happens in Edit Mode) def _timer_callback() -> float | None:
updated_meshes = {update.id.name for update in depsgraph.updates if isinstance(update.id, bpy.types.Mesh)} global _timer_scheduled, _is_updating
if updated_meshes: if _is_updating:
for obj in bpy.data.objects: # Retry in 50ms if currently evaluating
if obj.type == 'MESH' and obj.data.name in updated_meshes: return 0.05
updated_object_names.add(obj.name) _timer_scheduled = False
run_auto_refresh()
return None
# Note: custom NodeTree updates often don't appear in depsgraph.updates def schedule_auto_refresh(delay: float = 0.02) -> None:
# so we rely on _tree_changed flag set by property update callbacks. global _timer_scheduled
if _timer_scheduled:
return
if _has_real_timers():
try:
_timer_scheduled = True
bpy.app.timers.register(_timer_callback, first_interval=delay, persistent=True)
return
except Exception:
_timer_scheduled = False
# Fallback if timers are not running / available (e.g. tests or non-interactive CLI)
if not _is_updating:
run_auto_refresh()
if not updated_object_names and not _tree_changed: def request_auto_refresh(changed_tree: Any = None) -> None:
global _tree_changed, _changed_trees
if _is_updating:
return
_tree_changed = True
if changed_tree is not None:
_changed_trees.add(changed_tree)
schedule_auto_refresh()
def _tag_redraw() -> None:
try:
wm = getattr(bpy.context, "window_manager", None)
if wm:
for window in getattr(wm, "windows", []):
screen = getattr(window, "screen", None)
if screen:
for area in getattr(screen, "areas", []):
if getattr(area, "type", "") in ('VIEW_3D', 'NODE_EDITOR'):
area.tag_redraw()
except Exception:
pass
def run_auto_refresh(
updated_object_names: set[str] | None = None,
updated_collection_names: set[str] | None = None,
changed_trees: set[Any] | None = None,
force_refresh: bool = False
) -> None:
global _is_updating, _tree_changed, _changed_trees
if _is_updating:
return return
tree_needs_refresh = _tree_changed active_changed_trees = set(_changed_trees)
_tree_changed = False # reset for next run if changed_trees:
active_changed_trees.update(changed_trees)
_changed_trees.clear()
for tree in bpy.data.node_groups: tree_needs_refresh = force_refresh or _tree_changed
if tree.bl_idname != "SLICETREE_NodeTree": _tree_changed = False
has_updates = tree_needs_refresh or bool(updated_object_names) or bool(updated_collection_names)
if not has_updates:
return
# Collect all SLICETREE_NodeTree datablocks
trees_to_check: list[Any] = []
node_groups = getattr(bpy.data, "node_groups", None)
if node_groups is not None:
trees_to_check.extend(list(node_groups))
try:
space_tree = getattr(getattr(bpy.context, "space_data", None), "edit_tree", None)
if space_tree and space_tree not in trees_to_check:
trees_to_check.append(space_tree)
except Exception:
pass
executed_any = False
for tree in trees_to_check:
if getattr(tree, "bl_idname", "") != "SLICETREE_NodeTree":
continue continue
for node in tree.nodes: for node in getattr(tree, "nodes", []):
if node.bl_idname == "SLICENODE_Executor" and getattr(node, "auto_refresh", False): if getattr(node, "bl_idname", "") == "SLICENODE_Executor" and getattr(node, "auto_refresh", False):
# Trigger if the tree changed OR if a referenced object changed should_refresh = False
should_refresh = tree_needs_refresh
if tree_needs_refresh:
if not active_changed_trees:
should_refresh = True
else:
owner_tree = getattr(node, "id_data", tree)
dep_trees = {owner_tree} | get_referenced_trees(node)
if active_changed_trees & dep_trees:
should_refresh = True
if not should_refresh: if not should_refresh:
referenced = get_referenced_objects(node) ref_objs, ref_cols = get_referenced_data(node)
referenced_names = {obj.name for obj in referenced if obj} if updated_object_names:
if updated_object_names & referenced_names: ref_names = {o.name for o in ref_objs if o and hasattr(o, "name")}
if updated_object_names & ref_names:
should_refresh = True
if not should_refresh and updated_collection_names:
ref_col_names = {c.name for c in ref_cols if c and hasattr(c, "name")}
if updated_collection_names & ref_col_names:
should_refresh = True should_refresh = True
if should_refresh: if should_refresh:
@ -104,6 +313,7 @@ def tree_update_handler(scene, depsgraph):
try: try:
with execution_cache(): with execution_cache():
node.evaluate() node.evaluate()
executed_any = True
except Exception as e: except Exception as e:
print(f"Auto-refresh failed: {e}") print(f"Auto-refresh failed: {e}")
import traceback import traceback
@ -111,6 +321,43 @@ def tree_update_handler(scene, depsgraph):
finally: finally:
_is_updating = False _is_updating = False
if executed_any:
_tag_redraw()
def tree_update_handler(scene, depsgraph):
global _tree_changed, _is_updating
if _is_updating:
return
# Names of objects that have changed
updated_object_names = {update.id.name for update in depsgraph.updates if _is_btype(update.id, "Object")}
# Also check for mesh updates (happens in Edit Mode)
updated_meshes = {update.id.name for update in depsgraph.updates if _is_btype(update.id, "Mesh")}
if updated_meshes:
objects = getattr(bpy.data, "objects", [])
for obj in objects:
if getattr(obj, "type", "") == 'MESH' and getattr(getattr(obj, "data", None), "name", None) in updated_meshes:
updated_object_names.add(obj.name)
# Check for collection updates
updated_collection_names = {update.id.name for update in depsgraph.updates if _is_btype(update.id, "Collection")}
# Check if any NodeTree updated in depsgraph
updated_trees = {update.id for update in depsgraph.updates if _is_btype(update.id, "NodeTree")}
changed_trees = {t for t in updated_trees if getattr(t, "bl_idname", "") == "SLICETREE_NodeTree"}
if changed_trees:
_tree_changed = True
if not updated_object_names and not updated_collection_names and not _tree_changed:
return
run_auto_refresh(
updated_object_names=updated_object_names,
updated_collection_names=updated_collection_names,
changed_trees=changed_trees
)
@persistent @persistent
def depsgraph_update_handler(scene, depsgraph): def depsgraph_update_handler(scene, depsgraph):
global _is_updating, _tree_changed global _is_updating, _tree_changed
@ -158,6 +405,17 @@ class SLICETREE_NodeTree(NodeTree):
bl_group_idname = "SLICENODE_Group" bl_group_idname = "SLICENODE_Group"
bl_use_group_interface = True bl_use_group_interface = True
def update(self):
super_update = getattr(super(), "update", None)
if callable(super_update):
try:
super_update()
except Exception:
pass
if _is_updating:
return
request_auto_refresh(self)
def valid_link(self, from_socket: Any, to_socket: Any) -> bool: def valid_link(self, from_socket: Any, to_socket: Any) -> bool:
geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"}
out_s, in_s = from_socket, to_socket out_s, in_s = from_socket, to_socket
@ -232,7 +490,7 @@ class SLICENODE_OT_GroupToggle(Operator):
# If we have a target group node, we try to enter it # If we have a target group node, we try to enter it
if target_node and target_node.bl_idname == "SLICENODE_Group": if target_node and target_node.bl_idname == "SLICENODE_Group":
group_tree = getattr(target_node, "slicer_tree", None) group_tree = getattr(target_node, "slicer_tree", None) or getattr(target_node, "node_tree", None)
if group_tree: if group_tree:
path.append(group_tree, node=target_node) path.append(group_tree, node=target_node)
return {'FINISHED'} return {'FINISHED'}

View file

@ -1,9 +1,15 @@
from __future__ import annotations from __future__ import annotations
def get_all_children(obj): def get_all_children(obj, visited=None):
if visited is None:
visited = set()
children = [] children = []
for child in getattr(obj, "children", []): children_attr = getattr(obj, "children", [])
children += [child] + get_all_children(child) if "Mock" in type(children_attr).__name__ or not isinstance(children_attr, (list, tuple, set)):
return []
children = list(set(children)) for child in children_attr:
return children if child not in visited:
visited.add(child)
children.append(child)
children.extend(get_all_children(child, visited))
return list(set(children))

357
tests/test_auto_refresh.py Normal file
View file

@ -0,0 +1,357 @@
import unittest
from unittest.mock import MagicMock
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.append(str(PROJECT_ROOT.parent))
class DummyObject:
def __init__(self, name="TestObj", obj_type='MESH', parent=None, children=None):
self.name = name
self.type = obj_type
self.parent = parent
self.children = children or []
def as_pointer(self):
return id(self)
class DummyCollection:
def __init__(self, name="Col", objects=None):
self.name = name
self.objects = objects or []
self.all_objects = self.objects
def as_pointer(self):
return id(self)
if 'bpy' not in sys.modules:
class DummyBase: pass
mock_types = MagicMock()
mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {})
mock_types.Node = type("Node", (DummyBase,), {})
mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {})
mock_types.NodeTree = type("NodeTree", (DummyBase,), {})
mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {})
mock_types.Operator = type("Operator", (DummyBase,), {})
mock_types.Panel = type("Panel", (DummyBase,), {})
mock_types.Menu = type("Menu", (DummyBase,), {})
mock_types.Object = DummyObject
mock_types.Collection = DummyCollection
mock_bpy = MagicMock()
mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock()
sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock()
else:
if 'bpy.props' not in sys.modules:
sys.modules['bpy.props'] = MagicMock()
class DummyNodeSocket:
def __init__(self, name="", bl_idname="NodeSocketObject", node=None, value=None):
self.name = name
self.identifier = name
self.bl_idname = bl_idname
self.node = node
self.is_linked = False
self.links = []
self.value = value
self.default_value = value
def as_pointer(self):
return id(self)
class DummyLink:
def __init__(self, from_socket, to_socket):
self.from_socket = from_socket
self.from_node = getattr(from_socket, "node", None)
self.to_socket = to_socket
self.to_node = getattr(to_socket, "node", None)
class DummyNode:
def __init__(self, name="Node", bl_idname="DummyNode", id_data=None):
self.name = name
self.bl_idname = bl_idname
self.id_data = id_data
self.inputs = []
self.outputs = []
self.bl_rna = MagicMock()
self.bl_rna.properties = []
def as_pointer(self):
return id(self)
class DummyGroupNode(DummyNode):
def __init__(self, name="GroupNode", slicer_tree=None, node_tree=None, id_data=None):
super().__init__(name, "SLICENODE_Group", id_data)
self.slicer_tree = slicer_tree
self.node_tree = node_tree
class DummyExecutorNode(DummyNode):
def __init__(self, name="Executor", auto_refresh=True, id_data=None):
super().__init__(name, "SLICENODE_Executor", id_data)
self.auto_refresh = auto_refresh
self.evaluated = False
self.evaluate_count = 0
def evaluate(self, operator=None):
self.evaluated = True
self.evaluate_count += 1
return "ok"
class DummyNodeTree:
def __init__(self, name="Tree"):
self.name = name
self.bl_idname = "SLICETREE_NodeTree"
self.nodes = []
def as_pointer(self):
return id(self)
# Import tree functions
from NodeSlicer.core.nodeslicer.tree import (
get_referenced_data,
get_referenced_objects,
get_referenced_trees,
run_auto_refresh,
request_auto_refresh,
SLICETREE_NodeTree
)
import NodeSlicer.core.nodeslicer.tree as tree_mod
import bpy
class TestAutoRefresh(unittest.TestCase):
def setUp(self):
tree_mod._is_updating = False
tree_mod._tree_changed = False
tree_mod._changed_trees.clear()
tree_mod._timer_scheduled = False
def tearDown(self):
tree_mod._is_updating = False
tree_mod._tree_changed = False
tree_mod._changed_trees.clear()
tree_mod._timer_scheduled = False
def test_referenced_objects_direct_and_upstream(self):
obj1 = DummyObject("Obj1")
obj2 = DummyObject("Obj2")
node_a = DummyNode("NodeA")
out_sock_a = DummyNodeSocket("Out", "NodeSocketObject", node=node_a, value=obj1)
node_a.outputs.append(out_sock_a)
node_b = DummyNode("NodeB")
in_sock_b = DummyNodeSocket("In", "NodeSocketObject", node=node_b)
node_b.inputs.append(in_sock_b)
# Link A to B
link = DummyLink(out_sock_a, in_sock_b)
in_sock_b.is_linked = True
in_sock_b.links.append(link)
# Direct unlinked socket on B with obj2
in_sock_b2 = DummyNodeSocket("In2", "SLICESOCK_Object", node=node_b, value=obj2)
node_b.inputs.append(in_sock_b2)
ref_objs = get_referenced_objects(node_b)
self.assertIn(obj1, ref_objs)
self.assertIn(obj2, ref_objs)
def test_referenced_objects_with_hierarchy(self):
parent_empty = DummyObject("ParentRig")
child_mesh = DummyObject("ChildDetail")
main_obj = DummyObject("MainMesh", parent=parent_empty, children=[child_mesh])
parent_empty.children = [main_obj]
child_mesh.parent = main_obj
node = DummyNode("MeshNode")
sock = DummyNodeSocket("Obj", "NodeSocketObject", node=node, value=main_obj)
node.inputs.append(sock)
ref_objs = get_referenced_objects(node)
self.assertIn(main_obj, ref_objs)
self.assertIn(parent_empty, ref_objs)
self.assertIn(child_mesh, ref_objs)
def test_referenced_objects_in_node_groups(self):
obj_inner = DummyObject("InnerSuzanne")
obj_outer = DummyObject("OuterCube")
# Group tree
group_tree = DummyNodeTree("GroupTree")
inner_node = DummyNode("InnerNode")
inner_sock = DummyNodeSocket("Obj", "NodeSocketObject", node=inner_node, value=obj_inner)
inner_node.inputs.append(inner_sock)
group_tree.nodes.append(inner_node)
# Group Node instance
group_node = DummyGroupNode("GroupInstance", slicer_tree=group_tree)
outer_sock = DummyNodeSocket("OuterIn", "NodeSocketObject", node=group_node, value=obj_outer)
group_node.inputs.append(outer_sock)
# Main node connected to group node
main_node = DummyNode("MainNode")
main_in = DummyNodeSocket("In", "SLICESOCK_Mesh", node=main_node)
group_out = DummyNodeSocket("Out", "SLICESOCK_Mesh", node=group_node)
group_node.outputs.append(group_out)
link = DummyLink(group_out, main_in)
main_in.is_linked = True
main_in.links.append(link)
main_node.inputs.append(main_in)
ref_objs = get_referenced_objects(main_node)
self.assertIn(obj_inner, ref_objs)
self.assertIn(obj_outer, ref_objs)
def test_referenced_objects_nested_groups(self):
obj_deep = DummyObject("DeepObj")
# Inner-most group
deep_tree = DummyNodeTree("DeepTree")
deep_node = DummyNode("DeepNode")
deep_node.inputs.append(DummyNodeSocket("DeepSock", value=obj_deep))
deep_tree.nodes.append(deep_node)
# Middle group containing inner group
mid_tree = DummyNodeTree("MidTree")
mid_group = DummyGroupNode("MidGroupNode", node_tree=deep_tree)
mid_tree.nodes.append(mid_group)
# Top group node
top_group = DummyGroupNode("TopGroupNode", slicer_tree=mid_tree)
ref_objs = get_referenced_objects(top_group)
self.assertIn(obj_deep, ref_objs)
def test_get_referenced_trees(self):
# Two group trees: GroupA and GroupB
tree_b = DummyNodeTree("TreeB")
node_b = DummyGroupNode("NodeB", slicer_tree=tree_b)
tree_a = DummyNodeTree("TreeA")
node_a = DummyGroupNode("NodeA", slicer_tree=tree_a)
sock_a = DummyNodeSocket("A_Out", node=node_a)
node_a.outputs.append(sock_a)
executor = DummyExecutorNode("Executor")
exec_in = DummyNodeSocket("In", node=executor)
exec_in.is_linked = True
exec_in.links.append(DummyLink(sock_a, exec_in))
executor.inputs.append(exec_in)
trees = get_referenced_trees(executor)
self.assertIn(tree_a, trees)
self.assertNotIn(tree_b, trees)
def test_collection_objects_referenced(self):
obj_c1 = DummyObject("C1")
obj_c2 = DummyObject("C2")
col = DummyCollection("TestCollection", [obj_c1, obj_c2])
col_node = DummyNode("ColNode", bl_idname="SLICENODE_CollectionObjects")
col_node.collection = col
# Output sockets named C1, C2
out_sock1 = DummyNodeSocket("C1", node=col_node)
out_sock2 = DummyNodeSocket("C2", node=col_node)
col_node.outputs.extend([out_sock1, out_sock2])
ref_objs, ref_cols = get_referenced_data(col_node)
self.assertIn(col, ref_cols)
self.assertIn(obj_c1, ref_objs)
self.assertIn(obj_c2, ref_objs)
def test_run_auto_refresh_on_object_change(self):
main_tree = DummyNodeTree("MainTree")
executor = DummyExecutorNode("Executor", auto_refresh=True, id_data=main_tree)
target_obj = DummyObject("TargetCube")
in_sock = DummyNodeSocket("MeshIn", node=executor, value=target_obj)
executor.inputs.append(in_sock)
main_tree.nodes.append(executor)
# Mock bpy.data.node_groups
old_groups = getattr(tree_mod.bpy.data, "node_groups", None)
tree_mod.bpy.data.node_groups = [main_tree]
bpy.data.node_groups = [main_tree]
try:
# 1. Unrelated object update -> should NOT evaluate
run_auto_refresh(updated_object_names={"UnrelatedObject"})
self.assertFalse(executor.evaluated)
# 2. Related object update -> SHOULD evaluate
run_auto_refresh(updated_object_names={"TargetCube"})
self.assertTrue(executor.evaluated)
self.assertEqual(executor.evaluate_count, 1)
# 3. auto_refresh = False -> should NOT evaluate
executor.evaluated = False
executor.auto_refresh = False
run_auto_refresh(updated_object_names={"TargetCube"})
self.assertFalse(executor.evaluated)
finally:
tree_mod.bpy.data.node_groups = old_groups
bpy.data.node_groups = old_groups
def test_run_auto_refresh_on_group_tree_change(self):
group_tree = DummyNodeTree("GroupTree")
main_tree = DummyNodeTree("MainTree")
unrelated_tree = DummyNodeTree("UnrelatedTree")
group_node = DummyGroupNode("GroupInstance", slicer_tree=group_tree, id_data=main_tree)
g_out = DummyNodeSocket("Out", node=group_node)
group_node.outputs.append(g_out)
executor = DummyExecutorNode("Executor", auto_refresh=True, id_data=main_tree)
e_in = DummyNodeSocket("In", node=executor)
e_in.is_linked = True
e_in.links.append(DummyLink(g_out, e_in))
executor.inputs.append(e_in)
main_tree.nodes.extend([group_node, executor])
old_groups = getattr(tree_mod.bpy.data, "node_groups", None)
tree_mod.bpy.data.node_groups = [main_tree, group_tree, unrelated_tree]
bpy.data.node_groups = [main_tree, group_tree, unrelated_tree]
try:
# 1. Change to unrelated tree -> should NOT evaluate
run_auto_refresh(changed_trees={unrelated_tree}, force_refresh=True)
self.assertFalse(executor.evaluated)
# 2. Change to group_tree -> SHOULD evaluate
run_auto_refresh(changed_trees={group_tree}, force_refresh=True)
self.assertTrue(executor.evaluated)
self.assertEqual(executor.evaluate_count, 1)
# 3. Change to main_tree -> SHOULD evaluate
executor.evaluated = False
run_auto_refresh(changed_trees={main_tree}, force_refresh=True)
self.assertTrue(executor.evaluated)
self.assertEqual(executor.evaluate_count, 2)
finally:
tree_mod.bpy.data.node_groups = old_groups
bpy.data.node_groups = old_groups
def test_request_auto_refresh_guard(self):
# When _is_updating is True, request_auto_refresh must do nothing
tree_mod._is_updating = True
tree_mod._tree_changed = False
tree_mod._timer_scheduled = True
request_auto_refresh()
self.assertFalse(tree_mod._tree_changed)
tree_mod._is_updating = False
request_auto_refresh()
self.assertTrue(tree_mod._tree_changed)
if __name__ == '__main__':
unittest.main()

View file

@ -164,6 +164,8 @@ class TestCollectionObjectsSorting(unittest.TestCase):
def test_evaluate_output(self): def test_evaluate_output(self):
obj_a = DummyObject("Apple", 'MESH') obj_a = DummyObject("Apple", 'MESH')
bpy.data.objects = {"Apple": obj_a} bpy.data.objects = {"Apple": obj_a}
from NodeSlicer.core.nodeslicer.nodes import inputs as inp_module
inp_module.bpy.data.objects = {"Apple": obj_a}
collection = DummyCollection("Fruits", [obj_a]) collection = DummyCollection("Fruits", [obj_a])
self.node.collection = collection self.node.collection = collection
self.node._update_outputs() self.node._update_outputs()

View file

@ -56,9 +56,9 @@ mock_types.NodeGroupInput = DummyNodeGroupInput
mock_types.NodeGroupOutput = DummyNodeGroupOutput mock_types.NodeGroupOutput = DummyNodeGroupOutput
mock_types.NodeCustomGroup = DummyGroupNode mock_types.NodeCustomGroup = DummyGroupNode
if 'bpy' not in sys.modules:
mock_bpy = MagicMock() mock_bpy = MagicMock()
mock_bpy.types = mock_types mock_bpy.types = mock_types
sys.modules['bpy'] = mock_bpy sys.modules['bpy'] = mock_bpy
sys.modules['bpy.types'] = mock_types sys.modules['bpy.types'] = mock_types
sys.modules['bpy.props'] = MagicMock() sys.modules['bpy.props'] = MagicMock()
@ -66,6 +66,14 @@ sys.modules['bpy.utils'] = MagicMock()
sys.modules['bpy.utils.previews'] = MagicMock() sys.modules['bpy.utils.previews'] = MagicMock()
sys.modules['bpy.app'] = MagicMock() sys.modules['bpy.app'] = MagicMock()
sys.modules['bpy.app.handlers'] = MagicMock() sys.modules['bpy.app.handlers'] = MagicMock()
else:
mock_bpy = sys.modules['bpy']
mock_bpy.types.NodeSocket = DummyNodeSocket
mock_bpy.types.Node = DummyNode
mock_bpy.types.NodeGroup = DummyNodeGroup
mock_bpy.types.NodeGroupInput = DummyNodeGroupInput
mock_bpy.types.NodeGroupOutput = DummyNodeGroupOutput
mock_bpy.types.NodeCustomGroup = DummyGroupNode
from NodeSlicer.infra.blender.blender_slice_nodes_generic import ( from NodeSlicer.infra.blender.blender_slice_nodes_generic import (
_resolve_group_output, _resolve_group_output,