flatten paths

This commit is contained in:
Nicolas Predella 2026-10-14 17:59:21 +03:00
parent 31a86fbb73
commit 1604e6da23
4 changed files with 112 additions and 0 deletions

View file

@ -14,6 +14,54 @@ class SLICENODE_PathsToList(SliceNodeListJoiner):
socket_type = "SLICESOCK_Paths" socket_type = "SLICESOCK_Paths"
socket_label = "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 @register_class
class SLICENODE_GetPaths(SliceNode): class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths" bl_label = "Get Paths"

View file

@ -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="Infill").type = "SLICENODE_Infill"
layout.operator("node.add_node", text="Overhang Mask").type = "SLICENODE_OverhangMask" 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="Path Operations").type = "SLICENODE_PathOperations"
layout.operator("node.add_node", text="Flatten Path List").type = "SLICENODE_FlattenPathList"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath" layout.operator("node.add_node", text="Math").type = "SLICENODE_FloatMath"
layout.operator("node.add_node", text="Integer Math").type = "SLICENODE_IntMath" 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="Objects to List").type = "SLICENODE_ObjectsToList"
layout.operator("node.add_node", text="Meshes to List").type = "SLICENODE_MeshesToList" 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="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.operator("node.add_node", text="Gcodes to List").type = "SLICENODE_GcodesToList"
layout.separator() layout.separator()
layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh"

View file

@ -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. - **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. - **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. - **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. - **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. - **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. - **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.

View file

@ -472,6 +472,64 @@ class TestNodeEvaluationsWithLists(unittest.TestCase):
self.assertEqual(set(recovered_group.collections.keys()), {"Part1", "Part2"}) self.assertEqual(set(recovered_group.collections.keys()), {"Part1", "Part2"})
self.assertEqual(recovered_group.transforms, orig_group.transforms) 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): def test_join_mesh_flattens_lists(self):
from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh