From 4fe4f4e75cf88298c18e8ff46eaf895d83478427 Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Tue, 28 Jul 2026 19:04:11 +0300 Subject: [PATCH] ng fix --- core/slice.py | 6 +- infra/blender/blender_slice_nodes_generic.py | 65 ++++++--- tests/test_node_groups.py | 143 +++++++++++++++++++ tests/test_wipe_tower.py | 2 +- 4 files changed, 193 insertions(+), 23 deletions(-) create mode 100644 tests/test_node_groups.py diff --git a/core/slice.py b/core/slice.py index f16aaf7..d4551be 100644 --- a/core/slice.py +++ b/core/slice.py @@ -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.""" diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index d0e448f..5e6dbf5 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -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 - node.use_custom_color = True - node.color = (75/255, 73/255, 19/255) # Green + 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 - bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + try: + bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + except Exception: + pass try: with execution_cache(): yield finally: - node.use_custom_color = old_use_color - node.color = old_color - bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) + 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]: - cache = _current_execution_cache +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 - return resolve_input(typing.cast(Any, outer_socket), cache=cache, visited=visited) + 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 diff --git a/tests/test_node_groups.py b/tests/test_node_groups.py new file mode 100644 index 0000000..9171acf --- /dev/null +++ b/tests/test_node_groups.py @@ -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() diff --git a/tests/test_wipe_tower.py b/tests/test_wipe_tower.py index e805411..87f522a 100644 --- a/tests/test_wipe_tower.py +++ b/tests/test_wipe_tower.py @@ -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)