diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index a78904c..a0f1fbc 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -14,6 +14,54 @@ class SLICENODE_PathsToList(SliceNodeListJoiner): socket_type = "SLICESOCK_Paths" socket_label = "Paths" +@register_class +class SLICENODE_FlattenPathList(SliceNode): + bl_label = "Flatten Path List" + always_list_inputs = ["Paths"] + + mode: str = NfEnumProperty( + name="Mode", + items=[ + ('ZIP', "Zip", "Interleave paths layer by layer across all list items"), + ('EXTEND', "Extend", "Append paths sequentially from all list items"), + ], + default='ZIP', + update=notify_tree_changed, + ) + + def init(self, context): + self.add_input("SLICESOCK_Paths", "Paths", display_shape='LIST') + self.add_output("SLICESOCK_Paths", "Paths") + + def draw_buttons(self, context, layout): + assert layout + layout.prop(self, "mode", text="") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + val = self.resolve("Paths") + if val is None: + return SlicePaths() + + if not is_list_data(val): + if isinstance(val, SlicePaths) or getattr(val, "is_slice_paths", False): + return val + return SlicePaths() + + raw_items = flatten_list(val) + paths_list: list[SlicePaths] = [ + item for item in raw_items + if isinstance(item, SlicePaths) or getattr(item, "is_slice_paths", False) + ] + + if not paths_list: + return SlicePaths() + + mode = str(getattr(self, "mode", "ZIP")).upper() + if mode == "EXTEND": + return SlicePaths.extend_paths(paths_list) + else: + return SlicePaths.zip_paths(paths_list) + @register_class class SLICENODE_GetPaths(SliceNode): bl_label = "Get Paths" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 2cc11f4..0b597d6 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -826,6 +826,7 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Infill").type = "SLICENODE_Infill" layout.operator("node.add_node", text="Overhang Mask").type = "SLICENODE_OverhangMask" layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations" + layout.operator("node.add_node", text="Flatten Path List").type = "SLICENODE_FlattenPathList" layout.separator() layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" @@ -869,6 +870,7 @@ class SLICENODE_MT_Joiners(Menu): layout.operator("node.add_node", text="Objects to List").type = "SLICENODE_ObjectsToList" layout.operator("node.add_node", text="Meshes to List").type = "SLICENODE_MeshesToList" layout.operator("node.add_node", text="Paths to List").type = "SLICENODE_PathsToList" + layout.operator("node.add_node", text="Flatten Path List").type = "SLICENODE_FlattenPathList" layout.operator("node.add_node", text="Gcodes to List").type = "SLICENODE_GcodesToList" layout.separator() layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" diff --git a/doc/NODES.md b/doc/NODES.md index 1bf12bd..ecea8fc 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -195,6 +195,10 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Objects to List**: Multi-input list joiner that bundles objects into a `list[bpy.types.Object]` with LIST socket shape. Flattens incoming lists. - **Meshes to List**: Multi-input list joiner that bundles meshes into a `list[SlicingCollection]` with LIST socket shape. Flattens incoming lists. - **Paths to List**: Multi-input list joiner that bundles toolpaths into a `list[SlicePaths]` with LIST socket shape. Flattens incoming lists. +- **Flatten Path List**: Flattens a `list[SlicePaths]` into a single `SlicePaths`. + - **Mode**: Choose between **Zip** (interleaves paths layer by layer across all list items) and **Extend** (appends paths sequentially from all list items). + - **Paths**: Input socket accepting a list of toolpaths (`display_shape='LIST'`). + - **Paths**: Output socket delivering the unified single toolpath. - **Gcodes to List**: Multi-input list joiner that bundles G-code strings into a `list[str]` with LIST socket shape. Flattens incoming lists. - **Group to List**: Decomposes a `SlicingGroup` into a `list[SlicingCollection]`. Output socket `Collections` displays LIST socket shape. - **List to Group**: Converts a `list[SlicingCollection]` into a single `SlicingGroup`. Input socket `Collections` has LIST socket shape, output socket `Group` outputs a single group. diff --git a/tests/test_lists.py b/tests/test_lists.py index 5d99658..15aa020 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -472,6 +472,64 @@ class TestNodeEvaluationsWithLists(unittest.TestCase): self.assertEqual(set(recovered_group.collections.keys()), {"Part1", "Part2"}) self.assertEqual(recovered_group.transforms, orig_group.transforms) + def test_flatten_path_list(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_FlattenPathList + + node = SLICENODE_FlattenPathList() + self.assertEqual(node.always_list_inputs, ["Paths"]) + + # Construct sp1: layer 0 (Z=0.2), layer 1 (Z=0.4) + sp1 = SlicePaths([ + np.array([[0, 0, 0.2], [10, 0, 0.2]], dtype=np.float64), + np.array([[0, 0, 0.4], [10, 0, 0.4]], dtype=np.float64), + ], groups=[0, 1]) + + # Construct sp2: layer 0 (Z=0.2), layer 1 (Z=0.4) + sp2 = SlicePaths([ + np.array([[20, 0, 0.2], [30, 0, 0.2]], dtype=np.float64), + np.array([[20, 0, 0.4], [30, 0, 0.4]], dtype=np.float64), + ], groups=[0, 1]) + + # 1. Mode ZIP: layer-by-layer interleaving + node.mode = 'ZIP' + node.resolve = lambda label: [sp1, sp2] + res_zip = node.evaluate_output("Paths") + self.assertIsInstance(res_zip, SlicePaths) + self.assertEqual(len(res_zip), 4) + self.assertEqual(list(res_zip.groups), [0, 0, 1, 1]) + np.testing.assert_allclose(res_zip[0], sp1[0]) + np.testing.assert_allclose(res_zip[1], sp2[0]) + np.testing.assert_allclose(res_zip[2], sp1[1]) + np.testing.assert_allclose(res_zip[3], sp2[1]) + + # 2. Mode EXTEND: sequential appending + node.mode = 'EXTEND' + node.resolve = lambda label: [sp1, sp2] + res_ext = node.evaluate_output("Paths") + self.assertIsInstance(res_ext, SlicePaths) + self.assertEqual(len(res_ext), 4) + self.assertEqual(list(res_ext.groups), [0, 1, 0, 1]) + np.testing.assert_allclose(res_ext[0], sp1[0]) + np.testing.assert_allclose(res_ext[1], sp1[1]) + np.testing.assert_allclose(res_ext[2], sp2[0]) + np.testing.assert_allclose(res_ext[3], sp2[1]) + + # 3. Nested list input flattens properly + node.resolve = lambda label: [[sp1], [sp2]] + res_nested = node.evaluate_output("Paths") + self.assertEqual(len(res_nested), 4) + + # 4. Single SlicePaths input returns the object intact + node.resolve = lambda label: sp1 + res_single = node.evaluate_output("Paths") + self.assertEqual(res_single, sp1) + + # 5. None or empty list input returns empty SlicePaths + node.resolve = lambda label: None + self.assertEqual(len(node.evaluate_output("Paths")), 0) + node.resolve = lambda label: [] + self.assertEqual(len(node.evaluate_output("Paths")), 0) + def test_join_mesh_flattens_lists(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh