This commit is contained in:
Nicolas Predella 2026-07-28 19:04:11 +03:00
parent 98c9032f8f
commit 4fe4f4e75c
4 changed files with 193 additions and 23 deletions

View file

@ -157,8 +157,10 @@ class SlicePaths(list[np.ndarray]):
extrusion_rate: list[np.ndarray]
feedrate: list[np.ndarray]
extra_attributes: dict[str, list[np.ndarray]]
layer_height: float
first_layer_height: float | None
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None):
def __init__(self, paths: list[np.ndarray] = [], groups: list[int] = [], extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None, layer_height: float = 0.2, first_layer_height: float | None = None):
super().__init__(paths)
self.groups = []
@ -178,6 +180,8 @@ class SlicePaths(list[np.ndarray]):
self.feedrate = [np.zeros(len(p)) for p in paths]
self.extra_attributes = extra_attributes or {}
self.layer_height = layer_height
self.first_layer_height = first_layer_height
def copy_with(self, paths: list[np.ndarray], groups: list[int] | None = None, extrusion_rate: list[np.ndarray] | None = None, feedrate: list[np.ndarray] | None = None, extra_attributes: dict[str, list[np.ndarray]] | None = None) -> SlicePaths:
"""Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes."""

View file

@ -151,40 +151,51 @@ def node_execution_feedback(node: bpy.types.Node):
return
# Save original color
old_use_color = node.use_custom_color
old_color = node.color[:]
old_use_color = getattr(node, "use_custom_color", False)
old_color = getattr(node, "color", (0.0, 0.0, 0.0))[:] if hasattr(node, "color") else None
if hasattr(node, "use_custom_color"):
node.use_custom_color = True
if hasattr(node, "color"):
node.color = (75/255, 73/255, 19/255) # Green
# Refresh UI
try:
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
except Exception:
pass
try:
with execution_cache():
yield
finally:
if hasattr(node, "use_custom_color"):
node.use_custom_color = old_use_color
if hasattr(node, "color") and old_color is not None:
node.color = old_color
try:
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
except Exception:
pass
@contextmanager
def _group_context(node: bpy.types.Node):
stack = _get_group_stack()
def _group_context(node: bpy.types.Node, cache: _EvalCache | None = None):
stack = _get_group_stack(cache)
stack.append(node)
try:
yield
finally:
stack.pop()
def _get_group_stack() -> list[bpy.types.Node]:
def _get_group_stack(cache: _EvalCache | None = None) -> list[bpy.types.Node]:
if cache is None:
cache = _current_execution_cache
if cache is None: return []
if '_group_stack' not in cache:
cache['_group_stack'] = []
return cache['_group_stack']
def _get_current_group_node() -> bpy.types.Node | None:
stack = _get_group_stack()
def _get_current_group_node(cache: _EvalCache | None = None) -> bpy.types.Node | None:
stack = _get_group_stack(cache)
return stack[-1] if stack else None
@register_class
@ -316,21 +327,26 @@ def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited:
inner_socket = output_node.inputs[idx]
with _group_context(node):
with _group_context(node, cache=cache):
return resolve_input(typing.cast(Any, inner_socket), cache=cache, visited=visited)
def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None):
group_node = _get_current_group_node()
if not group_node: return None
stack = _get_group_stack(cache)
if not stack: return None
group_node = stack.pop()
# NodeGroupInput outputs usually match the group inputs by index
try:
idx = list(socket.node.outputs).index(socket)
outer_socket = group_node.inputs[idx]
except (ValueError, IndexError):
stack.append(group_node)
return None
try:
return resolve_input(typing.cast(Any, outer_socket), cache=cache, visited=visited)
finally:
stack.append(group_node)
def _follow_reroute(socket: NodeSocket) -> NodeSocket:
current: NodeSocket = socket
@ -399,7 +415,7 @@ def _resolve_output(
assert socket.node
# Include group stack in cache key to ensure independent evaluation for multiple instances
group_stack = tuple(n.as_pointer() for n in _get_group_stack())
group_stack = tuple(n.as_pointer() for n in _get_group_stack(cache))
# Use identifier instead of name for the cache key, as multiple sockets
# (especially in multi-groups) can share the same name but must have unique identifiers.
@ -413,9 +429,16 @@ def _resolve_output(
try:
with node_execution_feedback(socket.node):
if not isinstance(socket.node, SliceNode):
if isinstance(socket.node, (bpy.types.NodeGroup, SLICENODE_Group)):
is_group = isinstance(socket.node, SLICENODE_Group) or (
isinstance(getattr(bpy.types, "NodeGroup", None), type) and isinstance(socket.node, bpy.types.NodeGroup)
) or getattr(socket.node, "bl_idname", "") in ("NodeGroup", "GeometryNodeGroup")
if is_group:
return _resolve_group_output(socket, cache=cache, visited=visited)
if isinstance(socket.node, bpy.types.NodeGroupInput):
is_group_input = (
isinstance(getattr(bpy.types, "NodeGroupInput", None), type) and isinstance(socket.node, bpy.types.NodeGroupInput)
) or getattr(socket.node, "bl_idname", "") == "NodeGroupInput"
if is_group_input:
return _resolve_group_input(socket, cache=cache, visited=visited)
return default

143
tests/test_node_groups.py Normal file
View file

@ -0,0 +1,143 @@
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))
# Dummy classes for bpy mocks
class DummyNodeSocket:
def __init__(self, name=""):
self.name = name
self.identifier = name
self.is_linked = False
self.links = []
self.default = None
self.node = None
class DummyNode:
def __init__(self, name="Node"):
self.name = name
self.inputs = []
self.outputs = []
self.bl_idname = "DummyNode"
def as_pointer(self):
return id(self)
class DummyNodeGroupInput(DummyNode):
def __init__(self):
super().__init__("NodeGroupInput")
self.bl_idname = "NodeGroupInput"
class DummyNodeGroupOutput(DummyNode):
def __init__(self):
super().__init__("NodeGroupOutput")
self.bl_idname = "NodeGroupOutput"
class DummyGroupNode(DummyNode):
def __init__(self, slicer_tree=None):
super().__init__("GroupNode")
self.bl_idname = "SLICENODE_Group"
self.slicer_tree = slicer_tree
class DummyLink:
def __init__(self, from_socket, to_socket):
self.from_socket = from_socket
self.to_socket = to_socket
class DummyNodeGroup(DummyNode): pass
mock_types = MagicMock()
mock_types.NodeSocket = DummyNodeSocket
mock_types.Node = DummyNode
mock_types.NodeGroup = DummyNodeGroup
mock_types.NodeGroupInput = DummyNodeGroupInput
mock_types.NodeGroupOutput = DummyNodeGroupOutput
mock_types.NodeCustomGroup = DummyGroupNode
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()
from NodeSlicer.infra.blender.blender_slice_nodes_generic import (
_resolve_group_output,
_resolve_group_input,
resolve_input,
_resolve_output,
execution_cache,
SliceNode
)
class DummySliceNode(SliceNode, DummyNode):
def __init__(self, name="SliceNode"):
DummyNode.__init__(self, name)
self.bl_idname = "DummySliceNode"
self.inputs = []
self.outputs = []
def evaluate_output(self, socket_name: str, **kwargs):
inp = self.inputs[0] if self.inputs else None
if inp and inp.is_linked:
return resolve_input(inp, cache=kwargs.get("cache"), visited=kwargs.get("visited"))
return "val"
class TestNodeGroupsRecursion(unittest.TestCase):
def test_nested_group_cycle_prevention(self):
# Build a node group containing a SliceNode and NodeGroupInput/Output
tree = MagicMock()
group_input = DummyNodeGroupInput()
in_sock = DummyNodeSocket("In")
in_sock.node = group_input
group_input.outputs = [in_sock]
slice_node = DummySliceNode("InnerNode")
s_in = DummyNodeSocket("Mesh")
s_in.node = slice_node
s_out = DummyNodeSocket("Mesh")
s_out.node = slice_node
slice_node.inputs = [s_in]
slice_node.outputs = [s_out]
# Link NodeGroupInput to slice_node input
s_in.is_linked = True
s_in.links = [DummyLink(in_sock, s_in)]
group_output = DummyNodeGroupOutput()
out_sock = DummyNodeSocket("Out")
out_sock.node = group_output
group_output.inputs = [out_sock]
# Link slice_node output to NodeGroupOutput input
out_sock.is_linked = True
out_sock.links = [DummyLink(s_out, out_sock)]
tree.nodes = [group_input, slice_node, group_output]
# Group Node instance outside
group_node = DummyGroupNode(slicer_tree=tree)
g_in = DummyNodeSocket("In")
g_in.node = group_node
g_out = DummyNodeSocket("Out")
g_out.node = group_node
group_node.inputs = [g_in]
group_node.outputs = [g_out]
# Outer cycle: Group node's input is linked to Group node's output!
g_in.is_linked = True
g_in.links = [DummyLink(g_out, g_in)]
# Evaluating group output should NOT raise RecursionError due to cycle guard
with execution_cache():
result = _resolve_output(g_out)
# Result should be None (default) because cycle was broken cleanly
self.assertIsNone(result)
if __name__ == "__main__":
unittest.main()

View file

@ -24,7 +24,7 @@ class TestWipeTowerNode(unittest.TestCase):
def test_offset_object_node_output(self):
node = SLICENODE_OffsetObject()
node.resolve = lambda key: {"Offset X": 12.5, "Offset Y": -5.0, "Offset Z": 2.0}.get(key, 0.0)
node.resolve = lambda key: {"X Offset": 12.5, "Y Offset": -5.0, "Z Offset": 2.0}.get(key, 0.0)
res = node.evaluate_output("Settings")
self.assertIn("offset_object", res)