diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index 0f76eae..3227d70 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -100,6 +100,99 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner): return group +@register_class +class SLICENODE_GroupToList(SliceNode): + bl_label = "Group to List" + always_list_outputs = ["Collections"] + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Group") + self.add_output("SLICESOCK_Mesh", "Collections", display_shape='LIST') + + def evaluate_output(self, socket_name: str, **_kwargs) -> list[SlicingCollection]: + val = self.resolve("Group") + if val is None: + val = self.resolve("Collections") + if val is None and hasattr(self, "inputs") and len(self.inputs) > 0: + val = self.resolve(self.inputs[0].name) + if val is None: + return [] + + if isinstance(val, SlicingGroup): + return list(val.collections.values()) + + if isinstance(val, (list, tuple)): + res: list[SlicingCollection] = [] + for item in val: + if isinstance(item, SlicingGroup): + res.extend(item.collections.values()) + elif isinstance(item, SlicingCollection): + res.append(item) + elif isinstance(item, SlicingObject): + res.append(SlicingCollection([item], getattr(item, "name", "Collection"))) + return res + + if isinstance(val, SlicingCollection): + return [val] + + if isinstance(val, SlicingObject): + return [SlicingCollection([val], getattr(val, "name", "Collection"))] + + return [] + +@register_class +class SLICENODE_ListToGroup(SliceNode): + bl_label = "List to Group" + always_list_inputs = ["Collections"] + + def init(self, context): + self.add_input("SLICESOCK_Mesh", "Collections", display_shape='LIST') + self.add_output("SLICESOCK_Mesh", "Group") + + def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup: + val = self.resolve("Collections") + if val is None: + val = self.resolve("Group") + if val is None and hasattr(self, "inputs") and len(self.inputs) > 0: + val = self.resolve(self.inputs[0].name) + if val is None: + return SlicingGroup([]) + + if isinstance(val, SlicingGroup): + return val + + items = val if isinstance(val, (list, tuple)) else [val] + group: SlicingGroup = SlicingGroup([]) + + for idx, item in enumerate(items): + if not item: + continue + if isinstance(item, SlicingGroup): + if hasattr(item, "transforms") and item.transforms and not group.transforms: + group.transforms = list(item.transforms) + for k, sc in item.collections.items(): + name = k + if name in group.collections: + name = f"{name}_{idx}" + group.collections[name] = sc + elif isinstance(item, SlicingCollection): + if hasattr(item, "transforms") and item.transforms and not group.transforms: + group.transforms = list(item.transforms) + name = item.name or f"collection_{idx}" + if name in group.collections: + name = f"{name}_{idx}" + group.collections[name] = item + elif isinstance(item, SlicingObject): + if hasattr(item, "transforms") and item.transforms and not group.transforms: + group.transforms = list(item.transforms) + name = getattr(item, "name", f"object_{idx}") + if name in group.collections: + name = f"{name}_{idx}" + sc = SlicingCollection([item], name) + group.collections[name] = sc + + return group + @register_class class SLICENODE_SeparateMesh(SliceNode): bl_label = "Separate Mesh" diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 92eb4e3..2cc11f4 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -808,6 +808,8 @@ class SLICENODE_MT_Geometry(Menu): layout.operator("node.add_node", text="Separate Mesh").type = "SLICENODE_SeparateMesh" layout.operator("node.add_node", text="Separate Mesh by Range").type = "SLICENODE_SeparateMeshByRange" layout.operator("node.add_node", text="Arrange Slicing Group").type = "SLICENODE_ArrangeMesh" + layout.operator("node.add_node", text="Group to List").type = "SLICENODE_GroupToList" + layout.operator("node.add_node", text="List to Group").type = "SLICENODE_ListToGroup" layout.operator("node.add_node", text="Get Paths").type = "SLICENODE_GetPaths" layout.separator() layout.operator("node.add_node", text="Layers").type = "SLICENODE_Layers" diff --git a/doc/NODES.md b/doc/NODES.md index 1e3d825..1bf12bd 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -196,6 +196,8 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **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. - **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. - **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`. Flattens incoming lists. - **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`. Flattens incoming lists. - **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations. The first input can be a list, in which case the operation runs per-element against the same secondary inputs. diff --git a/tests/test_lists.py b/tests/test_lists.py index 4596bfa..5d99658 100644 --- a/tests/test_lists.py +++ b/tests/test_lists.py @@ -375,6 +375,103 @@ class TestNodeEvaluationsWithLists(unittest.TestCase): g_node.resolve_multi_input = lambda label: [["G1 X1", "G1 X2"], "G1 X3", ["G1 X4", "G1 X5"]] self.assertEqual(g_node.evaluate_output("Gcode"), ["G1 X1", "G1 X2", "G1 X3", "G1 X4", "G1 X5"]) + def test_group_to_list(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GroupToList + + sc1 = SlicingCollection([], "PartA") + sc2 = SlicingCollection([], "PartB") + group = SlicingGroup([]) + group.collections = {"PartA": sc1, "PartB": sc2} + + node = SLICENODE_GroupToList() + self.assertEqual(node.always_list_outputs, ["Collections"]) + + # 1. Normal conversion: SlicingGroup -> list[SlicingCollection] + node.resolve = lambda label: group + res = node.evaluate_output("Collections") + self.assertIsInstance(res, list) + self.assertEqual(len(res), 2) + self.assertEqual(res, [sc1, sc2]) + + # 2. None input -> empty list + node.resolve = lambda label: None + self.assertEqual(node.evaluate_output("Collections"), []) + + # 3. Single collection passed directly -> [sc1] + node.resolve = lambda label: sc1 + self.assertEqual(node.evaluate_output("Collections"), [sc1]) + + # 4. List of groups -> flattened list of collections + group2 = SlicingGroup([]) + sc3 = SlicingCollection([], "PartC") + group2.collections = {"PartC": sc3} + node.resolve = lambda label: [group, group2] + res_multi = node.evaluate_output("Collections") + self.assertEqual(res_multi, [sc1, sc2, sc3]) + + def test_list_to_group(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_ListToGroup + + sc1 = SlicingCollection([], "PartA") + sc2 = SlicingCollection([], "PartB") + + node = SLICENODE_ListToGroup() + self.assertEqual(node.always_list_inputs, ["Collections"]) + + # 1. Normal conversion: list[SlicingCollection] -> SlicingGroup + node.resolve = lambda label: [sc1, sc2] + res = node.evaluate_output("Group") + self.assertIsInstance(res, SlicingGroup) + self.assertEqual(len(res.collections), 2) + self.assertIs(res.collections.get("PartA"), sc1) + self.assertIs(res.collections.get("PartB"), sc2) + + # 2. None input -> empty SlicingGroup + node.resolve = lambda label: None + res_empty = node.evaluate_output("Group") + self.assertIsInstance(res_empty, SlicingGroup) + self.assertEqual(len(res_empty.collections), 0) + + # 3. Single collection -> SlicingGroup with 1 collection + node.resolve = lambda label: sc1 + res_single = node.evaluate_output("Group") + self.assertIsInstance(res_single, SlicingGroup) + self.assertEqual(len(res_single.collections), 1) + self.assertIs(res_single.collections.get("PartA"), sc1) + + # 4. Duplicate collection names are handled without overwriting + dup1 = SlicingCollection([], "Part") + dup2 = SlicingCollection([], "Part") + node.resolve = lambda label: [dup1, dup2] + res_dup = node.evaluate_output("Group") + self.assertEqual(len(res_dup.collections), 2) + self.assertIn("Part", res_dup.collections) + self.assertIn("Part_1", res_dup.collections) + + def test_group_to_list_and_list_to_group_roundtrip(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_GroupToList, SLICENODE_ListToGroup + + sc1 = SlicingCollection([], "Part1") + sc1.transforms = [{"type": "INIT", "translation": (1.0, 2.0, 3.0), "rotation": (0.0, 0.0, 0.0), "scale": (1.0, 1.0, 1.0)}] + sc2 = SlicingCollection([], "Part2") + orig_group = SlicingGroup([]) + orig_group.collections = {"Part1": sc1, "Part2": sc2} + orig_group.transforms = list(sc1.transforms) + + g2l = SLICENODE_GroupToList() + g2l.resolve = lambda label: orig_group + colls_list = g2l.evaluate_output("Collections") + self.assertIsInstance(colls_list, list) + self.assertEqual(len(colls_list), 2) + + l2g = SLICENODE_ListToGroup() + l2g.resolve = lambda label: colls_list + recovered_group = l2g.evaluate_output("Group") + self.assertIsInstance(recovered_group, SlicingGroup) + self.assertEqual(len(recovered_group.collections), 2) + self.assertEqual(set(recovered_group.collections.keys()), {"Part1", "Part2"}) + self.assertEqual(recovered_group.transforms, orig_group.transforms) + def test_join_mesh_flattens_lists(self): from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh