diff --git a/core/nodeslicer/nodes/output/gcode_to_file.py b/core/nodeslicer/nodes/output/gcode_to_file.py index c519f9f..9b92d25 100644 --- a/core/nodeslicer/nodes/output/gcode_to_file.py +++ b/core/nodeslicer/nodes/output/gcode_to_file.py @@ -13,7 +13,7 @@ class SLICENODE_GcodeToFile(ExecSliceNode): def init(self, context): self._init_exec() - self.add_input("NodeSocketString", "Folder") + self.add_input("NodeSocketString", "Folder", subtype='DIR_PATH') self.add_input("NodeSocketString", "Name") self.add_multi_socket_group("NodeSocketString", name="Gcode") diff --git a/core/nodeslicer/nodes/output/mesh_to_pdf.py b/core/nodeslicer/nodes/output/mesh_to_pdf.py index 697a736..664e9cf 100644 --- a/core/nodeslicer/nodes/output/mesh_to_pdf.py +++ b/core/nodeslicer/nodes/output/mesh_to_pdf.py @@ -91,7 +91,7 @@ class SLICENODE_MeshToPdf(ExecSliceNode): def init(self, context): self._init_exec() - self.add_input("SLICESOCK_DirPath", "Folder") + self.add_input("NodeSocketString", "Folder", subtype='DIR_PATH') self.add_input("SLICESOCK_Settings", "Settings") self.add_input("SLICESOCK_Mesh", "Geometry") self.add_input("SLICESOCK_Paths", "Paths") diff --git a/core/nodeslicer/sockets.py b/core/nodeslicer/sockets.py index effc924..bb7fe23 100644 --- a/core/nodeslicer/sockets.py +++ b/core/nodeslicer/sockets.py @@ -15,6 +15,19 @@ class LabelNodeSocket(SliceNodeSocket): layout.label(text=text or self.name) class InputNodeSocket(SliceNodeSocket): + def __init__(self, *args: Any, **kwargs: Any): + try: + super().__init__(*args, **kwargs) + except TypeError: + super().__init__() + if getattr(self, "value", None) is None: + default_val = getattr(self, "default", None) + if default_val is not None: + try: + self.value = default_val + except (AttributeError, TypeError): + pass + def draw(self, context: Any, layout: Any, node: Any, text: str | None): assert layout if self.is_output or self.is_linked: @@ -61,6 +74,20 @@ class SLICESOCK_Intrinsics(LabelNodeSocket): default = None bl_color = (0.2, 0.2, 0.2, 1.0) +@register_class +class SLICESOCK_FilePath(InputNodeSocket): + bl_label = "Filepath" + default = "" + value: str = NfStringProperty(name="Filepath", default="", subtype='FILE_PATH') # pyright: ignore[reportInvalidTypeForm] + bl_color = (0.4, 0.7, 1.0, 1.0) + +@register_class +class SLICESOCK_DirPath(InputNodeSocket): + bl_label = "Folder" + default = "" + value: str = NfStringProperty(name="Folder", default="", subtype='DIR_PATH') # pyright: ignore[reportInvalidTypeForm] + bl_color = (0.4, 0.7, 1.0, 1.0) + @register_class class SettingItem(bpy.types.PropertyGroup): field: str = NfStringProperty(name="Field", default="") diff --git a/doc/MODULES.md b/doc/MODULES.md index a34107e..cec1661 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -98,7 +98,8 @@ This file provides a mapping of modules to their primary responsibilities and ke - `blender_slice_nodes_generic.py`: - Base classes for NodeSlicer nodes (`SliceNode`) and sockets (`SliceNodeSocket`). - - Native Blender socket type mappings (`NodeSocketFloat`, `NodeSocketInt`, `NodeSocketVector`, `NodeSocketRotation`, `NodeSocketString`, `NodeSocketObject`, `NodeSocketGeometry`). + - Native Blender socket type mappings (`NodeSocketFloat`, `NodeSocketInt`, `NodeSocketVector`, `NodeSocketRotation`, `NodeSocketString`, `NodeSocketObject`, `NodeSocketGeometry`) and custom path picker sockets (`SLICESOCK_DirPath`, `SLICESOCK_FilePath`). + - Socket creation utilities (`add_input`, `add_output`) with support for `subtype='DIR_PATH'` (folder picker) and `subtype='FILE_PATH'` (file picker). - Core evaluation logic (`resolve_input`, `_resolve_output`). - `debug.py`: diff --git a/doc/NODES.md b/doc/NODES.md index bdae351..76f246a 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -7,7 +7,9 @@ | `NodeSocketObject` | `bpy.types.Object` | Blender RNA Object | Reference to a Blender Object | | `NodeSocketFloat` | `float` | Python float | Numeric value | | `NodeSocketInt` | `int` | Python int | Integer value | -| `NodeSocketString` | `str` | Python string | Text, G-code, file/folder path value | +| `NodeSocketString` | `str` | Python string | Text, G-code value | +| `SLICESOCK_DirPath` | `str` | Python string (`DIR_PATH`) | Directory path value with folder picker | +| `SLICESOCK_FilePath` | `str` | Python string (`FILE_PATH`) | File path value with file picker | | `NodeSocketVector` | `tuple` | Python tuple (3) | XYZ vector value | | `NodeSocketRotation` | `tuple` | Python tuple (3) | Euler rotation value | | `NodeSocketGeometry` | `Geometry` | Geometry Data | Generic geometry reference | diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 6b9ca1e..04ed7f6 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -23,6 +23,8 @@ NATIVE_SOCKET_LABELS: dict[str, str] = { "NodeSocketRotation": "Rotation", "NodeSocketString": "String", "NodeSocketObject": "Object", + "SLICESOCK_DirPath": "Folder", + "SLICESOCK_FilePath": "Filepath", } if hasattr(bpy.types, "NodeSocket") and not hasattr(bpy.types.NodeSocket, "value"): @@ -169,7 +171,13 @@ class SliceNode(Node, AutoBlIdName): is_atomic=is_atomic, ) - def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = ""): + def add_output(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = "", subtype: str = ""): + if subtype: + st = subtype.upper() + if st in ("DIR_PATH", "DIRECTORY", "DIR", "FOLDER"): + bl_idname = "SLICESOCK_DirPath" + elif st in ("FILE_PATH", "FILE", "FILENAME"): + bl_idname = "SLICESOCK_FilePath" bl_idname = NATIVE_SOCKET_MAP.get(bl_idname, bl_idname) eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.outputs.new(bl_idname, eval_name, identifier=identifier or eval_name) @@ -186,7 +194,13 @@ class SliceNode(Node, AutoBlIdName): pass return sock - def add_input(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = ""): + def add_input(self, bl_idname: str, name: str = "", identifier: str = "", hide: bool = False, display_shape: str = "", description: str = "", subtype: str = ""): + if subtype: + st = subtype.upper() + if st in ("DIR_PATH", "DIRECTORY", "DIR", "FOLDER"): + bl_idname = "SLICESOCK_DirPath" + elif st in ("FILE_PATH", "FILE", "FILENAME"): + bl_idname = "SLICESOCK_FilePath" bl_idname = NATIVE_SOCKET_MAP.get(bl_idname, bl_idname) eval_name = name or NATIVE_SOCKET_LABELS.get(bl_idname) or get_label_by_name(bl_idname) or bl_idname sock = self.inputs.new(bl_idname, eval_name, identifier=identifier or eval_name) diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py index 99997d7..b057639 100644 --- a/infra/blender/list_utils.py +++ b/infra/blender/list_utils.py @@ -9,6 +9,8 @@ LIST_CAPABLE_SOCKET_TYPES = { "SLICESOCK_Geometry", "SLICESOCK_Intrinsics", "SLICESOCK_Settings", + "SLICESOCK_DirPath", + "SLICESOCK_FilePath", "NodeSocketObject", "NodeSocketString", "NodeSocketVector", @@ -237,7 +239,7 @@ def is_socket_vector_type(node: Any, socket_name: str) -> bool: bl_id = getattr(sock, "bl_idname", "") if any(term in bl_id for term in ("Vector", "Rotation", "Color", "Euler")): return True - if bl_id in ("NodeSocketFloat", "NodeSocketInt", "NodeSocketString", "NodeSocketBool", "SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Settings"): + if bl_id in ("NodeSocketFloat", "NodeSocketInt", "NodeSocketString", "NodeSocketBool", "SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Settings", "SLICESOCK_DirPath", "SLICESOCK_FilePath"): return False return False diff --git a/tests/test_path_sockets.py b/tests/test_path_sockets.py new file mode 100644 index 0000000..2b32191 --- /dev/null +++ b/tests/test_path_sockets.py @@ -0,0 +1,173 @@ +from __future__ import annotations +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Ensure mock bpy is set up +from tests.test_lists import mock_bpy + +from NodeSlicer.core.nodeslicer.sockets import SLICESOCK_DirPath, SLICESOCK_FilePath, InputNodeSocket +from NodeSlicer.infra.blender.blender_slice_nodes_generic import SliceNode, resolve_input +from NodeSlicer.core.nodeslicer.nodes.output.gcode_to_file import SLICENODE_GcodeToFile +from NodeSlicer.core.nodeslicer.nodes.output.mesh_to_pdf import SLICENODE_MeshToPdf +from NodeSlicer.infra.blender.list_utils import LIST_CAPABLE_SOCKET_TYPES + + +class TestPathSockets(unittest.TestCase): + def test_socket_definitions(self): + """Test that SLICESOCK_DirPath and SLICESOCK_FilePath are properly defined subclasses.""" + self.assertTrue(issubclass(SLICESOCK_DirPath, InputNodeSocket)) + self.assertTrue(issubclass(SLICESOCK_FilePath, InputNodeSocket)) + + dir_sock = SLICESOCK_DirPath() + file_sock = SLICESOCK_FilePath() + + self.assertEqual(dir_sock.default, "") + self.assertEqual(file_sock.default, "") + self.assertEqual(dir_sock.value, "") + self.assertEqual(file_sock.value, "") + + dir_sock.value = "/custom/directory/path" + file_sock.value = "/custom/file/path.gcode" + self.assertEqual(dir_sock.value, "/custom/directory/path") + self.assertEqual(file_sock.value, "/custom/file/path.gcode") + + def test_list_capable_socket_types(self): + """Ensure path sockets are recognized as valid socket types in list utilities.""" + self.assertIn("SLICESOCK_DirPath", LIST_CAPABLE_SOCKET_TYPES) + self.assertIn("SLICESOCK_FilePath", LIST_CAPABLE_SOCKET_TYPES) + + def test_add_input_subtype_mapping(self): + """Test that add_input maps subtype='DIR_PATH' and subtype='FILE_PATH' to the appropriate socket.""" + class DummyNode(SliceNode): + def __init__(self): + self.inputs = MagicMock() + self.outputs = MagicMock() + + node = DummyNode() + node.add_input("NodeSocketString", "Folder", subtype="DIR_PATH") + node.inputs.new.assert_called_with("SLICESOCK_DirPath", "Folder", identifier="Folder") + + node.add_input("NodeSocketString", "File", subtype="FILE_PATH") + node.inputs.new.assert_called_with("SLICESOCK_FilePath", "File", identifier="File") + + # Case-insensitive and alias tests + node.add_input("NodeSocketString", "Dir", subtype="folder") + node.inputs.new.assert_called_with("SLICESOCK_DirPath", "Dir", identifier="Dir") + + node.add_input("NodeSocketString", "Doc", subtype="file") + node.inputs.new.assert_called_with("SLICESOCK_FilePath", "Doc", identifier="Doc") + + # Standard string without subtype remains NodeSocketString + node.add_input("NodeSocketString", "Name") + node.inputs.new.assert_called_with("NodeSocketString", "Name", identifier="Name") + + # Direct socket type specification + node.add_input("SLICESOCK_DirPath", "TargetDir") + node.inputs.new.assert_called_with("SLICESOCK_DirPath", "TargetDir", identifier="TargetDir") + + def test_add_output_subtype_mapping(self): + """Test that add_output also supports subtype mapping.""" + class DummyNode(SliceNode): + def __init__(self): + self.inputs = MagicMock() + self.outputs = MagicMock() + + node = DummyNode() + node.add_output("NodeSocketString", "OutFolder", subtype="DIR_PATH") + node.outputs.new.assert_called_with("SLICESOCK_DirPath", "OutFolder", identifier="OutFolder") + + node.add_output("NodeSocketString", "OutFile", subtype="FILE_PATH") + node.outputs.new.assert_called_with("SLICESOCK_FilePath", "OutFile", identifier="OutFile") + + def test_nodes_use_dir_path_socket(self): + """Verify that GcodeToFile and MeshToPdf configure the Folder socket with DIR_PATH subtype.""" + class MockSocketCollection: + def __init__(self): + self.created = [] + + def new(self, bl_idname, name, identifier=""): + mock_sock = MagicMock() + mock_sock.bl_idname = bl_idname + mock_sock.name = name + mock_sock.identifier = identifier or name + self.created.append((bl_idname, name, identifier, mock_sock)) + return mock_sock + + def __iter__(self): + return iter([item[3] for item in self.created]) + + def get(self, name): + for bl_id, n, ident, s in self.created: + if n == name: + return s + return None + + class InspectGcodeToFile(SLICENODE_GcodeToFile): + def __init__(self): + self.inputs = MockSocketCollection() + self.outputs = MockSocketCollection() + self._data = {} + + class InspectMeshToPdf(SLICENODE_MeshToPdf): + def __init__(self): + self.inputs = MockSocketCollection() + self.outputs = MockSocketCollection() + self._data = {} + + gcode_node = InspectGcodeToFile() + gcode_node.init(None) + folder_in_gcode = next((bl_id for bl_id, name, _, _ in gcode_node.inputs.created if name == "Folder"), None) + self.assertEqual(folder_in_gcode, "SLICESOCK_DirPath") + + pdf_node = InspectMeshToPdf() + pdf_node.init(None) + folder_in_pdf = next((bl_id for bl_id, name, _, _ in pdf_node.inputs.created if name == "Folder"), None) + self.assertEqual(folder_in_pdf, "SLICESOCK_DirPath") + + def test_resolve_input_with_path_socket(self): + """Verify resolve_input behavior for unlinked and linked path sockets.""" + class DummySocket: + def __init__(self, name, bl_idname, value=""): + self.name = name + self.bl_idname = bl_idname + self.identifier = name + self.value = value + self.default = "" + self.is_linked = False + self.links = [] + self.node = MagicMock() + + sock = DummySocket("Folder", "SLICESOCK_DirPath", value="") + # Empty value returns default empty string + self.assertEqual(resolve_input(sock), "") + + # Set value + sock.value = "/path/to/folder" + self.assertEqual(resolve_input(sock), "/path/to/folder") + + # Linked socket follows upstream + class UpstreamNode(SliceNode): + def evaluate_output(self, socket_name, **kwargs): + return "/upstream/linked/path" + + upstream = UpstreamNode() + from_socket = MagicMock() + from_socket.node = upstream + from_socket.name = "String Out" + from_socket.identifier = "String Out" + mock_link = MagicMock() + mock_link.from_socket = from_socket + + sock.is_linked = True + sock.links = [mock_link] + + self.assertEqual(resolve_input(sock), "/upstream/linked/path") + + +if __name__ == "__main__": + unittest.main()