ng fix
This commit is contained in:
parent
98c9032f8f
commit
4fe4f4e75c
4 changed files with 193 additions and 23 deletions
|
|
@ -157,8 +157,10 @@ class SlicePaths(list[np.ndarray]):
|
||||||
extrusion_rate: list[np.ndarray]
|
extrusion_rate: list[np.ndarray]
|
||||||
feedrate: list[np.ndarray]
|
feedrate: list[np.ndarray]
|
||||||
extra_attributes: dict[str, 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)
|
super().__init__(paths)
|
||||||
|
|
||||||
self.groups = []
|
self.groups = []
|
||||||
|
|
@ -178,6 +180,8 @@ class SlicePaths(list[np.ndarray]):
|
||||||
self.feedrate = [np.zeros(len(p)) for p in paths]
|
self.feedrate = [np.zeros(len(p)) for p in paths]
|
||||||
|
|
||||||
self.extra_attributes = extra_attributes or {}
|
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:
|
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."""
|
"""Returns a new SlicePaths object with new paths/groups, preserving all metadata and instance attributes."""
|
||||||
|
|
|
||||||
|
|
@ -151,40 +151,51 @@ def node_execution_feedback(node: bpy.types.Node):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Save original color
|
# Save original color
|
||||||
old_use_color = node.use_custom_color
|
old_use_color = getattr(node, "use_custom_color", False)
|
||||||
old_color = node.color[:]
|
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
|
node.use_custom_color = True
|
||||||
|
if hasattr(node, "color"):
|
||||||
node.color = (75/255, 73/255, 19/255) # Green
|
node.color = (75/255, 73/255, 19/255) # Green
|
||||||
|
|
||||||
# Refresh UI
|
# Refresh UI
|
||||||
|
try:
|
||||||
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
|
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
with execution_cache():
|
with execution_cache():
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if hasattr(node, "use_custom_color"):
|
||||||
node.use_custom_color = old_use_color
|
node.use_custom_color = old_use_color
|
||||||
|
if hasattr(node, "color") and old_color is not None:
|
||||||
node.color = old_color
|
node.color = old_color
|
||||||
|
try:
|
||||||
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
|
bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _group_context(node: bpy.types.Node):
|
def _group_context(node: bpy.types.Node, cache: _EvalCache | None = None):
|
||||||
stack = _get_group_stack()
|
stack = _get_group_stack(cache)
|
||||||
stack.append(node)
|
stack.append(node)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
stack.pop()
|
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
|
cache = _current_execution_cache
|
||||||
if cache is None: return []
|
if cache is None: return []
|
||||||
if '_group_stack' not in cache:
|
if '_group_stack' not in cache:
|
||||||
cache['_group_stack'] = []
|
cache['_group_stack'] = []
|
||||||
return cache['_group_stack']
|
return cache['_group_stack']
|
||||||
|
|
||||||
def _get_current_group_node() -> bpy.types.Node | None:
|
def _get_current_group_node(cache: _EvalCache | None = None) -> bpy.types.Node | None:
|
||||||
stack = _get_group_stack()
|
stack = _get_group_stack(cache)
|
||||||
return stack[-1] if stack else None
|
return stack[-1] if stack else None
|
||||||
|
|
||||||
@register_class
|
@register_class
|
||||||
|
|
@ -316,21 +327,26 @@ def _resolve_group_output(socket: NodeSocket, cache: _EvalCache | None, visited:
|
||||||
|
|
||||||
inner_socket = output_node.inputs[idx]
|
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)
|
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):
|
def _resolve_group_input(socket: NodeSocket, cache: _EvalCache | None, visited: set[tuple[int, str]] | None):
|
||||||
group_node = _get_current_group_node()
|
stack = _get_group_stack(cache)
|
||||||
if not group_node: return None
|
if not stack: return None
|
||||||
|
group_node = stack.pop()
|
||||||
|
|
||||||
# NodeGroupInput outputs usually match the group inputs by index
|
# NodeGroupInput outputs usually match the group inputs by index
|
||||||
try:
|
try:
|
||||||
idx = list(socket.node.outputs).index(socket)
|
idx = list(socket.node.outputs).index(socket)
|
||||||
outer_socket = group_node.inputs[idx]
|
outer_socket = group_node.inputs[idx]
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
|
stack.append(group_node)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
return resolve_input(typing.cast(Any, outer_socket), cache=cache, visited=visited)
|
return resolve_input(typing.cast(Any, outer_socket), cache=cache, visited=visited)
|
||||||
|
finally:
|
||||||
|
stack.append(group_node)
|
||||||
|
|
||||||
def _follow_reroute(socket: NodeSocket) -> NodeSocket:
|
def _follow_reroute(socket: NodeSocket) -> NodeSocket:
|
||||||
current: NodeSocket = socket
|
current: NodeSocket = socket
|
||||||
|
|
@ -399,7 +415,7 @@ def _resolve_output(
|
||||||
|
|
||||||
assert socket.node
|
assert socket.node
|
||||||
# Include group stack in cache key to ensure independent evaluation for multiple instances
|
# 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
|
# 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.
|
# (especially in multi-groups) can share the same name but must have unique identifiers.
|
||||||
|
|
@ -413,9 +429,16 @@ def _resolve_output(
|
||||||
try:
|
try:
|
||||||
with node_execution_feedback(socket.node):
|
with node_execution_feedback(socket.node):
|
||||||
if not isinstance(socket.node, SliceNode):
|
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)
|
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 _resolve_group_input(socket, cache=cache, visited=visited)
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
|
||||||
143
tests/test_node_groups.py
Normal file
143
tests/test_node_groups.py
Normal 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()
|
||||||
|
|
@ -24,7 +24,7 @@ class TestWipeTowerNode(unittest.TestCase):
|
||||||
|
|
||||||
def test_offset_object_node_output(self):
|
def test_offset_object_node_output(self):
|
||||||
node = SLICENODE_OffsetObject()
|
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")
|
res = node.evaluate_output("Settings")
|
||||||
self.assertIn("offset_object", res)
|
self.assertIn("offset_object", res)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue