From 47e66bb0306e13a584ef619423e279f9c5d2d87b Mon Sep 17 00:00:00 2001 From: Nicolas Predella Date: Mon, 12 Oct 2026 16:23:02 +0300 Subject: [PATCH] list support --- core/nodeslicer/nodes/base.py | 39 ++ core/nodeslicer/nodes/gcode.py | 149 ++-- core/nodeslicer/nodes/inputs.py | 9 +- core/nodeslicer/nodes/mesh.py | 371 +++++----- core/nodeslicer/nodes/output.py | 75 ++- core/nodeslicer/nodes/path.py | 478 +++++++------ core/nodeslicer/nodes/preview.py | 11 +- core/nodeslicer/nodes/system.py | 5 + core/nodeslicer/tree.py | 58 +- core/slice.py | 1 + doc/MODULES.md | 6 + doc/NODES.md | 12 +- doc/nodes_list_behavior.csv | 6 +- infra/blender/blender_slice_nodes_generic.py | 26 + infra/blender/list_utils.py | 563 ++++++++++++++++ tests/test_lists.py | 671 +++++++++++++++++++ 16 files changed, 2045 insertions(+), 435 deletions(-) create mode 100644 infra/blender/list_utils.py create mode 100644 tests/test_lists.py diff --git a/core/nodeslicer/nodes/base.py b/core/nodeslicer/nodes/base.py index e5a287c..734a557 100644 --- a/core/nodeslicer/nodes/base.py +++ b/core/nodeslicer/nodes/base.py @@ -134,6 +134,45 @@ class SliceNodeJoiner(SliceNode): self.add_output(self.output_socket_type or self.socket_type, name=self.socket_label) self.add_multi_socket_group(self.socket_type, name=self.socket_label) + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + for inp in self.inputs: + if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_"): + is_list = is_socket_linked_to_list(inp) + set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + +class SliceNodeListJoiner(SliceNode): + socket_type: str = "" + output_socket_type: str = "" + socket_label: str = "" + output_socket_name: str = "" + is_list_output: bool = True + + def init(self, context): + self.add_output(self.output_socket_type or self.socket_type, name=self.output_socket_name or self.socket_label, display_shape='LIST') + self.add_multi_socket_group(self.socket_type, name=self.socket_label) + + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + for inp in self.inputs: + if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_"): + is_list = is_socket_linked_to_list(inp) + set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + + def evaluate_output(self, socket_name: str, **kwargs) -> list[Any]: + from ....infra.blender.list_utils import flatten_list, is_list_data + items: list[Any] = [] + raw_inputs = self.resolve_multi_input(self.socket_label) + for val in raw_inputs: + if val is not None: + if is_list_data(val): + items.extend(flatten_list(val)) + else: + items.append(val) + return items + class ExecSliceNode(SliceNode): button_label: str = 'Execute' diff --git a/core/nodeslicer/nodes/gcode.py b/core/nodeslicer/nodes/gcode.py index 704882a..a16b83e 100644 --- a/core/nodeslicer/nodes/gcode.py +++ b/core/nodeslicer/nodes/gcode.py @@ -1,15 +1,24 @@ from __future__ import annotations +from typing import Any import numpy as np import bpy import os from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty from ....registry import register_class -from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner +from .base import SliceNode, SliceIntrinsics, Pather, SliceNodeJoiner, SliceNodeListJoiner +from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data + +@register_class +class SLICENODE_GcodesToList(SliceNodeListJoiner): + bl_label = "Gcodes to List" + socket_type = "NodeSocketString" + socket_label = "Gcode" @register_class class SLICENODE_PathsToGcode(SliceNode): bl_label = "Paths to Gcode" + list_socket_mapping = [("Paths", "Gcode")] use_header: bool = NfBoolProperty(name="Header", default=True) @@ -22,30 +31,35 @@ class SLICENODE_PathsToGcode(SliceNode): assert layout layout.prop(self, "use_header") - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - paths: list[np.ndarray] = self.resolve("Paths") + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + paths: Any = self.resolve("Paths") intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics") - pather: Pather = Pather(paths) - if hasattr(paths, "layer_height"): - pather.layer_height = getattr(paths, "layer_height") - if hasattr(paths, "first_layer_height"): - pather.first_layer_height = getattr(paths, "first_layer_height") + if not paths: + return "" - if intrinsics: - # Note: Pather currently uses bed_center_x/y as its internal attributes. - # We map the bed size center to these attributes if provided. - pather.bed_center_x = intrinsics.bed_x / 2.0 - pather.bed_center_y = intrinsics.bed_y / 2.0 - pather.feedrate = intrinsics.feedrate - pather.travel_feedrate = intrinsics.travel_feedrate - pather.extrusion_rate = intrinsics.extrusion_rate - pather.retraction = intrinsics.retraction - pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0) - pather.lift = intrinsics.lift + def _paths_to_gcode(p: Any) -> str: + if not p: + return "" + pather: Pather = Pather(p) + if hasattr(p, "layer_height"): + pather.layer_height = getattr(p, "layer_height") + if hasattr(p, "first_layer_height"): + pather.first_layer_height = getattr(p, "first_layer_height") - gcode: str = pather.to_gcode(include_header=self.use_header) - return gcode + if intrinsics: + pather.bed_center_x = intrinsics.bed_x / 2.0 + pather.bed_center_y = intrinsics.bed_y / 2.0 + pather.feedrate = intrinsics.feedrate + pather.travel_feedrate = intrinsics.travel_feedrate + pather.extrusion_rate = intrinsics.extrusion_rate + pather.retraction = intrinsics.retraction + pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0) + pather.lift = intrinsics.lift + + return pather.to_gcode(include_header=self.use_header) + + return eval_element_wise(_paths_to_gcode, paths) @register_class class SLICENODE_GcodeBlock(SliceNode): @@ -83,6 +97,7 @@ class SLICENODE_AssignMetadata(SliceNode): @register_class class SLICENODE_GcodeRegexReplace(SliceNode): bl_label = "Gcode Regex Replace" + list_socket_mapping = [("Gcode", "Gcode")] def init(self, context): self.add_input("NodeSocketString", "Gcode") @@ -90,29 +105,37 @@ class SLICENODE_GcodeRegexReplace(SliceNode): self.add_input("NodeSocketString", "Replace") self.add_output("NodeSocketString", "Gcode") - def evaluate_output(self, socket_name: str, **_kwargs) -> str: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: import re - gcode = self.resolve("Gcode") or "" + gcode = self.resolve("Gcode") find = self.resolve("Find") or "" replace = self.resolve("Replace") or "" + if not gcode: + return "" + if not find: return gcode - try: - # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g - def subst(match): - if match.group(1): return '$' - elif match.group(2): return '\\g<' + match.group(2) + '>' - elif match.group(3): return '\\g<' + match.group(3) + '>' - return match.group(0) + def _replace_one(gc: Any) -> str: + gc_str = str(gc or "") + if not find or not gc_str: + return gc_str + try: + # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g + def subst(match): + if match.group(1): return '$' + elif match.group(2): return '\\g<' + match.group(2) + '>' + elif match.group(3): return '\\g<' + match.group(3) + '>' + return match.group(0) - replace = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace) + rep = re.sub(r'(\$\$)|(?:\$\{([^}]+)\})|(?:\$([a-zA-Z0-9_]+))', subst, replace) + return re.sub(find, rep, gc_str) + except Exception as e: + print(f"Regex error in node {self.name}: {e}") + return gc_str - return re.sub(find, replace, gcode) - except Exception as e: - print(f"Regex error in node {self.name}: {e}") - return gcode + return eval_element_wise(_replace_one, gcode) @register_class class SLICENODE_GcodeHelper(SliceNode): @@ -180,16 +203,52 @@ class SLICENODE_JoinGcode(SliceNodeJoiner): output_socket_type = "NodeSocketString" socket_label = "Gcode" - def evaluate_output(self, socket_name: str, **_kwargs) -> str: - from ....infra.blender.blender_slice_nodes_generic import resolve_input - gcode_input: list[str] = [] - for input in self.inputs: - val = resolve_input(input) - if val is not None: - gc = str(val) - if gc: - gcode_input.append(gc) - return "\n".join(gcode_input) + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + has_list = any( + is_socket_linked_to_list(inp) + for inp in self.inputs + if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_") + ) + out_sock = self.outputs.get(self.socket_label) + if out_sock: + set_socket_shape(out_sock, "LIST" if has_list else "CIRCLE") + + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + raw_inputs = self.resolve_multi_input(self.socket_label) + has_list = any(is_list_data(inp) for inp in raw_inputs) + + if not has_list: + flattened = flatten_list(raw_inputs) + gcode_input: list[str] = [str(gc) for gc in flattened if gc] + return "\n".join(gcode_input) + + # There is at least one list input + list_lengths = [len(inp) for inp in raw_inputs if is_list_data(inp)] + if len(set(list_lengths)) > 1: + raise ValueError( + f"Join Gcode: all input lists must have the same length, got lengths {list_lengths}" + ) + + n_elements = list_lengths[0] if list_lengths else 0 + result: list[str] = [] + for i in range(n_elements): + row_parts: list[str] = [] + for inp in raw_inputs: + if is_list_data(inp): + val = inp[i] + else: + val = inp + if is_list_data(val): + val_str = "\n".join(str(s) for s in flatten_list(val) if s) + if val_str: + row_parts.append(val_str) + elif val: + row_parts.append(str(val)) + result.append("\n".join(row_parts)) + + return result @register_class class SLICENODE_LoadGcode(SliceNode): diff --git a/core/nodeslicer/nodes/inputs.py b/core/nodeslicer/nodes/inputs.py index e6ad854..a39e420 100644 --- a/core/nodeslicer/nodes/inputs.py +++ b/core/nodeslicer/nodes/inputs.py @@ -6,7 +6,7 @@ import numpy as np from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty, NfPointerProperty from ....registry import register_class -from .base import InputNode, SliceNode, SliceIntrinsics +from .base import InputNode, SliceNode, SliceIntrinsics, SliceNodeListJoiner @register_class class SLICENODE_InputString(InputNode): @@ -39,6 +39,13 @@ class SLICENODE_InputObject(InputNode): socket_type = "NodeSocketObject" bl_icon = "OBJECT_DATA" +@register_class +class SLICENODE_ObjectsToList(SliceNodeListJoiner): + bl_label = "Objects to List" + socket_type = "NodeSocketObject" + socket_label = "Object" + output_socket_name = "Objects" + @register_class class SLICENODE_CollectionObjects(SliceNode): bl_label = "Collection Objects" diff --git a/core/nodeslicer/nodes/mesh.py b/core/nodeslicer/nodes/mesh.py index d67a03f..9115dce 100644 --- a/core/nodeslicer/nodes/mesh.py +++ b/core/nodeslicer/nodes/mesh.py @@ -4,14 +4,16 @@ import numpy as np from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed from ....registry import register_class -from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SlicingGroup, SlicingObject, SlicePaths +from .base import SliceNode, SlicingCollection, get_all_children, SliceNodeJoiner, SliceNodeListJoiner, SlicingGroup, SlicingObject, SlicePaths from ....infra.blender.blender_mesh_capture import PdfProperties from ....core.pdf_exporter.geometry import transform_slice_paths from ....core.geometry import update_transform +from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data @register_class class SLICENODE_GetMesh(SliceNode): bl_label = "Get Mesh" + list_socket_mapping = [("Object", "Mesh")] include_children: bool = NfBoolProperty(name="Include Children", default=True) @@ -23,17 +25,25 @@ class SLICENODE_GetMesh(SliceNode): assert layout layout.prop(self, "include_children") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Mesh": return None obj: Any = self.resolve("Object") if not obj: return None - family = [obj] + (get_all_children(obj) if self.include_children else []) + def _to_collection(o: Any) -> SlicingCollection | None: + if not o: return None + family = [o] + (get_all_children(o) if self.include_children else []) + return SlicingCollection(family, getattr(o, "name", "Mesh")) - sc: SlicingCollection = SlicingCollection(family, obj.name) - - return sc + return eval_element_wise(_to_collection, obj) + +@register_class +class SLICENODE_MeshesToList(SliceNodeListJoiner): + bl_label = "Meshes to List" + socket_type = "SLICESOCK_Mesh" + socket_label = "Mesh" + output_socket_name = "Meshes" @register_class class SLICENODE_JoinMesh(SliceNodeJoiner): @@ -42,19 +52,23 @@ class SLICENODE_JoinMesh(SliceNodeJoiner): def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: from ....infra.blender.blender_slice_nodes_generic import resolve_input - first_coll = resolve_input(self.inputs[0]) if self.inputs else None + all_colls = [] + for inp in self.inputs: + if getattr(inp, "is_linked", False) or getattr(inp, "value", None): + val = resolve_input(inp) + if val: + all_colls.extend(flatten_list(val)) + + first_coll = all_colls[0] if all_colls else None merged_coll = SlicingCollection([], first_coll.name if first_coll else '') - if first_coll: - merged_coll.objects = list(first_coll.objects) - merged_coll.transforms = [dict(t) for t in getattr(first_coll, "transforms", [])] - - for input in self.inputs[1:]: - coll = resolve_input(input) - if coll: + for coll in all_colls: + if hasattr(coll, "objects"): merged_coll.objects += list(coll.objects) - for t in getattr(coll, "transforms", []): - if t not in merged_coll.transforms: - merged_coll.transforms.append(dict(t)) + elif isinstance(coll, SlicingObject): + merged_coll.objects.append(coll) + for t in getattr(coll, "transforms", []): + if t not in merged_coll.transforms: + merged_coll.transforms.append(dict(t)) return merged_coll @@ -70,22 +84,28 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner): def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingGroup: from ....infra.blender.blender_slice_nodes_generic import resolve_input group: SlicingGroup = SlicingGroup([]) - - for idx, input_socket in enumerate(self.inputs): - coll = resolve_input(input_socket) - if coll: - col_name = getattr(coll, "name", "") - if not col_name and coll.objects: - col_name = coll.objects[0].name - if not col_name: - col_name = f"collection_{idx}" - group.collections[col_name] = coll + + all_colls = [] + for input_socket in self.inputs: + if getattr(input_socket, "is_linked", False) or getattr(input_socket, "value", None): + val = resolve_input(input_socket) + if val: + all_colls.extend(flatten_list(val)) + + for idx, coll in enumerate(all_colls): + col_name = getattr(coll, "name", "") + if not col_name and getattr(coll, "objects", None): + col_name = coll.objects[0].name + if not col_name: + col_name = f"collection_{idx}" + group.collections[col_name] = coll return group @register_class class SLICENODE_SeparateMesh(SliceNode): bl_label = "Separate Mesh" + list_socket_mapping = [("Mesh", ["Selection", "Inverted"])] comparison: str = NfEnumProperty( name="Comparison", @@ -112,69 +132,74 @@ class SLICENODE_SeparateMesh(SliceNode): assert layout layout.prop(self, "comparison", text="") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: - coll: SlicingCollection | None = self.resolve("Mesh") + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + coll: Any = self.resolve("Mesh") attr_name: str = self.resolve("Attribute") value: float = self.resolve("Value") if not coll: return None - res_selection = SlicingCollection([], coll.name + "_selection") - res_inverted = SlicingCollection([], coll.name + "_inverted") + def _split_coll(sc: SlicingCollection) -> tuple[SlicingCollection, SlicingCollection]: + res_selection = SlicingCollection([], sc.name + "_selection") + res_inverted = SlicingCollection([], sc.name + "_inverted") - for so in coll.objects: - if attr_name not in so.attributes: - # If attribute missing, entire object goes to inverted - res_inverted.objects.append(so) - continue - - attr = so.attributes[attr_name] - vals = attr["values"] - - mask = np.zeros(len(so.mesh), dtype=bool) - if self.comparison == 'EQUAL': mask = vals == value - elif self.comparison == 'NOT_EQUAL': mask = vals != value - elif self.comparison == 'GREATER_THAN': mask = vals > value - elif self.comparison == 'LESS_THAN': mask = vals < value - elif self.comparison == 'GREATER_EQUAL': mask = vals >= value - elif self.comparison == 'LESS_EQUAL': mask = vals <= value - - if np.all(mask): - res_selection.objects.append(so) - elif not np.any(mask): - res_inverted.objects.append(so) - else: - # Split the object - so_sel = SlicingObject() - so_sel.name = so.name + "_sel" - so_sel.parent = so.parent - so_sel.mesh = so.mesh[mask] - so_sel.transforms = [dict(t) for t in getattr(so, "transforms", [])] - so_sel.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() - so_sel.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) - for k, v in so.attributes.items(): - so_sel.attributes[k] = { - "domain": v["domain"], - "type": v["type"], - "values": v["values"][mask] - } - res_selection.objects.append(so_sel) + for so in sc.objects: + if attr_name not in so.attributes: + res_inverted.objects.append(so) + continue + + attr = so.attributes[attr_name] + vals = attr["values"] + + mask = np.zeros(len(so.mesh), dtype=bool) + if self.comparison == 'EQUAL': mask = vals == value + elif self.comparison == 'NOT_EQUAL': mask = vals != value + elif self.comparison == 'GREATER_THAN': mask = vals > value + elif self.comparison == 'LESS_THAN': mask = vals < value + elif self.comparison == 'GREATER_EQUAL': mask = vals >= value + elif self.comparison == 'LESS_EQUAL': mask = vals <= value + + if np.all(mask): + res_selection.objects.append(so) + elif not np.any(mask): + res_inverted.objects.append(so) + else: + so_sel = SlicingObject() + so_sel.name = so.name + "_sel" + so_sel.parent = so.parent + so_sel.mesh = so.mesh[mask] + so_sel.transforms = [dict(t) for t in getattr(so, "transforms", [])] + so_sel.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() + so_sel.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) + for k, v in so.attributes.items(): + so_sel.attributes[k] = { + "domain": v["domain"], + "type": v["type"], + "values": v["values"][mask] + } + res_selection.objects.append(so_sel) - so_inv = SlicingObject() - so_inv.name = so.name + "_inv" - so_inv.parent = so.parent - so_inv.mesh = so.mesh[~mask] - so_inv.transforms = [dict(t) for t in getattr(so, "transforms", [])] - so_inv.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() - so_inv.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) - for k, v in so.attributes.items(): - so_inv.attributes[k] = { - "domain": v["domain"], - "type": v["type"], - "values": v["values"][~mask] - } - res_inverted.objects.append(so_inv) + so_inv = SlicingObject() + so_inv.name = so.name + "_inv" + so_inv.parent = so.parent + so_inv.mesh = so.mesh[~mask] + so_inv.transforms = [dict(t) for t in getattr(so, "transforms", [])] + so_inv.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() + so_inv.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) + for k, v in so.attributes.items(): + so_inv.attributes[k] = { + "domain": v["domain"], + "type": v["type"], + "values": v["values"][~mask] + } + res_inverted.objects.append(so_inv) + return res_selection, res_inverted + if is_list_data(coll): + results = [_split_coll(c) for c in coll] + return [r[0] for r in results] if socket_name == "Selection" else [r[1] for r in results] + + res_selection, res_inverted = _split_coll(coll) if socket_name == "Selection": return res_selection return res_inverted @@ -202,8 +227,13 @@ class SLICENODE_SeparateMeshByRange(SliceNode): end = max(start, end_val) expected_ids = [str(i) for i in range(start, end + 1)] + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + inp = self.inputs.get("Mesh") if hasattr(self, "inputs") else None + is_list = is_socket_linked_to_list(inp) if inp is not None else False + if inp is not None: + set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + # Remove outputs that are no longer in the range - # We use a list to avoid modification-during-iteration issues to_remove = [o for o in self.outputs if o.identifier not in expected_ids] for o in to_remove: self.outputs.remove(o) @@ -211,7 +241,9 @@ class SLICENODE_SeparateMeshByRange(SliceNode): # Add new outputs for id_str in expected_ids: if id_str not in self.outputs: - self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str) + self.add_output("SLICESOCK_Mesh", id_str, identifier=id_str, display_shape="LIST" if is_list else "") + else: + set_socket_shape(self.outputs[id_str], "LIST" if is_list else "CIRCLE") # Re-order to match range for i, id_str in enumerate(expected_ids): @@ -225,8 +257,8 @@ class SLICENODE_SeparateMeshByRange(SliceNode): col.prop(self, "start_val") col.prop(self, "end_val") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: - coll: SlicingCollection | None = self.resolve("Mesh") + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + coll: Any = self.resolve("Mesh") attr_name: str = self.resolve("Attribute") if not coll: return None @@ -237,47 +269,50 @@ class SLICENODE_SeparateMeshByRange(SliceNode): except ValueError: return None - res = SlicingCollection([], f"{coll.name}_{target_val}") - - start = self.start_val - end = max(start, self.end_val) + start = int(self.start_val) if isinstance(getattr(self, "start_val", None), (int, float)) else 0 + end_val = int(self.end_val) if isinstance(getattr(self, "end_val", None), (int, float)) else 1 + end = max(start, end_val) total_parts = end - start + 1 num_digits = max(2, len(str(total_parts))) i = target_val - start - for so in coll.objects: - if attr_name not in so.attributes: - continue - - attr = so.attributes[attr_name] - vals = attr["values"] - - # Attribute values might be floats or ints, we check for equality - mask = vals == target_val - - if np.any(mask): - # Split the object - so_new = SlicingObject() - base_mesh_name = so.name - so_new.name = f"{base_mesh_name}{i:0{num_digits}d}" - so_new.parent = so.parent - so_new.mesh = so.mesh[mask] - so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])] - so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() - so_new.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) - for k, v in so.attributes.items(): - so_new.attributes[k] = { - "domain": v["domain"], - "type": v["type"], - "values": v["values"][mask] - } - res.objects.append(so_new) + def _split_coll(sc: SlicingCollection) -> SlicingCollection: + res = SlicingCollection([], f"{sc.name}_{target_val}") + for so in sc.objects: + if attr_name not in so.attributes: + continue + + attr = so.attributes[attr_name] + vals = attr["values"] + + # Attribute values might be floats or ints, we check for equality + mask = vals == target_val + + if np.any(mask): + # Split the object + so_new = SlicingObject() + base_mesh_name = so.name + so_new.name = f"{base_mesh_name}{i:0{num_digits}d}" + so_new.parent = so.parent + so_new.mesh = so.mesh[mask] + so_new.transforms = [dict(t) for t in getattr(so, "transforms", [])] + so_new.pdf_properties = copy.deepcopy(so.pdf_properties) if hasattr(so, "pdf_properties") and so.pdf_properties else PdfProperties() + so_new.pdf_object_properties = list(getattr(so, "pdf_object_properties", [])) + for k, v in so.attributes.items(): + so_new.attributes[k] = { + "domain": v["domain"], + "type": v["type"], + "values": v["values"][mask] + } + res.objects.append(so_new) + return res - return res + return eval_element_wise(_split_coll, coll) @register_class class SLICENODE_ArrangeMesh(SliceNode): bl_label = "Arrange Slicing Group" + list_socket_mapping = [("Slicing Group", "Slicing Group")] method: str = NfEnumProperty( name="Method", @@ -297,11 +332,11 @@ class SLICENODE_ArrangeMesh(SliceNode): ) def init(self, context): - self.add_input("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST') + self.add_input("SLICESOCK_Mesh", "Slicing Group") self.add_input("NodeSocketFloat", "Bed X").value = 250.0 self.add_input("NodeSocketFloat", "Bed Y").value = 250.0 self.add_input("NodeSocketFloat", "Spacing").value = 2.0 - self.add_output("SLICESOCK_Mesh", "Slicing Group", display_shape='LIST') + self.add_output("SLICESOCK_Mesh", "Slicing Group") def draw_buttons(self, context, layout): assert layout @@ -320,9 +355,22 @@ class SLICENODE_ArrangeMesh(SliceNode): bed_y: float = self.resolve("Bed Y") or 250.0 spacing: float = self.resolve("Spacing") or 2.0 - # Convert input to SlicingGroup if it's a SlicingCollection + # Convert input to SlicingGroup if it's a SlicingCollection or list + is_list = is_list_data(mesh_data) if isinstance(mesh_data, SlicingGroup): slicing_group = mesh_data + elif is_list: + slicing_group = SlicingGroup([]) + for idx, item in enumerate(mesh_data): + if isinstance(item, SlicingGroup): + for k, sc in item.collections.items(): + slicing_group.collections[f"{k}_{idx}"] = sc + elif isinstance(item, SlicingCollection): + col_name = item.name or f"mesh_{idx}" + slicing_group.collections[col_name] = item + elif isinstance(item, SlicingObject): + sc = SlicingCollection([item], item.name or f"mesh_{idx}") + slicing_group.collections[sc.name] = sc else: slicing_group = SlicingGroup([]) slicing_group.collections = {mesh_data.name: mesh_data} @@ -419,8 +467,8 @@ class SLICENODE_ArrangeMesh(SliceNode): so.mesh[:, 3, 1] = ny_new else: name, sc = collection_map[id(item)] - print(f"Warning: Collection '{name}' did not fit on the build plate (binId: {bin_id})") - + if is_list: + return list(copied_group.collections.values()) return copied_group @@ -515,6 +563,7 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_ @register_class class SLICENODE_TransformGeometry(SliceNode): bl_label = "Transform Geometry" + list_socket_mapping = [("Geometry", "Geometry")] def init(self, context): self.add_input("SLICESOCK_Geometry", "Geometry") @@ -545,6 +594,7 @@ class SLICENODE_TransformGeometry(SliceNode): @register_class class SLICENODE_DropGeometry(SliceNode): bl_label = "Drop Geometry" + list_socket_mapping = [("Mesh", ["Mesh", "Translation"])] def init(self, context): self.add_input("SLICESOCK_Mesh", "Mesh") @@ -552,23 +602,8 @@ class SLICENODE_DropGeometry(SliceNode): self.add_output("SLICESOCK_Mesh", "Mesh") self.add_output("NodeSocketVector", "Translation") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | tuple[float, float, float] | None: - if socket_name == "Translation": - mesh_data: SlicingCollection | SlicingGroup | None = self.resolve("Mesh") - if not mesh_data: - return None - z = self.resolve('Z') - if z is None: - z = 0.0 - assert mesh_data.min_z is not None - min_z = round(mesh_data.min_z, 6) - offset_z = float(z - min_z) - return (0.0, 0.0, offset_z) - - if socket_name != "Mesh": - return None - - mesh_data = self.resolve("Mesh") + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + mesh_data: Any = self.resolve("Mesh") if not mesh_data: return None @@ -576,29 +611,38 @@ class SLICENODE_DropGeometry(SliceNode): if z is None: z = 0.0 - assert mesh_data.min_z is not None - min_z = round(mesh_data.min_z, 6) - offset_z = float(z - min_z) - - if isinstance(mesh_data, SlicingGroup): - copied_data = _copy_slicing_group(mesh_data) - else: - copied_data = _copy_slicing_collection(mesh_data) - - drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64) - copied_data.offset(drop_vec) - - copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec) - if isinstance(copied_data, SlicingGroup): - for col in copied_data.collections.values(): - col.transforms = update_transform(getattr(col, "transforms", []), t_arr=drop_vec) - for so in col.objects: + def _drop_item(item: Any) -> tuple[Any, tuple[float, float, float]]: + if not item or getattr(item, "min_z", None) is None: + return item, (0.0, 0.0, 0.0) + min_z = round(item.min_z, 6) + offset_z = float(z - min_z) + if isinstance(item, SlicingGroup): + copied_data = _copy_slicing_group(item) + else: + copied_data = _copy_slicing_collection(item) + drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64) + copied_data.offset(drop_vec) + copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec) + if isinstance(copied_data, SlicingGroup): + for col in copied_data.collections.values(): + col.transforms = update_transform(getattr(col, "transforms", []), t_arr=drop_vec) + for so in col.objects: + so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) + elif isinstance(copied_data, SlicingCollection): + for so in copied_data.objects: so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) - elif isinstance(copied_data, SlicingCollection): - for so in copied_data.objects: - so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) + return copied_data, (0.0, 0.0, offset_z) - return copied_data + if is_list_data(mesh_data): + results = [_drop_item(m) for m in mesh_data] + if socket_name == "Translation": + return [r[1] for r in results] + return [r[0] for r in results] + + dropped, trans = _drop_item(mesh_data) + if socket_name == "Translation": + return trans + return dropped def format_name_template(template: str, so: Any, default_old_name: str = "") -> str: @@ -664,6 +708,7 @@ def _rename_slicing_item(geom: Any, new_name: str) -> Any: @register_class class SLICENODE_RenameMesh(SliceNode): bl_label = "Rename Mesh" + list_socket_mapping = [("Geometry", "Geometry")] def init(self, context): self.add_input("SLICESOCK_Mesh", "Geometry") @@ -716,6 +761,7 @@ def _assign_pdf_properties_to_item(geom: Any, line_width: float, line_color: tup @register_class class SLICENODE_AssignPdfProperties(SliceNode): bl_label = "Assign PDF Properties" + list_socket_mapping = [("Geometry", "Geometry")] line_width: float = NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0) line_color: tuple[float, float, float] = NfFloatVectorProperty( @@ -777,7 +823,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any: return copied elif isinstance(geom, SlicingObject): copied = _copy_slicing_object(geom) - copied.pdf_object_properties = resolve_object_properties(copied, props) + copied.pdf_object_properties = resolve_object_properties(geom, props) return copied return geom @@ -785,6 +831,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any: @register_class class SLICENODE_AssignPropertyToObject(SliceNode): bl_label = "Assign PDF Object Properties" + list_socket_mapping = [("Geometry", "Geometry")] def init(self, context): self.add_input("SLICESOCK_Mesh", "Geometry") diff --git a/core/nodeslicer/nodes/output.py b/core/nodeslicer/nodes/output.py index d9abb63..1f65471 100644 --- a/core/nodeslicer/nodes/output.py +++ b/core/nodeslicer/nodes/output.py @@ -23,13 +23,22 @@ class SLICENODE_GcodeToFile(ExecSliceNode): self.add_input("NodeSocketString", "Name") self.add_multi_socket_group("NodeSocketString", name="Gcode") + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + for inp in self.inputs: + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + def draw_buttons(self, context, layout): super().draw_buttons(context, layout) def evaluate(self, operator=None): from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, prepare_gcode_file - - gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc] + from ....infra.blender.list_utils import flatten_list + + raw_gcodes = self.resolve_multi_input('Gcode') + gcodes: list[str] = [str(gc) for gc in flatten_list(raw_gcodes) if gc] folder: str = self.resolve("Folder") if not gcodes: @@ -86,21 +95,38 @@ class SLICENODE_GcodeToTextBlock(ExecSliceNode): self._init_exec() self.add_input("NodeSocketString", "Gcode") + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + inp = self.inputs.get("Gcode") + if inp: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, "text_block_name") def evaluate(self, operator=None): - gcode: str = self.resolve("Gcode") - if not gcode: + from ....infra.blender.list_utils import flatten_list, is_list_data + raw = self.resolve("Gcode") + if not raw: return 'ok' - name = self.text_block_name - text_block = bpy.data.texts.get(name) - if text_block is None: - text_block = bpy.data.texts.new(name=name) + gcodes = [str(g) for g in flatten_list(raw) if g] if is_list_data(raw) else ([str(raw)] if raw else []) + if not gcodes: + return 'ok' - text_block.from_string(gcode) + base_name = self.text_block_name + name_root, ext = os.path.splitext(base_name) + total_parts = len(gcodes) + num_digits = max(2, len(str(total_parts))) + + for i, gcode in enumerate(gcodes): + name = f"{name_root}_{i:0{num_digits}d}{ext}" if total_parts > 1 else base_name + text_block = bpy.data.texts.get(name) + if text_block is None: + text_block = bpy.data.texts.new(name=name) + text_block.from_string(gcode) # Attempt to show in an open text editor assert bpy.context.window_manager @@ -124,14 +150,21 @@ class SLICENODE_StartPrint(ExecSliceNode): self.add_input("SLICESOCK_Printer") def evaluate(self, operator=None): + from ....infra.blender.list_utils import is_list_data + + raw_gcode = self.resolve("Gcode") + if is_list_data(raw_gcode): + if operator: operator.report({'ERROR'}, "Start Print does not accept a list of Gcode") + return 'error' + from ....infra.networking.printer_backends import ( PrinterConfig, PrusaLinkBackend, CrealityBackend, MoonrakerBackend ) - - gcode: str = self.resolve("Gcode") + + gcode: str = str(raw_gcode or "") config: PrinterConfig | None = self.resolve("Printer") if not gcode: @@ -192,6 +225,13 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode): self.add_input("NodeSocketString", "Gcode") self.add_input("SLICESOCK_Settings", "Metadata") + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + inp = self.inputs.get("Gcode") + if inp: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, "record_id") @@ -199,8 +239,9 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode): def evaluate(self, operator=None): from ....infra.networking.bgcode import convert_to_bgcode from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, parse_gcode_metadata - - gcodes: list[str] = self.resolve_multi_input("Gcode") + from ....infra.blender.list_utils import flatten_list + + gcodes: list[str] = [str(gc) for gc in flatten_list(self.resolve_multi_input("Gcode")) if gc] for gc in gcodes: if not gc: @@ -413,6 +454,14 @@ class SLICENODE_MeshToPdf(ExecSliceNode): self.add_input("SLICESOCK_Mesh", "Geometry") self.add_input("SLICESOCK_Paths", "Paths") + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + for name in ("Geometry", "Paths"): + inp = self.inputs.get(name) + if inp: + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + def draw_buttons(self, context, layout): super().draw_buttons(context, layout) diff --git a/core/nodeslicer/nodes/path.py b/core/nodeslicer/nodes/path.py index 7c292f2..505755a 100644 --- a/core/nodeslicer/nodes/path.py +++ b/core/nodeslicer/nodes/path.py @@ -4,12 +4,20 @@ import numpy as np from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed from ....registry import register_class -from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SlicingGroup +from .base import SliceNode, SlicePaths, SlicingObject, SlicingCollection, slice_at_range, SliceNodeJoiner, SliceNodeListJoiner, SlicingGroup from ...slice import align_path_seam_sharpest, is_closed_path +from ....infra.blender.list_utils import eval_element_wise, is_list_data, set_socket_shape, is_socket_linked_to_list + +@register_class +class SLICENODE_PathsToList(SliceNodeListJoiner): + bl_label = "Paths to List" + socket_type = "SLICESOCK_Paths" + socket_label = "Paths" @register_class class SLICENODE_GetPaths(SliceNode): bl_label = "Get Paths" + list_socket_mapping = [("Object", "Paths")] def init(self, context): self.add_input("NodeSocketObject") @@ -21,27 +29,143 @@ class SLICENODE_GetPaths(SliceNode): def draw_buttons(self, context, layout): pass - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: obj: Any = self.resolve("Object") e_attr_name: str = self.resolve("Extrusion Multiplier Attribute") f_attr_name: str = self.resolve("Feedrate Attribute") extra_attrs: dict[str, str] = self.resolve("Extra Attributes") from ....infra.blender.blender_mesh_capture import capture_mesh_edges - so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(obj, e_attr_name, f_attr_name, extra_attrs) - sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) - if hasattr(obj, "transforms"): - sp.transforms = list(getattr(obj, "transforms", [])) - if hasattr(obj, "name") and getattr(obj, "name"): - name_val = str(getattr(obj, "name")) - sp.name = name_val - sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]} - return sp + def _get_single_path(o: Any) -> SlicePaths: + if not o: + return SlicePaths() + so, e_rates, f_rates, extra_attrs_data = capture_mesh_edges(o, e_attr_name, f_attr_name, extra_attrs) + sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data) + if hasattr(o, "transforms"): + sp.transforms = list(getattr(o, "transforms", [])) + if hasattr(o, "name") and getattr(o, "name"): + name_val = str(getattr(o, "name")) + sp.name = name_val + sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]} + return sp + + return eval_element_wise(_get_single_path, obj) + +def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | None, rotation: Any, ignore_holes: bool) -> SlicePaths: + if not coll: + return SlicePaths() + + # Handle SlicingGroup, SlicingCollection, SlicingObject + if isinstance(coll, SlicingGroup): + objects = [] + for c in coll.collections.values(): + objects.extend(c.objects) + elif isinstance(coll, SlicingCollection): + objects = list(coll.objects) + elif isinstance(coll, SlicingObject): + objects = [coll] + else: + objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else []) + + merged_object = SlicingObject.merge(objects) + if merged_object.mesh is None or merged_object.mesh.size == 0: + return SlicePaths() + + has_rot = bool(rotation and any(rotation)) + if has_rot: + from mathutils import Euler + mat = np.array(Euler(rotation).to_matrix().inverted()) + merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T + merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T + + min_z = float(merged_object.mesh[:, :3, 2].min()) + if abs(min_z) < 1e-6: + min_z = 0.0 + max_z = float(merged_object.mesh[:, :3, 2].max()) + + h_first = fl_height if fl_height is not None else layer_height + + min_z_um = int(round(min_z * 1000)) + lh_um = int(round(layer_height * 1000)) + flh_um = int(round(h_first * 1000)) + max_z_um = int(round(max_z * 1000)) + + z_first_um = min_z_um + flh_um + z_rest_start_um = z_first_um + lh_um + + if z_first_um <= max_z_um: + if z_rest_start_um <= max_z_um and lh_um > 0: + z_um_values = np.arange(z_rest_start_um, max_z_um + 1, lh_um, dtype=np.int64) + z_values = np.concatenate(([z_first_um], z_um_values)) / 1000.0 + else: + z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 + else: + z_values = np.array([], dtype=np.float64) + + slices_group: list[list[np.ndarray]] | None = slice_at_range( + [merged_object.mesh], + z_values, + ignore_holes=ignore_holes + ) + if not slices_group or not slices_group[0]: + return SlicePaths() + + paths = slices_group[0] + z_to_group: dict[int, int] = {} + groups = [] + for p in paths: + if len(p) > 0: + z_k = int(round(p[0, 2] * 1000)) + if z_k not in z_to_group: + z_to_group[z_k] = len(z_to_group) + groups.append(z_to_group[z_k]) + else: + groups.append(0) + + slices = SlicePaths(paths, groups=groups) + slices.layer_height = layer_height + if fl_height is not None: + slices.first_layer_height = fl_height + slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", [])) + + names = [] + if objects: + for o in objects: + n = getattr(o, "name", "") + if n: + if n.endswith("_merged"): + n = n[:-7] + if n and n not in names: + names.append(n) + if not names and hasattr(coll, "name") and getattr(coll, "name"): + names.append(str(getattr(coll, "name"))) + + if names: + slices.name = names[0] + slices.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]} + + if len(z_values) > 0 and fl_height is not None and layer_height > 0: + mult = fl_height / layer_height + z_first_um = int(round(z_values[0] * 1000)) + for i, p in enumerate(slices): + if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um: + slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult + else: + break + + if has_rot: + from mathutils import Euler + mat_inv = np.array(Euler(rotation).to_matrix()) + slices = slices.transform(mat_inv) + slices = slices.align_seams(mode="sharpest") + + return slices @register_class class SLICENODE_SliceShells(SliceNode): bl_label = "Slice Shells" + list_socket_mapping = [("Mesh", "Paths")] ignore_holes: bool = NfBoolProperty( name="Ignore Holes", @@ -61,7 +185,7 @@ class SLICENODE_SliceShells(SliceNode): if hasattr(self, "ignore_holes"): layout.prop(self, "ignore_holes") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() coll: Any = self.resolve("Mesh") @@ -92,126 +216,15 @@ class SLICENODE_SliceShells(SliceNode): if not coll: return SlicePaths() - # Handle SlicingGroup, SlicingCollection, SlicingObject, or list - if isinstance(coll, SlicingGroup): - objects = [] - for c in coll.collections.values(): - objects.extend(c.objects) - elif isinstance(coll, SlicingCollection): - objects = list(coll.objects) - elif isinstance(coll, SlicingObject): - objects = [coll] - elif isinstance(coll, list): - objects = [] - for item in coll: - if isinstance(item, SlicingGroup): - for c in item.collections.values(): - objects.extend(c.objects) - elif isinstance(item, SlicingCollection): - objects.extend(item.objects) - elif isinstance(item, SlicingObject): - objects.append(item) - else: - objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else []) + def _slice_one(item: Any) -> SlicePaths: + return _slice_single_mesh_item(item, layer_height, fl_height, rotation, ignore_holes) - merged_object = SlicingObject.merge(objects) - if merged_object.mesh is None or merged_object.mesh.size == 0: - return SlicePaths() - - has_rot = bool(rotation and any(rotation)) - if has_rot: - from mathutils import Euler - mat = np.array(Euler(rotation).to_matrix().inverted()) - merged_object.mesh[:, :3, :] = merged_object.mesh[:, :3, :] @ mat.T - merged_object.mesh[:, 3:4, :] = merged_object.mesh[:, 3:4, :] @ mat.T - - min_z = float(merged_object.mesh[:, :3, 2].min()) - if abs(min_z) < 1e-6: - min_z = 0.0 - max_z = float(merged_object.mesh[:, :3, 2].max()) - - h_first = fl_height if fl_height is not None else layer_height - - min_z_um = int(round(min_z * 1000)) - lh_um = int(round(layer_height * 1000)) - flh_um = int(round(h_first * 1000)) - max_z_um = int(round(max_z * 1000)) - - z_first_um = min_z_um + flh_um - z_rest_start_um = z_first_um + lh_um - - if z_first_um <= max_z_um: - if z_rest_start_um <= max_z_um and lh_um > 0: - z_um_values = np.arange(z_rest_start_um, max_z_um + 1, lh_um, dtype=np.int64) - z_values = np.concatenate(([z_first_um], z_um_values)) / 1000.0 - else: - z_values = np.array([z_first_um], dtype=np.float64) / 1000.0 - else: - z_values = np.array([], dtype=np.float64) - - slices_group: list[list[np.ndarray]] | None = slice_at_range( - [merged_object.mesh], - z_values, - ignore_holes=ignore_holes - ) - if not slices_group or not slices_group[0]: - return SlicePaths() - - paths = slices_group[0] - # Assign layer-consistent group indices based on Z height in integer microns - z_to_group: dict[int, int] = {} - groups = [] - for p in paths: - if len(p) > 0: - z_k = int(round(p[0, 2] * 1000)) - if z_k not in z_to_group: - z_to_group[z_k] = len(z_to_group) - groups.append(z_to_group[z_k]) - else: - groups.append(0) - - slices = SlicePaths(paths, groups=groups) - slices.layer_height = layer_height - if fl_height is not None: - slices.first_layer_height = fl_height - slices.transforms = list(getattr(coll, "transforms", [])) or list(getattr(merged_object, "transforms", [])) - - names = [] - if objects: - for o in objects: - n = getattr(o, "name", "") - if n: - if n.endswith("_merged"): - n = n[:-7] - if n and n not in names: - names.append(n) - if not names and hasattr(coll, "name") and getattr(coll, "name"): - names.append(str(getattr(coll, "name"))) - - if names: - slices.name = names[0] - slices.objects_info = {"objects": [{"name": n, "polygon": []} for n in names]} - - if len(z_values) > 0 and fl_height is not None and layer_height > 0: - mult = fl_height / layer_height - z_first_um = int(round(z_values[0] * 1000)) - for i, p in enumerate(slices): - if len(p) > 0 and int(round(p[0, 2] * 1000)) == z_first_um: - slices.extrusion_rate[i] = slices.extrusion_rate[i] * mult - else: - break - - if has_rot: - from mathutils import Euler - mat_inv = np.array(Euler(rotation).to_matrix()) - slices = slices.transform(mat_inv) - slices = slices.align_seams(mode="sharpest") - - return slices + return eval_element_wise(_slice_one, coll) @register_class class SLICENODE_BufferShells(SliceNode): bl_label = "Buffer Shells" + list_socket_mapping = [("Paths", "Paths")] shape_option: str = NfEnumProperty( name="Miter", @@ -232,21 +245,26 @@ class SLICENODE_BufferShells(SliceNode): assert layout layout.prop(self, "shape_option") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") distance_val = self.resolve("Distance") distance: float = float(distance_val) if distance_val is not None else 0.0 rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() - return paths.buffer(distance, miter=self.shape_option, rotation=rotation) + def _buf(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.buffer(distance, miter=self.shape_option, rotation=rotation) + + return eval_element_wise(_buf, paths) @register_class class SLICENODE_BufferCurve(SliceNode): bl_label = "Buffer Curve" + list_socket_mapping = [("Paths", "Paths")] cap_style: str = NfEnumProperty( name="Cap Style", @@ -286,26 +304,31 @@ class SLICENODE_BufferCurve(SliceNode): layout.prop(self, "join_style") layout.prop(self, "single_sided") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") distance: float = self.resolve("Distance") or 0.0 rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() - return paths.buffer_curve( - distance, - cap_style=self.cap_style, - join_style=self.join_style, - single_sided=self.single_sided, - rotation=rotation - ) + def _buf(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.buffer_curve( + distance, + cap_style=self.cap_style, + join_style=self.join_style, + single_sided=self.single_sided, + rotation=rotation + ) + + return eval_element_wise(_buf, paths) @register_class class SLICENODE_OffsetCurve(SliceNode): bl_label = "Offset Curve" + list_socket_mapping = [("Paths", "Paths")] join_style: str = NfEnumProperty( name="Join Style", @@ -327,24 +350,29 @@ class SLICENODE_OffsetCurve(SliceNode): assert layout layout.prop(self, "join_style") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") distance: float = self.resolve("Distance") or 0.0 rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() - return paths.offset_curve( - distance, - join_style=cast(Literal['round', 'mitre'], self.join_style), - rotation=rotation - ) + def _off(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.offset_curve( + distance, + join_style=cast(Literal['round', 'mitre'], self.join_style), + rotation=rotation + ) + + return eval_element_wise(_off, paths) @register_class class SLICENODE_PathOffset(SliceNode): bl_label = "Path Offset" + list_socket_mapping = [("Paths", "Paths")] def init(self, context): self.add_input("SLICESOCK_Paths", "Paths") @@ -352,16 +380,21 @@ class SLICENODE_PathOffset(SliceNode): self.add_output("SLICESOCK_Paths", "Paths") def evaluate_output(self, socket_name: str, **_kwargs) -> Any: - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() - return paths.offset(np.array(offset, dtype=np.float64)) + def _off(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.offset(np.array(offset, dtype=np.float64)) + + return eval_element_wise(_off, paths) @register_class class SLICENODE_RotatePath(SliceNode): bl_label = "Rotate Path" + list_socket_mapping = [("Paths", "Paths")] def init(self, context): self.add_input("SLICESOCK_Paths", "Paths") @@ -370,19 +403,24 @@ class SLICENODE_RotatePath(SliceNode): self.add_output("SLICESOCK_Paths", "Paths") def evaluate_output(self, socket_name: str, **_kwargs) -> Any: - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") angle: float = self.resolve("Angle") or 0.0 center: Any = self.resolve("Center") if not paths: return SlicePaths() center_vec = np.array(center, dtype=np.float64) if center and any(center) else None - - return paths.rotate(angle, center=center_vec) + + def _rot(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.rotate(angle, center=center_vec) + + return eval_element_wise(_rot, paths) @register_class class SLICENODE_BufferShellsMultiple(SliceNode): bl_label = "Buffer Shells Multiple" + list_socket_mapping = [("Paths", ["Paths", "Outermost Path", "Innermost Path"])] shape_option: str = NfEnumProperty( name="Miter", @@ -407,8 +445,8 @@ class SLICENODE_BufferShellsMultiple(SliceNode): assert layout layout.prop(self, "shape_option") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: - paths: SlicePaths = self.resolve("Paths") + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: + paths = self.resolve("Paths") base_distance_val = self.resolve("Distance") base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0 width_step_val = self.resolve("Width Step") @@ -420,20 +458,25 @@ class SLICENODE_BufferShellsMultiple(SliceNode): if not paths or count <= 0: return SlicePaths() - if socket_name == "Outermost Path": - outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance - return paths.buffer(outer_dist, miter=self.shape_option, rotation=rotation) - elif socket_name == "Innermost Path": - inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance - return paths.buffer(inner_dist, miter=self.shape_option, rotation=rotation) - elif socket_name == "Paths": - return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) + def _buf_multi(p: Any) -> SlicePaths: + if not p or count <= 0: + return SlicePaths() + if socket_name == "Outermost Path": + outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance + return p.buffer(outer_dist, miter=self.shape_option, rotation=rotation) + elif socket_name == "Innermost Path": + inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance + return p.buffer(inner_dist, miter=self.shape_option, rotation=rotation) + elif socket_name == "Paths": + return p.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) + return SlicePaths() - return SlicePaths() + return eval_element_wise(_buf_multi, paths) @register_class class SLICENODE_Infill(SliceNode): bl_label = "Infill" + list_socket_mapping = [("Paths", "Paths")] infill_type: str = NfEnumProperty( name="Type", @@ -457,26 +500,31 @@ class SLICENODE_Infill(SliceNode): assert layout layout.prop(self, "infill_type", text="") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") density: float = self.resolve("Density") or 2.0 infill_rotation: float = self.resolve("Infill Rotation") or 0.0 rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0) if not paths: return SlicePaths() - return paths.infill( - density, - rotation_deg=infill_rotation, - infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess] - layer_rotation=rotation - ) + def _inf(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.infill( + density, + rotation_deg=infill_rotation, + infill_type=self.infill_type, + layer_rotation=rotation + ) + + return eval_element_wise(_inf, paths) @register_class class SLICENODE_OverhangMask(SliceNode): bl_label = "Overhang Mask" + list_socket_mapping = [("Paths", "Paths")] def init(self, context): self.add_input("SLICESOCK_Paths") @@ -486,15 +534,19 @@ class SLICENODE_OverhangMask(SliceNode): def draw_buttons(self, context, layout): pass - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") tolerance: float = self.resolve("Tolerance") or 0.05 if not paths: return SlicePaths() - return paths.get_overhang_mask(tolerance=tolerance) + def _mask(p: Any) -> SlicePaths: + if not p: return SlicePaths() + return p.get_overhang_mask(tolerance=tolerance) + + return eval_element_wise(_mask, paths) @register_class class SLICENODE_PathOperations(SliceNodeJoiner): @@ -522,37 +574,56 @@ class SLICENODE_PathOperations(SliceNodeJoiner): default=False ) + def update(self) -> None: + super().update() + if self.inputs: + first_inp = self.inputs[0] + is_list = is_socket_linked_to_list(first_inp) + set_socket_shape(first_inp, "LIST" if is_list else "CIRCLE") + if self.outputs: + set_socket_shape(self.outputs[0], "LIST" if is_list else "CIRCLE") + def draw_buttons(self, context, layout): assert layout layout.prop(self, "operation", text="") if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']: layout.prop(self, "connect_along_boundary") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: from ....infra.blender.blender_slice_nodes_generic import resolve_input - inputs_data: list[SlicePaths] = [] - for input_socket in self.inputs: - if hasattr(input_socket, "is_linked") and not input_socket.is_linked: - continue - paths: Any = resolve_input(input_socket) - if isinstance(paths, SlicePaths) and len(paths) > 0: - inputs_data.append(paths) - - if not inputs_data: + if not self.inputs: return SlicePaths() - output = SlicePaths.path_operations( - inputs_data, - operation=self.operation, - connect_along_boundary=self.connect_along_boundary - ) - return output + first_val = resolve_input(self.inputs[0]) if (self.inputs[0].is_linked or getattr(self.inputs[0], 'value', None)) else None + other_paths: list[SlicePaths] = [] + for input_socket in self.inputs[1:]: + if getattr(input_socket, "is_linked", False): + val = resolve_input(input_socket) + if isinstance(val, SlicePaths) and len(val) > 0: + other_paths.append(val) + + def _run_op(primary_path: Any) -> SlicePaths: + inputs_data = [p for p in [primary_path] + other_paths if isinstance(p, SlicePaths) and len(p) > 0] + if not inputs_data: + return SlicePaths() + return SlicePaths.path_operations( + inputs_data, + operation=self.operation, + connect_along_boundary=self.connect_along_boundary + ) + + if is_list_data(first_val): + return [_run_op(item) for item in first_val] + elif isinstance(first_val, SlicePaths) or other_paths: + return _run_op(first_val) + return SlicePaths() @register_class class SLICENODE_SimplifyPaths(SliceNode): bl_label = "Simplify Paths" + list_socket_mapping = [("Paths", "Paths")] preserve_topology: bool = NfBoolProperty( name="Preserve Topology", @@ -569,21 +640,27 @@ class SLICENODE_SimplifyPaths(SliceNode): assert layout layout.prop(self, "preserve_topology") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") tolerance: float = self.resolve("Tolerance") or 0.0 if not paths or tolerance <= 0: return paths - return paths.simplify(tolerance, preserve_topology=self.preserve_topology) + def _simp(p: Any) -> SlicePaths: + if not p or tolerance <= 0: + return p + return p.simplify(tolerance, preserve_topology=self.preserve_topology) + + return eval_element_wise(_simp, paths) @register_class class SLICENODE_SeamPlacement(SliceNode): bl_label = "Seam Placement" + list_socket_mapping = [("Paths", "Paths")] mode: str = NfEnumProperty( name="Mode", @@ -614,11 +691,11 @@ class SLICENODE_SeamPlacement(SliceNode): layout.prop(self, "mode", text="") layout.prop(self, "acquire_transforms") - def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: + def evaluate_output(self, socket_name: str, **_kwargs) -> Any: if socket_name != "Paths": return SlicePaths() - paths: SlicePaths = self.resolve("Paths") + paths = self.resolve("Paths") mesh_input: Any = self.resolve("Mesh") stagger_val = self.resolve("Seam Staggering") stagger_mm: float = float(stagger_val) if stagger_val is not None else 0.0 @@ -626,10 +703,15 @@ class SLICENODE_SeamPlacement(SliceNode): if not paths: return SlicePaths() - if not mesh_input and abs(stagger_mm) < 1e-6: - return paths - acquire_t = bool(getattr(self, "acquire_transforms", True)) - return paths.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm) + + def _place(p: Any) -> SlicePaths: + if not p: + return SlicePaths() + if not mesh_input and abs(stagger_mm) < 1e-6: + return p + return p.place_seams(mesh_input, mode=self.mode.lower(), acquire_transforms=acquire_t, stagger_mm=stagger_mm) + + return eval_element_wise(_place, paths) diff --git a/core/nodeslicer/nodes/preview.py b/core/nodeslicer/nodes/preview.py index 4de1ad3..b578cfa 100644 --- a/core/nodeslicer/nodes/preview.py +++ b/core/nodeslicer/nodes/preview.py @@ -20,6 +20,13 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): self.add_input("NodeSocketString", "Name") self.add_multi_socket_group("NodeSocketString", name="Gcode") + def update(self) -> None: + super().update() + from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape + for inp in self.inputs: + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): + set_socket_shape(inp, "LIST" if is_socket_linked_to_list(inp) else "CIRCLE") + def draw_buttons(self, context, layout): super().draw_buttons(context, layout) layout.prop(self, 'show_e_0') @@ -229,7 +236,9 @@ class SLICENODE_GcodeToMesh(ExecSliceNode): mod["Socket_9"] = tuple(map(float, prep_scale)) def evaluate(self, operator=None): - gcodes: list[str] = [g for g in (self.resolve_multi_input("Gcode") or []) if g] + from ....infra.blender.list_utils import flatten_list + raw_gcodes = self.resolve_multi_input("Gcode") or [] + gcodes: list[str] = [str(g) for g in flatten_list(raw_gcodes) if g] base_mesh_name: str = self.resolve("Name") if not base_mesh_name: diff --git a/core/nodeslicer/nodes/system.py b/core/nodeslicer/nodes/system.py index c117c7b..85aaa9e 100644 --- a/core/nodeslicer/nodes/system.py +++ b/core/nodeslicer/nodes/system.py @@ -100,6 +100,11 @@ class SLICENODE_PrinterList(ExecSliceNode): if not gcode: continue + from ....infra.blender.list_utils import is_list_data + if is_list_data(gcode): + if operator: operator.report({'ERROR'}, f"Printer List does not accept a list of Gcode on socket {sock.name}") + continue + p_data = printer_configs[sock.name] host = str(p_data.get("ip", "localhost")) port = int(p_data.get("port", 80)) diff --git a/core/nodeslicer/tree.py b/core/nodeslicer/tree.py index 76518c0..01a9929 100644 --- a/core/nodeslicer/tree.py +++ b/core/nodeslicer/tree.py @@ -121,12 +121,23 @@ def depsgraph_update_handler(scene, depsgraph): # Sync SLICENODE_Group sockets if interface changed and migrate legacy sockets for tree in bpy.data.node_groups: if tree.bl_idname == "SLICETREE_NodeTree": + try: + from ...infra.blender.list_utils import sync_group_tree_internal_sockets + sync_group_tree_internal_sockets(tree) + except Exception: + pass for node in tree.nodes: - if node.bl_idname == "SLICENODE_Group" and hasattr(node, "sync_sockets"): - try: - node.sync_sockets() - except Exception: - pass + if node.bl_idname == "SLICENODE_Group": + if hasattr(node, "sync_sockets"): + try: + node.sync_sockets() + except Exception: + pass + elif hasattr(node, "sync_socket_shapes"): + try: + node.sync_socket_shapes() + except Exception: + pass elif node.bl_idname == "SLICENODE_CollectionObjects" and hasattr(node, "_update_outputs"): try: node._update_outputs() @@ -149,9 +160,35 @@ class SLICETREE_NodeTree(NodeTree): def valid_link(self, from_socket: Any, to_socket: Any) -> bool: geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} - if from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets: - return True - return from_socket.bl_idname == to_socket.bl_idname + compatible = ( + (from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets) + or from_socket.bl_idname == to_socket.bl_idname + ) + if not compatible: + return False + + from ...infra.blender.list_utils import is_output_socket_a_list, group_input_can_accept_list + if is_output_socket_a_list(from_socket): + to_node = getattr(to_socket, "node", None) + if to_node is not None: + # Start Print and Printer List reject lists + if to_node.bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"): + return False + # Path Operations: only input 0 can accept a list; secondary inputs reject lists + if to_node.bl_idname == "SLICENODE_PathOperations": + if getattr(to_node, "inputs", None) and to_socket != to_node.inputs[0]: + return False + # Seam Placement: secondary input (Mesh) rejects lists + if to_node.bl_idname == "SLICENODE_SeamPlacement": + if getattr(to_socket, "name", "") == "Mesh": + return False + # Group node: check if input can accept list + if to_node.bl_idname == "SLICENODE_Group": + tree = getattr(to_node, "slicer_tree", None) or getattr(to_node, "node_tree", None) + if tree and not group_input_can_accept_list(tree, to_socket): + return False + + return True @register_class @@ -590,6 +627,11 @@ class SLICENODE_MT_Joiners(Menu): def draw(self, context): layout = self.layout assert layout + 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="Gcodes to List").type = "SLICENODE_GcodesToList" + layout.separator() layout.operator("node.add_node", text="Join Mesh Parts").type = "SLICENODE_JoinMesh" layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups" layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations" diff --git a/core/slice.py b/core/slice.py index fa88808..654f8a5 100644 --- a/core/slice.py +++ b/core/slice.py @@ -1060,6 +1060,7 @@ def shift_path_seam_by_distance( class SlicePaths(list[np.ndarray]): + is_slice_paths: bool = True groups: list[int] extrusion_rate: list[np.ndarray] feedrate: list[np.ndarray] diff --git a/doc/MODULES.md b/doc/MODULES.md index ae20cc0..b1abb79 100644 --- a/doc/MODULES.md +++ b/doc/MODULES.md @@ -72,3 +72,9 @@ This file provides a mapping of modules to their primary responsibilities and ke - `get_last_debug_dump()` / `get_last_debug_text()` / `load_last_inputs()`: Utilities to review and reload the most recent execution dump for debugging. - `debug_session()`: Context manager enabling execution capture across node evaluations. - `SLICENODE_OT_DebugDump`: Blender operator (`slicenode.debug_dump`) to dump the active node's inputs on demand. + +- `list_utils.py`: + - `is_list_data()`: Type discrimination distinguishing list/tuple collections from atomic objects (such as `SlicePaths`). + - `flatten_list()`: Recursive flattener for multi-input joiners and batch sinks. + - `eval_element_wise()`: Vectorized applicator executing operations element-wise on lists or directly on single items. + - `set_socket_shape()` / `is_socket_linked_to_list()` / `is_output_socket_a_list()` / `sync_node_list_sockets()`: Dynamic socket visual appearance management (`LIST` vs `CIRCLE`). diff --git a/doc/NODES.md b/doc/NODES.md index 5d80b89..ab27ce9 100644 --- a/doc/NODES.md +++ b/doc/NODES.md @@ -168,10 +168,14 @@ All nodes are found in the **Add (Shift+A)** menu under the **SliceNodes** categ - **Load Gcode**: Reads a G-code file from disk. ### Utility & Joiners -- **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`. -- **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`. -- **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations. -- **Join Gcode**: Concatenates multiple G-code strings. +- **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. +- **Gcodes to List**: Multi-input list joiner that bundles G-code strings into a `list[str]` with LIST socket shape. Flattens incoming lists. +- **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. +- **Join Gcode**: Concatenates multiple G-code inputs. If no input is a list, concatenates scalar strings into a single string (CIRCLE). If one or more inputs are lists (which must all be the same length), outputs a list of strings (LIST) where each element at index $i$ is joined with static elements broadcast across all indices. - **Join Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys. - **Join Execution**: Synchronizes sequential execution flows. diff --git a/doc/nodes_list_behavior.csv b/doc/nodes_list_behavior.csv index 9a8bde5..9ab2316 100644 --- a/doc/nodes_list_behavior.csv +++ b/doc/nodes_list_behavior.csv @@ -1,4 +1,4 @@ -Type,Category,Node Name,Class Name,File,Archetype,First Input Socket,Other Input Sockets,Output Socket(s),First Input Shape,Output Shape,Single Item Behavior,List Behavior +Type,Category,Node Name,Class Name,File,Archetype,First Input Socket,Other Input Sockets,Output Socket(s),First Input Shape,Output Shape,Single Item Behavior,List Behavior Object,List Joiners (New),Objects to List,SLICENODE_ObjectsToList,core/nodeslicer/nodes/inputs.py,List Joiner,Multi-input NodeSocketObject (Object),None,NodeSocketObject (Objects),Standard / LIST,Fixed LIST,Wraps single item into a 1-element list [Obj1],Flattens incoming lists and combines all objects into list[bpy.types.Object]. Output appearance is LIST. Mesh,List Joiners (New),Meshes to List,SLICENODE_MeshesToList,core/nodeslicer/nodes/mesh.py,List Joiner,Multi-input SLICESOCK_Mesh (Mesh),None,SLICESOCK_Mesh (Meshes),Standard / LIST,Fixed LIST,Wraps single mesh into a 1-element list [Mesh1],Flattens incoming lists and bundles all meshes into list[SlicingCollection]. Output appearance is LIST. Path,List Joiners (New),Paths to List,SLICENODE_PathsToList,core/nodeslicer/nodes/path.py,List Joiner,Multi-input SLICESOCK_Paths (Paths),None,SLICESOCK_Paths (Paths),Standard / LIST,Fixed LIST,Wraps single path into a 1-element list [Path1],Flattens incoming lists and bundles all toolpaths into list[SlicePaths]. Output appearance is LIST. @@ -33,8 +33,8 @@ Path,G-code Processing,Paths to Gcode,SLICENODE_PathsToGcode,core/nodeslicer/nod Gcode,G-code Processing,Gcode Regex Replace,SLICENODE_GcodeRegexReplace,core/nodeslicer/nodes/gcode.py,Element-wise Operator,NodeSocketString (Gcode),"Find, Replace (cannot be list)",NodeSocketString (Gcode),Dynamic (Changes to LIST if input is list),Dynamic (Changes to LIST if input is list),Runs regex find & replace on G-code string,"First input is list [G1, G2, ...]. Runs regex search and replace on each G-code string using the same pattern. Output is [G1', G2', ...]. Sockets switch to LIST." Gcode,Action & I/O,Gcode to File,SLICENODE_GcodeToFile,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Folder, Name (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Saves G-code string to .gcode or .bgcode file on disk,"First input is list [G1, G2, ...]. Saves a separate file for each element using the same folder/name template with zero-padded numbering (Part00.gcode, Part01.gcode) or metadata names." Gcode,Action & I/O,Gcode to Text Block,SLICENODE_GcodeToTextBlock,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list],Text Block Name (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Writes G-code string to an active Blender text editor data-block,"First input is list [G1, G2, ...]. Writes separate numbered Blender text blocks (NodeSlicer_00.gcode, NodeSlicer_01.gcode) using the base name." -Gcode,Action & I/O,Start Print,SLICENODE_StartPrint,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list],SLICESOCK_Printer (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads and starts print on network printer,"First input is list [G1, G2, ...]. Sequentially queues and sends each G-code print job to the target printer using the same printer configuration." +Gcode,Action & I/O,Start Print,SLICENODE_StartPrint,core/nodeslicer/nodes/output.py,Single Item Sink,NodeSocketString (Gcode) [Single item only],SLICESOCK_Printer (Single item only),Execution Link,Standard (CIRCLE),Standard Execution Link,Uploads and starts print on network printer,List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received. Gcode,Action & I/O,Baserow Submit,SLICENODE_BaserowSubmit,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Settings, Host, Token, Table ID, Record ID (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads G-code attachment and creates/updates row in Baserow database,"First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Baserow using the same database credentials." Gcode,Action & I/O,Airtable Submit,SLICENODE_AirtableSubmit,core/nodeslicer/nodes/output.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Settings, PAT File, Base ID, Table ID, Record ID (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Uploads G-code attachment and creates/updates row in Airtable base,"First input is list [G1, G2, ...]. Submits each G-code as a separate record and attachment in Airtable using the same database credentials." Gcode,Action & I/O,Gcode to Preview Mesh,SLICENODE_GcodeToMesh,core/nodeslicer/nodes/preview.py,Batch Sink,NodeSocketString (Gcode) [Can be a list or multi-input],"Name, show_e_0 (cannot be list)",Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Decodes toolpaths and reconstructs 3D wireframe preview mesh object in Blender,"First input is list [G1, G2, ...]. Generates a separate preview mesh object in the scene (Preview00, Preview01, ...) for each G-code in the list." -Gcode,Action & I/O,Printer List,SLICENODE_PrinterList,core/nodeslicer/nodes/system.py,Batch Sink,Dynamic G-code sockets per printer [Can be a list],Config File JSON (cannot be list),Execution Link,Dynamic (Displays LIST if input is list),Standard Execution Link,Sends G-code to corresponding printer defined in config JSON,"If a printer socket receives a list [G1, G2, ...], queues and dispatches all jobs for that printer." +Gcode,Action & I/O,Printer List,SLICENODE_PrinterList,core/nodeslicer/nodes/system.py,Single Item Sink,Dynamic G-code sockets per printer [Single item only],Config File JSON (cannot be list),Execution Link,Standard (CIRCLE),Standard Execution Link,Sends G-code to corresponding printer defined in config JSON,List is not accepted. Sockets reject list connections and evaluate() ignores list inputs. diff --git a/infra/blender/blender_slice_nodes_generic.py b/infra/blender/blender_slice_nodes_generic.py index 098073b..d2ae756 100644 --- a/infra/blender/blender_slice_nodes_generic.py +++ b/infra/blender/blender_slice_nodes_generic.py @@ -9,6 +9,8 @@ from bpy.types import NodeSocket, Node class NodeException(Exception): ... +from .list_utils import is_list_data, flatten_list, eval_element_wise, set_socket_shape, is_socket_linked_to_list, sync_node_list_sockets + _EvalCache = dict[Any, Any] _VisitedSet = set[Any] @@ -116,8 +118,17 @@ class SliceNode(Node, AutoBlIdName): return key in self._data return False + list_socket_mapping: list[tuple[str, list[str] | str]] = [] + def update(self) -> None: self._update_multi_groups() + self._sync_list_socket_shapes() + + def _sync_list_socket_shapes(self) -> None: + mapping = getattr(self, "list_socket_mapping", None) + if mapping: + from .list_utils import sync_node_list_sockets + sync_node_list_sockets(self, mapping) def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ... @@ -475,6 +486,21 @@ class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName): # 3. Sync Outputs self._sync_socket_collection(self.outputs, expected_outputs) + # 4. Sync Socket Shapes (LIST vs CIRCLE) + self.sync_socket_shapes() + + def sync_socket_shapes(self): + try: + if not self.name or not self.slicer_tree: + return + except Exception: + return + from .list_utils import sync_group_node_socket_shapes + sync_group_node_socket_shapes(self) + + def update(self): + self.sync_socket_shapes() + def _sync_socket_collection(self, collection, expected): # Remove sockets that shouldn't be there expected_ids = {e[0] for e in expected} diff --git a/infra/blender/list_utils.py b/infra/blender/list_utils.py new file mode 100644 index 0000000..55d177d --- /dev/null +++ b/infra/blender/list_utils.py @@ -0,0 +1,563 @@ +from __future__ import annotations +from typing import Any, Callable + +LIST_CAPABLE_SOCKET_TYPES = { + "SLICESOCK_Mesh", + "SLICESOCK_Paths", + "SLICESOCK_Object", + "SLICESOCK_Geometry", + "NodeSocketObject", + "NodeSocketString", +} + +def is_list_data(val: Any) -> bool: + """Checks if val represents a list of items (Object, Mesh, Path, Gcode). + Note: SlicePaths is a list subclass representing an individual toolpath collection, + not a list of toolpaths, so it is treated as an atomic item (not list data).""" + if val is None or isinstance(val, (str, bytes)): + return False + if type(val).__name__ == "SlicePaths" or getattr(val, "is_slice_paths", False): + return False + return isinstance(val, (list, tuple)) + +def flatten_list(items: Any) -> list[Any]: + """Recursively flattens nested lists/tuples, keeping atomic items intact.""" + result: list[Any] = [] + if not is_list_data(items): + if items is not None: + result.append(items) + return result + for item in items: + if is_list_data(item): + result.extend(flatten_list(item)) + elif item is not None: + result.append(item) + return result + +def eval_element_wise(op_func: Callable[[Any], Any], data: Any) -> Any: + """If data is a list, applies op_func to each element and returns a list. + Otherwise applies op_func to data directly.""" + if is_list_data(data): + return [op_func(item) for item in data] + return op_func(data) + +def set_socket_shape(sock: Any, shape: str) -> None: + """Safely updates socket display_shape property ('LIST', 'CIRCLE', etc.).""" + if sock is not None: + try: + sock.display_shape = shape + except (AttributeError, TypeError): + pass + +def _follow_reroute_upstream(sock: Any, visited: set | None = None) -> Any: + """Follows reroute nodes backwards to find the original source output socket.""" + if visited is None: + visited = set() + current = sock + while ( + current is not None + and getattr(current, "node", None) is not None + and getattr(current.node, "bl_idname", "") == "NodeReroute" + and getattr(current.node, "inputs", None) + and current.node.inputs[0].is_linked + and current.node.inputs[0].links + ): + ptr = current.as_pointer() if hasattr(current, "as_pointer") else id(current) + if ptr in visited: + break + visited.add(ptr) + current = current.node.inputs[0].links[0].from_socket + return current + +def _follow_reroute_downstream(reroute_node: Any, visited: set | None = None) -> list[Any]: + """Follows reroute nodes forwards to find all downstream target input sockets.""" + if visited is None: + visited = set() + node_ptr = reroute_node.as_pointer() if hasattr(reroute_node, "as_pointer") else id(reroute_node) + if node_ptr in visited: + return [] + visited.add(node_ptr) + + targets: list[Any] = [] + for outp in getattr(reroute_node, "outputs", []): + for link in getattr(outp, "links", []): + to_sock = link.to_socket + to_node = getattr(to_sock, "node", None) + if to_node and getattr(to_node, "bl_idname", "") == "NodeReroute": + targets.extend(_follow_reroute_downstream(to_node, visited)) + else: + targets.append(to_sock) + return targets + +def socket_accepts_list(sock: Any, visited: set | None = None) -> bool: + """Determines whether a given input socket can accept a list input.""" + if sock is None: + return False + if visited is None: + visited = set() + sock_ptr = sock.as_pointer() if hasattr(sock, "as_pointer") else id(sock) + if sock_ptr in visited: + return False + visited.add(sock_ptr) + + node = getattr(sock, "node", None) + if node is None: + return False + + bl_idname = getattr(node, "bl_idname", "") + + # Strictly forbidden nodes + if bl_idname in ("SLICENODE_StartPrint", "SLICENODE_PrinterList"): + return False + + # Path Operations: only input 0 can accept a list + if bl_idname == "SLICENODE_PathOperations": + inputs = getattr(node, "inputs", None) + if inputs and sock != inputs[0]: + return False + return True + + # Seam Placement: "Mesh" input rejects lists; "Paths" accepts lists + if bl_idname == "SLICENODE_SeamPlacement": + if getattr(sock, "name", "") == "Mesh": + return False + return True + + # Socket type check + sock_type = getattr(sock, "bl_idname", "") + if sock_type not in LIST_CAPABLE_SOCKET_TYPES: + return False + + # String sockets: only Gcode accepts lists + if sock_type == "NodeSocketString": + sock_name = getattr(sock, "name", "") + if bl_idname == "SLICENODE_JoinGcode": + return True + if bl_idname in ("SLICENODE_WriteGcode", "SLICENODE_ExecuteGcode"): + return sock_name == "Gcode" + if bl_idname == "SLICENODE_GcodeRegexReplace": + return sock_name == "Gcode" + if bl_idname == "SLICENODE_GcodesToList": + return True + if bl_idname == "NodeGroupOutput": + return sock_name.lower().startswith("gcode") + if bl_idname == "SLICENODE_Group": + inner_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) + if inner_tree: + return group_input_can_accept_list(inner_tree, sock, visited) + return False + return False + + # Reroute node: check downstream targets + if bl_idname == "NodeReroute": + targets = _follow_reroute_downstream(node, visited) + if not targets: + return False + return all(socket_accepts_list(t, visited) for t in targets) + + # NodeGroupOutput + if bl_idname == "NodeGroupOutput": + return True + + # Nested SLICENODE_Group + if bl_idname == "SLICENODE_Group": + inner_tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) + if inner_tree: + return group_input_can_accept_list(inner_tree, sock, visited) + return False + + # List joiners accept lists + if getattr(node, "is_list_output", False): + return True + + # Data joiners accept lists + if bl_idname in ("SLICENODE_JoinMesh", "SLICENODE_JoinMeshGroups", "SLICENODE_JoinGcode"): + return True + + # Nodes with list_socket_mapping + mapping = getattr(node, "list_socket_mapping", None) + if mapping: + mapped_in_names = {item[0] for item in mapping} + if getattr(sock, "name", "") in mapped_in_names: + return True + + return False + +def _get_input_interface_item(tree: Any, sock_or_idx_or_ident: Any) -> Any: + if not hasattr(tree, "interface"): + inputs = getattr(tree, "inputs", []) + if isinstance(sock_or_idx_or_ident, int) and 0 <= sock_or_idx_or_ident < len(inputs): + return inputs[sock_or_idx_or_ident] + ident = getattr(sock_or_idx_or_ident, "identifier", str(sock_or_idx_or_ident)) + for s in inputs: + if s.identifier == ident or s.name == ident: + return s + return None + + input_items = [ + item for item in getattr(tree.interface, "items_tree", []) + if getattr(item, "item_type", "") == 'SOCKET' and getattr(item, "in_out", "") == 'INPUT' + ] + if isinstance(sock_or_idx_or_ident, int): + if 0 <= sock_or_idx_or_ident < len(input_items): + return input_items[sock_or_idx_or_ident] + return None + ident = getattr(sock_or_idx_or_ident, "identifier", None) + name = getattr(sock_or_idx_or_ident, "name", None) + if isinstance(sock_or_idx_or_ident, str): + ident = sock_or_idx_or_ident + name = sock_or_idx_or_ident + for item in input_items: + if ident and getattr(item, "identifier", None) == ident: + return item + for item in input_items: + if name and getattr(item, "name", None) == name: + return item + return None + +def _get_output_interface_item(tree: Any, sock_or_idx_or_ident: Any) -> Any: + if not hasattr(tree, "interface"): + outputs = getattr(tree, "outputs", []) + if isinstance(sock_or_idx_or_ident, int) and 0 <= sock_or_idx_or_ident < len(outputs): + return outputs[sock_or_idx_or_ident] + ident = getattr(sock_or_idx_or_ident, "identifier", str(sock_or_idx_or_ident)) + for s in outputs: + if s.identifier == ident or s.name == ident: + return s + return None + + output_items = [ + item for item in getattr(tree.interface, "items_tree", []) + if getattr(item, "item_type", "") == 'SOCKET' and getattr(item, "in_out", "") == 'OUTPUT' + ] + if isinstance(sock_or_idx_or_ident, int): + if 0 <= sock_or_idx_or_ident < len(output_items): + return output_items[sock_or_idx_or_ident] + return None + ident = getattr(sock_or_idx_or_ident, "identifier", None) + name = getattr(sock_or_idx_or_ident, "name", None) + if isinstance(sock_or_idx_or_ident, str): + ident = sock_or_idx_or_ident + name = sock_or_idx_or_ident + for item in output_items: + if ident and getattr(item, "identifier", None) == ident: + return item + for item in output_items: + if name and getattr(item, "name", None) == name: + return item + return None + +def _find_group_input_socket(tree: Any, item: Any) -> Any: + if not item or not hasattr(tree, "nodes"): + return None + ident = getattr(item, "identifier", "") + name = getattr(item, "name", "") + for n in tree.nodes: + if getattr(n, "bl_idname", "") == "NodeGroupInput": + for outp in getattr(n, "outputs", []): + if outp.identifier == ident or outp.name == name: + return outp + return None + +def _find_group_output_socket(tree: Any, item: Any) -> Any: + if not item or not hasattr(tree, "nodes"): + return None + ident = getattr(item, "identifier", "") + name = getattr(item, "name", "") + for n in tree.nodes: + if getattr(n, "bl_idname", "") == "NodeGroupOutput": + for inp in getattr(n, "inputs", []): + if inp.identifier == ident or inp.name == name: + return inp + return None + +def group_input_can_accept_list(tree: Any, sock_or_idx_or_ident: Any, visited: set | None = None) -> bool: + """Determines whether a group tree input socket can accept a list input.""" + if not tree: + return False + if visited is None: + visited = set() + + item = _get_input_interface_item(tree, sock_or_idx_or_ident) + if not item: + return False + + item_key = (tree.as_pointer() if hasattr(tree, "as_pointer") else id(tree), getattr(item, "identifier", id(item))) + if item_key in visited: + return False + visited.add(item_key) + + stype = getattr(item, "socket_type", "") or getattr(item, "bl_idname", "") + if stype not in LIST_CAPABLE_SOCKET_TYPES: + return False + + gin_sock = _find_group_input_socket(tree, item) + if not gin_sock or not getattr(gin_sock, "is_linked", False) or not gin_sock.links: + return False + + targets: list[Any] = [] + for link in gin_sock.links: + to_sock = link.to_socket + to_node = getattr(to_sock, "node", None) + if to_node and getattr(to_node, "bl_idname", "") == "NodeReroute": + targets.extend(_follow_reroute_downstream(to_node, visited)) + else: + targets.append(to_sock) + + if not targets: + return False + + return all(socket_accepts_list(t, visited) for t in targets) + +def is_internal_socket_a_list(group_node: Any, tree: Any, in_sock: Any, visited: set | None = None) -> bool: + """Determines whether an internal input socket receives a list within a group context.""" + if not in_sock or not getattr(in_sock, "is_linked", False) or not in_sock.links: + return False + from_sock = in_sock.links[0].from_socket + from_sock = _follow_reroute_upstream(from_sock, visited) + if not from_sock: + return False + from_node = getattr(from_sock, "node", None) + if not from_node: + return False + + if getattr(from_node, "is_list_output", False): + return True + + if getattr(from_node, "bl_idname", "") == "NodeGroupInput": + in_item = _get_input_interface_item(tree, from_sock) + if not in_item: + return False + if group_node is not None: + for grp_in in getattr(group_node, "inputs", []): + if grp_in.identifier == getattr(in_item, "identifier", None) or grp_in.name == getattr(in_item, "name", None): + return is_socket_linked_to_list(grp_in) + return False + else: + return group_input_can_accept_list(tree, in_item, visited) + + if getattr(from_node, "bl_idname", "") == "SLICENODE_Group": + inner = getattr(from_node, "slicer_tree", None) or getattr(from_node, "node_tree", None) + if inner: + return is_group_output_a_list(from_node, inner, from_sock, visited) + return False + + if getattr(from_node, "bl_idname", "") == "SLICENODE_JoinGcode": + for inp in getattr(from_node, "inputs", []): + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): + if is_internal_socket_a_list(group_node, tree, inp, visited): + return True + return False + + mapping = getattr(from_node, "list_socket_mapping", None) + if mapping: + sock_name = getattr(from_sock, "name", "") + for m_in_name, m_out_names in mapping: + targets = [m_out_names] if isinstance(m_out_names, str) else list(m_out_names) + if sock_name in targets: + mapped_inp = getattr(from_node, "inputs", None) and from_node.inputs.get(m_in_name) + if mapped_inp: + return is_internal_socket_a_list(group_node, tree, mapped_inp, visited) + + return getattr(from_sock, "display_shape", "") == "LIST" + +def is_group_output_a_list( + group_node: Any, + tree: Any, + sock_or_idx_or_ident: Any, + visited: set | None = None +) -> bool: + """Determines whether a group tree output socket produces a list.""" + if not tree: + return False + if visited is None: + visited = set() + + item = _get_output_interface_item(tree, sock_or_idx_or_ident) + if not item: + return False + + item_key = ( + tree.as_pointer() if hasattr(tree, "as_pointer") else id(tree), + getattr(item, "identifier", id(item)), + id(group_node) if group_node else 0, + ) + if item_key in visited: + return False + visited.add(item_key) + + gout_sock = _find_group_output_socket(tree, item) + if not gout_sock or not getattr(gout_sock, "is_linked", False) or not gout_sock.links: + return False + + src_sock = gout_sock.links[0].from_socket + src_sock = _follow_reroute_upstream(src_sock, visited) + if not src_sock: + return False + + src_node = getattr(src_sock, "node", None) + if not src_node: + return False + + src_bl_id = getattr(src_node, "bl_idname", "") + + if getattr(src_node, "is_list_output", False): + return True + + if src_bl_id == "SLICENODE_JoinGcode": + for inp in getattr(src_node, "inputs", []): + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_"): + if is_internal_socket_a_list(group_node, tree, inp, visited): + return True + return False + + if src_bl_id == "SLICENODE_Group": + inner_tree = getattr(src_node, "slicer_tree", None) or getattr(src_node, "node_tree", None) + if inner_tree: + return is_group_output_a_list(src_node, inner_tree, src_sock, visited) + return False + + if src_bl_id == "NodeGroupInput": + in_item = _get_input_interface_item(tree, src_sock) + if not in_item: + return False + if group_node is not None: + for grp_in in getattr(group_node, "inputs", []): + if grp_in.identifier == getattr(in_item, "identifier", None) or grp_in.name == getattr(in_item, "name", None): + return is_socket_linked_to_list(grp_in) + return False + else: + return group_input_can_accept_list(tree, in_item, visited) + + mapping = getattr(src_node, "list_socket_mapping", None) + if mapping: + sock_name = getattr(src_sock, "name", "") + for in_name, out_names in mapping: + targets = [out_names] if isinstance(out_names, str) else list(out_names) + if sock_name in targets: + inp = getattr(src_node, "inputs", None) and src_node.inputs.get(in_name) + if inp: + return is_internal_socket_a_list(group_node, tree, inp, visited) + + return getattr(src_sock, "display_shape", "") == "LIST" + +def sync_group_node_socket_shapes(group_node: Any) -> None: + """Updates display_shape property on a SLICENODE_Group node's inputs and outputs.""" + tree = getattr(group_node, "slicer_tree", None) or getattr(group_node, "node_tree", None) + if not tree: + return + for inp in getattr(group_node, "inputs", []): + can_list = group_input_can_accept_list(tree, inp) + set_socket_shape(inp, "LIST" if can_list else "CIRCLE") + for outp in getattr(group_node, "outputs", []): + is_list = is_group_output_a_list(group_node, tree, outp) + set_socket_shape(outp, "LIST" if is_list else "CIRCLE") + +def sync_group_tree_internal_sockets(tree: Any) -> None: + """Updates display_shape property on NodeGroupInput and NodeGroupOutput within a group tree.""" + if not tree or not hasattr(tree, "nodes"): + return + for node in tree.nodes: + bl_id = getattr(node, "bl_idname", "") + if bl_id == "NodeGroupInput": + for outp in getattr(node, "outputs", []): + if outp.name == "" or outp.identifier == "__extend__": + continue + can_list = group_input_can_accept_list(tree, outp) + set_socket_shape(outp, "LIST" if can_list else "CIRCLE") + elif bl_id == "NodeGroupOutput": + for inp in getattr(node, "inputs", []): + if inp.name == "" or inp.identifier == "__extend__": + continue + is_list = is_group_output_a_list(None, tree, inp) + set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + +def is_output_socket_a_list(sock: Any, visited: set | None = None) -> bool: + """Determines whether an output socket produces a list.""" + if sock is None: + return False + if visited is None: + visited = set() + sock_ptr = sock.as_pointer() if hasattr(sock, "as_pointer") else id(sock) + if sock_ptr in visited: + return False + visited.add(sock_ptr) + + if getattr(sock, "display_shape", "") == "LIST": + return True + + node = getattr(sock, "node", None) + if node is None: + return False + + bl_idname = getattr(node, "bl_idname", "") + + if getattr(node, "is_list_output", False): + return True + + if bl_idname == "NodeReroute": + inputs = getattr(node, "inputs", None) + if inputs and inputs[0].is_linked and inputs[0].links: + return is_output_socket_a_list(inputs[0].links[0].from_socket, visited) + return False + + if bl_idname == "SLICENODE_JoinGcode": + inputs = getattr(node, "inputs", []) + return any( + is_socket_linked_to_list(inp) + for inp in inputs + if getattr(inp, "name", "") == "Gcode" or getattr(inp, "identifier", "").startswith("Gcode_ms_in_") + ) + + if bl_idname == "SLICENODE_Group": + tree = getattr(node, "slicer_tree", None) or getattr(node, "node_tree", None) + if tree: + return is_group_output_a_list(node, tree, sock, visited) + return False + + if bl_idname == "NodeGroupInput": + tree = getattr(node, "id_data", None) + if tree: + return group_input_can_accept_list(tree, sock, visited) + return False + + mapping = getattr(node, "list_socket_mapping", None) + if mapping: + sock_name = getattr(sock, "name", "") + for in_name, out_names in mapping: + targets = [out_names] if isinstance(out_names, str) else list(out_names) + if sock_name in targets: + inp = getattr(node, "inputs", None) and node.inputs.get(in_name) + if inp and is_socket_linked_to_list(inp): + return True + + return False + +def is_socket_linked_to_list(sock: Any) -> bool: + """Determines whether an input socket is linked to an upstream list output.""" + if not getattr(sock, "is_linked", False) or not getattr(sock, "links", None): + return False + from_socket = sock.links[0].from_socket + from_socket = _follow_reroute_upstream(from_socket) + return is_output_socket_a_list(from_socket) + +def sync_node_list_sockets(node: Any, mapping: list[tuple[str, list[str] | str]]) -> None: + """Updates display_shape on input and output sockets based on mapping. + mapping is a list of tuples: (input_socket_name, output_socket_names) + e.g. [('Paths', ['Paths']), ('Mesh', ['Selection', 'Inverted'])] + """ + for in_name, out_names in mapping: + inp = getattr(node, "inputs", None) and node.inputs.get(in_name) + if inp is None: + continue + is_list = is_socket_linked_to_list(inp) + set_socket_shape(inp, "LIST" if is_list else "CIRCLE") + + if isinstance(out_names, str): + targets = [out_names] + else: + targets = list(out_names) + for out_name in targets: + out_sock = getattr(node, "outputs", None) and node.outputs.get(out_name) + if out_sock is not None: + set_socket_shape(out_sock, "LIST" if is_list else "CIRCLE") diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 0000000..8fbbe95 --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,671 @@ +import sys +import unittest +from unittest.mock import MagicMock +from pathlib import Path +import numpy as np + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.append(str(PROJECT_ROOT.parent)) + +# Ensure bpy mocks +class DummyObject: + def __init__(self, name="TestObj"): + self.name = name + +class DummyMesh: pass +class DummyBase: pass + +mock_types = MagicMock() +mock_types.NodeSocket = type("NodeSocket", (DummyBase,), {}) +mock_types.Node = type("Node", (DummyBase,), {}) +mock_types.NodeCustomGroup = type("NodeCustomGroup", (DummyBase,), {}) +mock_types.NodeTree = type("NodeTree", (DummyBase,), {}) +mock_types.PropertyGroup = type("PropertyGroup", (DummyBase,), {}) +mock_types.Operator = type("Operator", (DummyBase,), {}) +mock_types.Panel = type("Panel", (DummyBase,), {}) +mock_types.Object = DummyObject +mock_types.Mesh = DummyMesh + +mock_bpy = MagicMock() +mock_bpy.context.scene = MagicMock() +mock_bpy.context.scene.unit_settings.scale_length = 0.001 +mock_bpy.types = mock_types +mock_bpy.path.abspath = lambda path: path.replace("//", "/abs/") + +sys.modules['bpy'] = mock_bpy +sys.modules['bpy.types'] = mock_types +sys.modules['bpy.props'] = MagicMock() +sys.modules['bpy.utils'] = MagicMock() +sys.modules['bpy.utils.previews'] = MagicMock() +sys.modules['bpy.app'] = MagicMock() +sys.modules['bpy.app.handlers'] = MagicMock() + +from NodeSlicer.infra.blender.list_utils import ( + is_list_data, + flatten_list, + eval_element_wise, + set_socket_shape, + is_socket_linked_to_list, + is_output_socket_a_list, + sync_node_list_sockets, + group_input_can_accept_list, + is_group_output_a_list, + sync_group_node_socket_shapes, +) +from NodeSlicer.core.slice import SlicePaths +from NodeSlicer.infra.blender.blender_mesh_capture import SlicingObject, SlicingCollection, SlicingGroup +from NodeSlicer.core.nodeslicer.tree import SLICETREE_NodeTree + + +class TestListUtils(unittest.TestCase): + def test_is_list_data(self): + self.assertFalse(is_list_data(None)) + self.assertFalse(is_list_data("a string")) + self.assertFalse(is_list_data(b"bytes")) + self.assertFalse(is_list_data(123)) + self.assertFalse(is_list_data(12.34)) + + # SlicePaths subclasses list, but represents an atomic toolpath set, NOT a list of toolpaths + sp = SlicePaths() + self.assertFalse(is_list_data(sp)) + + # Normal lists and tuples + self.assertTrue(is_list_data([])) + self.assertTrue(is_list_data([1, 2, 3])) + self.assertTrue(is_list_data((1, 2))) + self.assertTrue(is_list_data([sp, sp])) + + def test_flatten_list(self): + sp1 = SlicePaths() + sp2 = SlicePaths() + nested = [sp1, [sp2, None], [["gcode1"], "gcode2"]] + flattened = flatten_list(nested) + self.assertEqual(flattened, [sp1, sp2, "gcode1", "gcode2"]) + + def test_eval_element_wise(self): + def double(x): + return x * 2 + + self.assertEqual(eval_element_wise(double, 5), 10) + self.assertEqual(eval_element_wise(double, [1, 2, 3]), [2, 4, 6]) + + +class DummySocket: + def __init__(self, name="Socket", bl_idname="NodeSocketFloat", display_shape="CIRCLE"): + self.name = name + self.identifier = name + self.bl_idname = bl_idname + self.display_shape = display_shape + self.is_linked = False + self.links = [] + self.node = None + self.value = None + + +class DummyLink: + def __init__(self, from_socket, to_socket): + self.from_socket = from_socket + self.to_socket = to_socket + + +class DummySocketCollection(list): + def get(self, name, default=None): + for s in self: + if getattr(s, "name", "") == name: + return s + return default + +class DummyNode: + def __init__(self, bl_idname="DummyNode"): + self.bl_idname = bl_idname + self.inputs = DummySocketCollection() + self.outputs = DummySocketCollection() + self.is_list_output = False + + def add_in(self, sock): + sock.node = self + self.inputs.append(sock) + + def add_out(self, sock): + sock.node = self + self.outputs.append(sock) + + +class TestSocketShapesAndLinks(unittest.TestCase): + def test_socket_linked_to_list(self): + upstream_node = DummyNode("SLICENODE_MeshesToList") + upstream_node.is_list_output = True + out_sock = DummySocket("Meshes", "SLICESOCK_Mesh", display_shape="LIST") + upstream_node.add_out(out_sock) + + downstream_node = DummyNode("SLICENODE_SliceShells") + in_sock = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="CIRCLE") + downstream_node.add_in(in_sock) + + # Before linking + self.assertFalse(is_socket_linked_to_list(in_sock)) + self.assertTrue(is_output_socket_a_list(out_sock)) + + # Link them + link = DummyLink(out_sock, in_sock) + in_sock.is_linked = True + in_sock.links = [link] + + self.assertTrue(is_socket_linked_to_list(in_sock)) + + def test_sync_node_list_sockets(self): + upstream_node = DummyNode("SLICENODE_MeshesToList") + out_sock = DummySocket("Meshes", "SLICESOCK_Mesh", display_shape="LIST") + upstream_node.add_out(out_sock) + + node = DummyNode("SLICENODE_SliceShells") + in_sock = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="CIRCLE") + out_path = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE") + node.add_in(in_sock) + node.add_out(out_path) + + link = DummyLink(out_sock, in_sock) + in_sock.is_linked = True + in_sock.links = [link] + + mapping = [("Mesh", "Paths")] + sync_node_list_sockets(node, mapping) + + self.assertEqual(in_sock.display_shape, "LIST") + self.assertEqual(out_path.display_shape, "LIST") + + +class TestTreeValidLinkRules(unittest.TestCase): + def setUp(self): + self.tree = SLICETREE_NodeTree() + + def test_valid_link_start_print_rejects_list(self): + list_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST") + start_print = DummyNode("SLICENODE_StartPrint") + sp_in = DummySocket("Gcode", "NodeSocketString") + start_print.add_in(sp_in) + + # Connection should be rejected + self.assertFalse(self.tree.valid_link(list_out, sp_in)) + + # Standard non-list connection should be allowed + normal_out = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE") + self.assertTrue(self.tree.valid_link(normal_out, sp_in)) + + def test_valid_link_printer_list_rejects_list(self): + list_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST") + printer_list = DummyNode("SLICENODE_PrinterList") + pl_in = DummySocket("Printer1", "NodeSocketString") + printer_list.add_in(pl_in) + + self.assertFalse(self.tree.valid_link(list_out, pl_in)) + + def test_valid_link_path_operations_secondary_inputs_reject_list(self): + list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") + path_ops = DummyNode("SLICENODE_PathOperations") + p0 = DummySocket("Paths", "SLICESOCK_Paths") + p1 = DummySocket("Paths", "SLICESOCK_Paths") + path_ops.add_in(p0) + path_ops.add_in(p1) + + # Primary input p0 accepts list + self.assertTrue(self.tree.valid_link(list_out, p0)) + # Secondary input p1 rejects list + self.assertFalse(self.tree.valid_link(list_out, p1)) + + def test_valid_link_seam_placement_cutting_mesh_rejects_list(self): + list_mesh_out = DummySocket("Mesh", "SLICESOCK_Mesh", display_shape="LIST") + seam_node = DummyNode("SLICENODE_SeamPlacement") + p_in = DummySocket("Paths", "SLICESOCK_Paths") + m_in = DummySocket("Mesh", "SLICESOCK_Mesh") + seam_node.add_in(p_in) + seam_node.add_in(m_in) + + # Secondary cutting mesh rejects list + self.assertFalse(self.tree.valid_link(list_mesh_out, m_in)) + + +class TestNodeEvaluationsWithLists(unittest.TestCase): + def test_path_operations_with_first_input_list(self): + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathOperations + + node = SLICENODE_PathOperations() + node.operation = 'EXTEND' + + p1 = SlicePaths([np.array([[0, 0, 0], [1, 1, 0]], dtype=np.float64)]) + p2 = SlicePaths([np.array([[2, 2, 0], [3, 3, 0]], dtype=np.float64)]) + q = SlicePaths([np.array([[10, 10, 0], [11, 11, 0]], dtype=np.float64)]) + + # Mock inputs + inp0 = DummySocket("Paths", "SLICESOCK_Paths") + inp0.is_linked = True + inp1 = DummySocket("Paths", "SLICESOCK_Paths") + inp1.is_linked = True + + node.inputs = [inp0, inp1] + + # resolve input mocking + def mock_resolve_input(sock, **kwargs): + if sock is inp0: + return [p1, p2] # List input + elif sock is inp1: + return q + return None + + import NodeSlicer.infra.blender.blender_slice_nodes_generic as b_gen + original_resolve = b_gen.resolve_input + b_gen.resolve_input = mock_resolve_input + try: + res = node.evaluate_output("Paths") + self.assertIsInstance(res, list) + self.assertEqual(len(res), 2) + # res[0] is EXTEND of p1 and q + self.assertEqual(len(res[0]), 2) + # res[1] is EXTEND of p2 and q + self.assertEqual(len(res[1]), 2) + finally: + b_gen.resolve_input = original_resolve + + def test_join_gcode_no_lists(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode + + node = SLICENODE_JoinGcode() + node.resolve_multi_input = lambda label: ["G28", "G90", "M104 S200"] + res = node.evaluate_output("Gcode") + self.assertEqual(res, "G28\nG90\nM104 S200") + + def test_join_gcode_one_list(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode + + node = SLICENODE_JoinGcode() + node.resolve_multi_input = lambda label: ["START", ["LAYER1", "LAYER2", "LAYER3"], "END"] + res = node.evaluate_output("Gcode") + self.assertEqual(res, [ + "START\nLAYER1\nEND", + "START\nLAYER2\nEND", + "START\nLAYER3\nEND", + ]) + + def test_join_gcode_multiple_lists_zipped(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode + + node = SLICENODE_JoinGcode() + node.resolve_multi_input = lambda label: [ + "INIT", + ["A1", "A2", "A3"], + "MID", + ["B1", "B2", "B3"], + "FINAL", + ] + res = node.evaluate_output("Gcode") + self.assertEqual(res, [ + "INIT\nA1\nMID\nB1\nFINAL", + "INIT\nA2\nMID\nB2\nFINAL", + "INIT\nA3\nMID\nB3\nFINAL", + ]) + + def test_join_gcode_mismatched_lengths_raises_error(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode + + node = SLICENODE_JoinGcode() + node.resolve_multi_input = lambda label: [["A1", "A2"], ["B1", "B2", "B3"]] + with self.assertRaises(ValueError): + node.evaluate_output("Gcode") + + def test_join_gcode_socket_shape_updates(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_JoinGcode + + node = SLICENODE_JoinGcode() + in0 = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE") + out0 = DummySocket("Gcode", "NodeSocketString", display_shape="CIRCLE") + node.inputs = DummySocketCollection([in0]) + node.outputs = DummySocketCollection([out0]) + + # Initially without list link + node.update() + self.assertEqual(out0.display_shape, "CIRCLE") + + # Linked to list + upstream = DummyNode("SLICENODE_GcodesToList") + upstream.is_list_output = True + up_out = DummySocket("Gcode", "NodeSocketString", display_shape="LIST") + upstream.add_out(up_out) + + link = DummyLink(up_out, in0) + in0.is_linked = True + in0.links = [link] + + node.update() + self.assertEqual(out0.display_shape, "LIST") + + def test_gcode_regex_replace_list(self): + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_GcodeRegexReplace + + node = SLICENODE_GcodeRegexReplace() + node.resolve = lambda field: { + "Gcode": ["G1 X10 Y10", "G1 X20 Y20"], + "Find": "G1", + "Replace": "G0" + }.get(field) + + res = node.evaluate_output("Gcode") + self.assertEqual(res, ["G0 X10 Y10", "G0 X20 Y20"]) + + def test_list_joiners_evaluation(self): + from NodeSlicer.core.nodeslicer.nodes.inputs import SLICENODE_ObjectsToList + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_MeshesToList + from NodeSlicer.core.nodeslicer.nodes.path import SLICENODE_PathsToList + from NodeSlicer.core.nodeslicer.nodes.gcode import SLICENODE_GcodesToList + + # ObjectsToList + obj1 = DummyObject("Obj1") + obj2 = DummyObject("Obj2") + o_node = SLICENODE_ObjectsToList() + o_node.resolve_multi_input = lambda label: [[obj1], obj2] + self.assertEqual(o_node.evaluate_output("Objects"), [obj1, obj2]) + + # MeshesToList + sc1 = SlicingCollection([], "M1") + sc2 = SlicingCollection([], "M2") + m_node = SLICENODE_MeshesToList() + m_node.resolve_multi_input = lambda label: [sc1, [sc2]] + self.assertEqual(m_node.evaluate_output("Meshes"), [sc1, sc2]) + + # PathsToList + sp1 = SlicePaths([np.array([[0, 0, 0], [1, 1, 0]], dtype=np.float64)]) + sp2 = SlicePaths([np.array([[2, 2, 0], [3, 3, 0]], dtype=np.float64)]) + p_node = SLICENODE_PathsToList() + p_node.resolve_multi_input = lambda label: [sp1, [sp2]] + self.assertEqual(p_node.evaluate_output("Paths"), [sp1, sp2]) + + # GcodesToList + g_node = SLICENODE_GcodesToList() + g_node.resolve_multi_input = lambda label: [["G1 X1"], "G1 X2"] + self.assertEqual(g_node.evaluate_output("Gcode"), ["G1 X1", "G1 X2"]) + + def test_join_mesh_flattens_lists(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_JoinMesh + + node = SLICENODE_JoinMesh() + so1 = SlicingObject() + so1.name = "part1" + so1.mesh = np.ones((1, 4, 3), dtype=np.float64) + sc1 = SlicingCollection([], "Coll1") + sc1.objects = [so1] + + so2 = SlicingObject() + so2.name = "part2" + so2.mesh = np.ones((2, 4, 3), dtype=np.float64) + sc2 = SlicingCollection([], "Coll2") + sc2.objects = [so2] + + so3 = SlicingObject() + so3.name = "part3" + so3.mesh = np.ones((3, 4, 3), dtype=np.float64) + sc3 = SlicingCollection([], "Coll3") + sc3.objects = [so3] + + # Mock inputs delivering [ [sc1, sc2], sc3 ] + mock_in1 = DummySocket("Mesh", "SLICESOCK_Mesh") + mock_in1.is_linked = True + mock_in2 = DummySocket("Mesh", "SLICESOCK_Mesh") + mock_in2.is_linked = True + node.inputs = [mock_in1, mock_in2] + + import NodeSlicer.infra.blender.blender_slice_nodes_generic as b_gen + original_resolve = b_gen.resolve_input + b_gen.resolve_input = lambda sock, **kw: [sc1, sc2] if sock is mock_in1 else sc3 + try: + res = node.evaluate_output("Mesh") + self.assertIsInstance(res, SlicingCollection) + self.assertEqual(len(res.objects), 3) + self.assertEqual({o.name for o in res.objects}, {"part1", "part2", "part3"}) + finally: + b_gen.resolve_input = original_resolve + + def test_drop_geometry_list_evaluation(self): + from NodeSlicer.core.nodeslicer.nodes.mesh import SLICENODE_DropGeometry + + node = SLICENODE_DropGeometry() + so1 = SlicingObject() + so1.name = "part1" + so1.mesh = np.array([[[0, 0, 5], [1, 0, 5], [0, 1, 5], [0, 0, 1]]], dtype=np.float64) + sc1 = SlicingCollection([], "M1") + sc1.objects = [so1] + + so2 = SlicingObject() + so2.name = "part2" + so2.mesh = np.array([[[0, 0, 2], [1, 0, 2], [0, 1, 2], [0, 0, 1]]], dtype=np.float64) + sc2 = SlicingCollection([], "M2") + sc2.objects = [so2] + + node.resolve = lambda field: [sc1, sc2] if field == "Mesh" else 0.0 + + res_mesh = node.evaluate_output("Mesh") + res_trans = node.evaluate_output("Translation") + + self.assertIsInstance(res_mesh, list) + self.assertEqual(len(res_mesh), 2) + self.assertIsInstance(res_trans, list) + self.assertEqual(res_trans, [(0.0, 0.0, -5.0), (0.0, 0.0, -2.0)]) + + def test_start_print_rejects_list_in_evaluate(self): + from NodeSlicer.core.nodeslicer.nodes.output import SLICENODE_StartPrint + + node = SLICENODE_StartPrint() + node.resolve = lambda field: ["G1 X0", "G1 X10"] if field == "Gcode" else None + + op = MagicMock() + res = node.evaluate(operator=op) + self.assertEqual(res, 'error') + op.report.assert_called_with({'ERROR'}, "Start Print does not accept a list of Gcode") + + +class DummyInterfaceItem: + def __init__(self, name, identifier, in_out="INPUT", socket_type="SLICESOCK_Paths"): + self.name = name + self.identifier = identifier + self.in_out = in_out + self.socket_type = socket_type + self.item_type = 'SOCKET' + + +class DummyInterface: + def __init__(self, items): + self.items_tree = items + + +class DummyGroupTree: + def __init__(self, items=None, nodes=None): + self.interface = DummyInterface(items or []) + self.nodes = nodes or [] + + +class DummyGroupNode(DummyNode): + def __init__(self, name="Group", tree=None): + super().__init__("SLICENODE_Group") + self.name = name + self.slicer_tree = tree + + +class TestGroupNodeListShapes(unittest.TestCase): + def test_group_input_can_accept_list_detection(self): + item_paths = DummyInterfaceItem("Paths", "sock_paths", in_out="INPUT", socket_type="SLICESOCK_Paths") + item_float = DummyInterfaceItem("FloatVal", "sock_float", in_out="INPUT", socket_type="NodeSocketFloat") + + gin = DummyNode("NodeGroupInput") + gin_out_paths = DummySocket("Paths", "SLICESOCK_Paths") + gin_out_paths.identifier = "sock_paths" + gin_out_float = DummySocket("FloatVal", "NodeSocketFloat") + gin_out_float.identifier = "sock_float" + gin.add_out(gin_out_paths) + gin.add_out(gin_out_float) + + target_node = DummyNode("SLICENODE_BufferShells") + target_node.list_socket_mapping = [("Paths", "Paths")] + target_in_paths = DummySocket("Paths", "SLICESOCK_Paths") + target_node.add_in(target_in_paths) + + link = DummyLink(gin_out_paths, target_in_paths) + gin_out_paths.is_linked = True + gin_out_paths.links = [link] + + tree = DummyGroupTree([item_paths, item_float], [gin, target_node]) + + # Paths input can accept list because target is BufferShells + self.assertTrue(group_input_can_accept_list(tree, item_paths)) + # FloatVal cannot accept list because socket type is Float + self.assertFalse(group_input_can_accept_list(tree, item_float)) + + def test_group_input_connected_to_forbidden_node_rejects_list(self): + item_gcode = DummyInterfaceItem("Gcode", "sock_gcode", in_out="INPUT", socket_type="NodeSocketString") + + gin = DummyNode("NodeGroupInput") + gin_out_gcode = DummySocket("Gcode", "NodeSocketString") + gin_out_gcode.identifier = "sock_gcode" + gin.add_out(gin_out_gcode) + + sp_node = DummyNode("SLICENODE_StartPrint") + sp_in = DummySocket("Gcode", "NodeSocketString") + sp_node.add_in(sp_in) + + link = DummyLink(gin_out_gcode, sp_in) + gin_out_gcode.is_linked = True + gin_out_gcode.links = [link] + + tree = DummyGroupTree([item_gcode], [gin, sp_node]) + + # StartPrint forbids lists, so group input must NOT accept list + self.assertFalse(group_input_can_accept_list(tree, item_gcode)) + + def test_group_output_inherent_list(self): + item_out = DummyInterfaceItem("Paths", "sock_out_paths", in_out="OUTPUT", socket_type="SLICESOCK_Paths") + + gout = DummyNode("NodeGroupOutput") + gout_in = DummySocket("Paths", "SLICESOCK_Paths") + gout_in.identifier = "sock_out_paths" + gout.add_in(gout_in) + + list_joiner = DummyNode("SLICENODE_PathsToList") + list_joiner.is_list_output = True + lj_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") + list_joiner.add_out(lj_out) + + link = DummyLink(lj_out, gout_in) + gout_in.is_linked = True + gout_in.links = [link] + + tree = DummyGroupTree([item_out], [gout, list_joiner]) + + # Output fed from PathsToList is always a list + self.assertTrue(is_group_output_a_list(None, tree, item_out)) + + def test_group_output_element_wise_propagation_and_sync(self): + item_in = DummyInterfaceItem("Paths", "sock_in", in_out="INPUT", socket_type="SLICESOCK_Paths") + item_out = DummyInterfaceItem("Paths", "sock_out", in_out="OUTPUT", socket_type="SLICESOCK_Paths") + + gin = DummyNode("NodeGroupInput") + gin_out = DummySocket("Paths", "SLICESOCK_Paths") + gin_out.identifier = "sock_in" + gin.add_out(gin_out) + + buf_node = DummyNode("SLICENODE_BufferShells") + buf_node.list_socket_mapping = [("Paths", "Paths")] + buf_in = DummySocket("Paths", "SLICESOCK_Paths") + buf_out = DummySocket("Paths", "SLICESOCK_Paths") + buf_node.add_in(buf_in) + buf_node.add_out(buf_out) + + gout = DummyNode("NodeGroupOutput") + gout_in = DummySocket("Paths", "SLICESOCK_Paths") + gout_in.identifier = "sock_out" + gout.add_in(gout_in) + + link1 = DummyLink(gin_out, buf_in) + gin_out.is_linked = True + gin_out.links = [link1] + buf_in.is_linked = True + buf_in.links = [link1] + + link2 = DummyLink(buf_out, gout_in) + buf_out.is_linked = True + buf_out.links = [link2] + gout_in.is_linked = True + gout_in.links = [link2] + + tree = DummyGroupTree([item_in, item_out], [gin, buf_node, gout]) + + grp_node = DummyGroupNode("MyGroup", tree) + grp_in = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE") + grp_in.identifier = "sock_in" + grp_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="CIRCLE") + grp_out.identifier = "sock_out" + grp_node.add_in(grp_in) + grp_node.add_out(grp_out) + + # Before external link: + # Input should be LIST (because list input is possible) + # Output should be CIRCLE (because no list is fed) + sync_group_node_socket_shapes(grp_node) + self.assertEqual(grp_in.display_shape, "LIST") + self.assertEqual(grp_out.display_shape, "CIRCLE") + + # Now link a list to the group's input externally: + up_node = DummyNode("SLICENODE_PathsToList") + up_node.is_list_output = True + up_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") + up_node.add_out(up_out) + + ext_link = DummyLink(up_out, grp_in) + grp_in.is_linked = True + grp_in.links = [ext_link] + + # After external list link: + # Output should now become LIST! + sync_group_node_socket_shapes(grp_node) + self.assertEqual(grp_in.display_shape, "LIST") + self.assertEqual(grp_out.display_shape, "LIST") + + def test_valid_link_with_group_nodes(self): + item_paths = DummyInterfaceItem("Paths", "sock_paths", in_out="INPUT", socket_type="SLICESOCK_Paths") + item_float = DummyInterfaceItem("FloatVal", "sock_float", in_out="INPUT", socket_type="NodeSocketFloat") + + gin = DummyNode("NodeGroupInput") + gin_out_paths = DummySocket("Paths", "SLICESOCK_Paths") + gin_out_paths.identifier = "sock_paths" + gin_out_float = DummySocket("FloatVal", "NodeSocketFloat") + gin_out_float.identifier = "sock_float" + gin.add_out(gin_out_paths) + gin.add_out(gin_out_float) + + target_node = DummyNode("SLICENODE_BufferShells") + target_node.list_socket_mapping = [("Paths", "Paths")] + target_in_paths = DummySocket("Paths", "SLICESOCK_Paths") + target_node.add_in(target_in_paths) + + link = DummyLink(gin_out_paths, target_in_paths) + gin_out_paths.is_linked = True + gin_out_paths.links = [link] + + tree = DummyGroupTree([item_paths, item_float], [gin, target_node]) + + grp_node = DummyGroupNode("Group", tree) + grp_in_paths = DummySocket("Paths", "SLICESOCK_Paths") + grp_in_paths.identifier = "sock_paths" + grp_in_float = DummySocket("FloatVal", "NodeSocketFloat") + grp_in_float.identifier = "sock_float" + grp_node.add_in(grp_in_paths) + grp_node.add_in(grp_in_float) + + list_out = DummySocket("Paths", "SLICESOCK_Paths", display_shape="LIST") + validator = SLICETREE_NodeTree() + + # Linking list to Paths group input (which accepts list): allowed + self.assertTrue(validator.valid_link(list_out, grp_in_paths)) + + # Linking list to FloatVal group input: rejected + self.assertFalse(validator.valid_link(list_out, grp_in_float)) + + +if __name__ == '__main__': + unittest.main()