list support

This commit is contained in:
Nicolas Predella 2026-10-12 16:23:02 +03:00
parent 12bc6ba08b
commit 47e66bb030
16 changed files with 2045 additions and 435 deletions

View file

@ -134,6 +134,45 @@ class SliceNodeJoiner(SliceNode):
self.add_output(self.output_socket_type or self.socket_type, name=self.socket_label) 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) 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): class ExecSliceNode(SliceNode):
button_label: str = 'Execute' button_label: str = 'Execute'

View file

@ -1,15 +1,24 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
import numpy as np import numpy as np
import bpy import bpy
import os import os
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfStringProperty
from ....registry import register_class 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 @register_class
class SLICENODE_PathsToGcode(SliceNode): class SLICENODE_PathsToGcode(SliceNode):
bl_label = "Paths to Gcode" bl_label = "Paths to Gcode"
list_socket_mapping = [("Paths", "Gcode")]
use_header: bool = NfBoolProperty(name="Header", default=True) use_header: bool = NfBoolProperty(name="Header", default=True)
@ -22,19 +31,23 @@ class SLICENODE_PathsToGcode(SliceNode):
assert layout assert layout
layout.prop(self, "use_header") layout.prop(self, "use_header")
def evaluate_output(self, socket_name: str, **_kwargs) -> str: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: list[np.ndarray] = self.resolve("Paths") paths: Any = self.resolve("Paths")
intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics") intrinsics: SliceIntrinsics | None = self.resolve("Intrinsics")
pather: Pather = Pather(paths) if not paths:
if hasattr(paths, "layer_height"): return ""
pather.layer_height = getattr(paths, "layer_height")
if hasattr(paths, "first_layer_height"): def _paths_to_gcode(p: Any) -> str:
pather.first_layer_height = getattr(paths, "first_layer_height") 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")
if intrinsics: 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_x = intrinsics.bed_x / 2.0
pather.bed_center_y = intrinsics.bed_y / 2.0 pather.bed_center_y = intrinsics.bed_y / 2.0
pather.feedrate = intrinsics.feedrate pather.feedrate = intrinsics.feedrate
@ -44,8 +57,9 @@ class SLICENODE_PathsToGcode(SliceNode):
pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0) pather.retraction_feedrate = getattr(intrinsics, "retraction_feedrate", 0.0)
pather.lift = intrinsics.lift pather.lift = intrinsics.lift
gcode: str = pather.to_gcode(include_header=self.use_header) return pather.to_gcode(include_header=self.use_header)
return gcode
return eval_element_wise(_paths_to_gcode, paths)
@register_class @register_class
class SLICENODE_GcodeBlock(SliceNode): class SLICENODE_GcodeBlock(SliceNode):
@ -83,6 +97,7 @@ class SLICENODE_AssignMetadata(SliceNode):
@register_class @register_class
class SLICENODE_GcodeRegexReplace(SliceNode): class SLICENODE_GcodeRegexReplace(SliceNode):
bl_label = "Gcode Regex Replace" bl_label = "Gcode Regex Replace"
list_socket_mapping = [("Gcode", "Gcode")]
def init(self, context): def init(self, context):
self.add_input("NodeSocketString", "Gcode") self.add_input("NodeSocketString", "Gcode")
@ -90,15 +105,22 @@ class SLICENODE_GcodeRegexReplace(SliceNode):
self.add_input("NodeSocketString", "Replace") self.add_input("NodeSocketString", "Replace")
self.add_output("NodeSocketString", "Gcode") 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 import re
gcode = self.resolve("Gcode") or "" gcode = self.resolve("Gcode")
find = self.resolve("Find") or "" find = self.resolve("Find") or ""
replace = self.resolve("Replace") or "" replace = self.resolve("Replace") or ""
if not gcode:
return ""
if not find: if not find:
return gcode return gcode
def _replace_one(gc: Any) -> str:
gc_str = str(gc or "")
if not find or not gc_str:
return gc_str
try: try:
# Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name> # Convert $1, $2, ${name} etc to Python backreferences \g<1>, \g<2>, \g<name>
def subst(match): def subst(match):
@ -107,12 +129,13 @@ class SLICENODE_GcodeRegexReplace(SliceNode):
elif match.group(3): return '\\g<' + match.group(3) + '>' elif match.group(3): return '\\g<' + match.group(3) + '>'
return match.group(0) 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)
return re.sub(find, replace, gcode)
except Exception as e: except Exception as e:
print(f"Regex error in node {self.name}: {e}") print(f"Regex error in node {self.name}: {e}")
return gcode return gc_str
return eval_element_wise(_replace_one, gcode)
@register_class @register_class
class SLICENODE_GcodeHelper(SliceNode): class SLICENODE_GcodeHelper(SliceNode):
@ -180,17 +203,53 @@ class SLICENODE_JoinGcode(SliceNodeJoiner):
output_socket_type = "NodeSocketString" output_socket_type = "NodeSocketString"
socket_label = "Gcode" socket_label = "Gcode"
def evaluate_output(self, socket_name: str, **_kwargs) -> str: def update(self) -> None:
from ....infra.blender.blender_slice_nodes_generic import resolve_input super().update()
gcode_input: list[str] = [] from ....infra.blender.list_utils import is_socket_linked_to_list, set_socket_shape
for input in self.inputs: has_list = any(
val = resolve_input(input) is_socket_linked_to_list(inp)
if val is not None: for inp in self.inputs
gc = str(val) if getattr(inp, "name", "") == self.socket_label or getattr(inp, "identifier", "").startswith(f"{self.socket_label}_ms_in_")
if gc: )
gcode_input.append(gc) 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) 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 @register_class
class SLICENODE_LoadGcode(SliceNode): class SLICENODE_LoadGcode(SliceNode):
bl_label = "Load Gcode File" bl_label = "Load Gcode File"

View file

@ -6,7 +6,7 @@ import numpy as np
from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty, NfPointerProperty from ..bpy_ns_types import NfStringProperty, notify_tree_changed, NfEnumProperty, NfPointerProperty
from ....registry import register_class from ....registry import register_class
from .base import InputNode, SliceNode, SliceIntrinsics from .base import InputNode, SliceNode, SliceIntrinsics, SliceNodeListJoiner
@register_class @register_class
class SLICENODE_InputString(InputNode): class SLICENODE_InputString(InputNode):
@ -39,6 +39,13 @@ class SLICENODE_InputObject(InputNode):
socket_type = "NodeSocketObject" socket_type = "NodeSocketObject"
bl_icon = "OBJECT_DATA" 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 @register_class
class SLICENODE_CollectionObjects(SliceNode): class SLICENODE_CollectionObjects(SliceNode):
bl_label = "Collection Objects" bl_label = "Collection Objects"

View file

@ -4,14 +4,16 @@ import numpy as np
from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed from ..bpy_ns_types import NfBoolProperty, NfEnumProperty, NfIntProperty, NfFloatProperty, NfStringProperty, NfFloatVectorProperty, notify_tree_changed
from ....registry import register_class 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 ....infra.blender.blender_mesh_capture import PdfProperties
from ....core.pdf_exporter.geometry import transform_slice_paths from ....core.pdf_exporter.geometry import transform_slice_paths
from ....core.geometry import update_transform from ....core.geometry import update_transform
from ....infra.blender.list_utils import eval_element_wise, flatten_list, is_list_data
@register_class @register_class
class SLICENODE_GetMesh(SliceNode): class SLICENODE_GetMesh(SliceNode):
bl_label = "Get Mesh" bl_label = "Get Mesh"
list_socket_mapping = [("Object", "Mesh")]
include_children: bool = NfBoolProperty(name="Include Children", default=True) include_children: bool = NfBoolProperty(name="Include Children", default=True)
@ -23,17 +25,25 @@ class SLICENODE_GetMesh(SliceNode):
assert layout assert layout
layout.prop(self, "include_children") 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 if socket_name != "Mesh": return None
obj: Any = self.resolve("Object") obj: Any = self.resolve("Object")
if not obj: return None 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 eval_element_wise(_to_collection, obj)
return sc @register_class
class SLICENODE_MeshesToList(SliceNodeListJoiner):
bl_label = "Meshes to List"
socket_type = "SLICESOCK_Mesh"
socket_label = "Mesh"
output_socket_name = "Meshes"
@register_class @register_class
class SLICENODE_JoinMesh(SliceNodeJoiner): class SLICENODE_JoinMesh(SliceNodeJoiner):
@ -42,16 +52,20 @@ class SLICENODE_JoinMesh(SliceNodeJoiner):
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection: def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection:
from ....infra.blender.blender_slice_nodes_generic import resolve_input from ....infra.blender.blender_slice_nodes_generic import resolve_input
first_coll = resolve_input(self.inputs[0]) if self.inputs else None all_colls = []
merged_coll = SlicingCollection([], first_coll.name if first_coll else '') for inp in self.inputs:
if first_coll: if getattr(inp, "is_linked", False) or getattr(inp, "value", None):
merged_coll.objects = list(first_coll.objects) val = resolve_input(inp)
merged_coll.transforms = [dict(t) for t in getattr(first_coll, "transforms", [])] if val:
all_colls.extend(flatten_list(val))
for input in self.inputs[1:]: first_coll = all_colls[0] if all_colls else None
coll = resolve_input(input) merged_coll = SlicingCollection([], first_coll.name if first_coll else '')
if coll: for coll in all_colls:
if hasattr(coll, "objects"):
merged_coll.objects += list(coll.objects) merged_coll.objects += list(coll.objects)
elif isinstance(coll, SlicingObject):
merged_coll.objects.append(coll)
for t in getattr(coll, "transforms", []): for t in getattr(coll, "transforms", []):
if t not in merged_coll.transforms: if t not in merged_coll.transforms:
merged_coll.transforms.append(dict(t)) merged_coll.transforms.append(dict(t))
@ -71,11 +85,16 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
from ....infra.blender.blender_slice_nodes_generic import resolve_input from ....infra.blender.blender_slice_nodes_generic import resolve_input
group: SlicingGroup = SlicingGroup([]) group: SlicingGroup = SlicingGroup([])
for idx, input_socket in enumerate(self.inputs): all_colls = []
coll = resolve_input(input_socket) for input_socket in self.inputs:
if coll: 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", "") col_name = getattr(coll, "name", "")
if not col_name and coll.objects: if not col_name and getattr(coll, "objects", None):
col_name = coll.objects[0].name col_name = coll.objects[0].name
if not col_name: if not col_name:
col_name = f"collection_{idx}" col_name = f"collection_{idx}"
@ -86,6 +105,7 @@ class SLICENODE_JoinMeshGroups(SliceNodeJoiner):
@register_class @register_class
class SLICENODE_SeparateMesh(SliceNode): class SLICENODE_SeparateMesh(SliceNode):
bl_label = "Separate Mesh" bl_label = "Separate Mesh"
list_socket_mapping = [("Mesh", ["Selection", "Inverted"])]
comparison: str = NfEnumProperty( comparison: str = NfEnumProperty(
name="Comparison", name="Comparison",
@ -112,19 +132,19 @@ class SLICENODE_SeparateMesh(SliceNode):
assert layout assert layout
layout.prop(self, "comparison", text="") layout.prop(self, "comparison", text="")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
coll: SlicingCollection | None = self.resolve("Mesh") coll: Any = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute") attr_name: str = self.resolve("Attribute")
value: float = self.resolve("Value") value: float = self.resolve("Value")
if not coll: return None if not coll: return None
res_selection = SlicingCollection([], coll.name + "_selection") def _split_coll(sc: SlicingCollection) -> tuple[SlicingCollection, SlicingCollection]:
res_inverted = SlicingCollection([], coll.name + "_inverted") res_selection = SlicingCollection([], sc.name + "_selection")
res_inverted = SlicingCollection([], sc.name + "_inverted")
for so in coll.objects: for so in sc.objects:
if attr_name not in so.attributes: if attr_name not in so.attributes:
# If attribute missing, entire object goes to inverted
res_inverted.objects.append(so) res_inverted.objects.append(so)
continue continue
@ -144,7 +164,6 @@ class SLICENODE_SeparateMesh(SliceNode):
elif not np.any(mask): elif not np.any(mask):
res_inverted.objects.append(so) res_inverted.objects.append(so)
else: else:
# Split the object
so_sel = SlicingObject() so_sel = SlicingObject()
so_sel.name = so.name + "_sel" so_sel.name = so.name + "_sel"
so_sel.parent = so.parent so_sel.parent = so.parent
@ -174,7 +193,13 @@ class SLICENODE_SeparateMesh(SliceNode):
"values": v["values"][~mask] "values": v["values"][~mask]
} }
res_inverted.objects.append(so_inv) 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": if socket_name == "Selection":
return res_selection return res_selection
return res_inverted return res_inverted
@ -202,8 +227,13 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
end = max(start, end_val) end = max(start, end_val)
expected_ids = [str(i) for i in range(start, end + 1)] 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 # 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] to_remove = [o for o in self.outputs if o.identifier not in expected_ids]
for o in to_remove: for o in to_remove:
self.outputs.remove(o) self.outputs.remove(o)
@ -211,7 +241,9 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
# Add new outputs # Add new outputs
for id_str in expected_ids: for id_str in expected_ids:
if id_str not in self.outputs: 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 # Re-order to match range
for i, id_str in enumerate(expected_ids): for i, id_str in enumerate(expected_ids):
@ -225,8 +257,8 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
col.prop(self, "start_val") col.prop(self, "start_val")
col.prop(self, "end_val") col.prop(self, "end_val")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | None: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
coll: SlicingCollection | None = self.resolve("Mesh") coll: Any = self.resolve("Mesh")
attr_name: str = self.resolve("Attribute") attr_name: str = self.resolve("Attribute")
if not coll: return None if not coll: return None
@ -237,15 +269,16 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
except ValueError: except ValueError:
return None return None
res = SlicingCollection([], f"{coll.name}_{target_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
start = self.start_val end = max(start, end_val)
end = max(start, self.end_val)
total_parts = end - start + 1 total_parts = end - start + 1
num_digits = max(2, len(str(total_parts))) num_digits = max(2, len(str(total_parts)))
i = target_val - start i = target_val - start
for so in coll.objects: 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: if attr_name not in so.attributes:
continue continue
@ -272,12 +305,14 @@ class SLICENODE_SeparateMeshByRange(SliceNode):
"values": v["values"][mask] "values": v["values"][mask]
} }
res.objects.append(so_new) res.objects.append(so_new)
return res return res
return eval_element_wise(_split_coll, coll)
@register_class @register_class
class SLICENODE_ArrangeMesh(SliceNode): class SLICENODE_ArrangeMesh(SliceNode):
bl_label = "Arrange Slicing Group" bl_label = "Arrange Slicing Group"
list_socket_mapping = [("Slicing Group", "Slicing Group")]
method: str = NfEnumProperty( method: str = NfEnumProperty(
name="Method", name="Method",
@ -297,11 +332,11 @@ class SLICENODE_ArrangeMesh(SliceNode):
) )
def init(self, context): 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 X").value = 250.0
self.add_input("NodeSocketFloat", "Bed Y").value = 250.0 self.add_input("NodeSocketFloat", "Bed Y").value = 250.0
self.add_input("NodeSocketFloat", "Spacing").value = 2.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): def draw_buttons(self, context, layout):
assert layout assert layout
@ -320,9 +355,22 @@ class SLICENODE_ArrangeMesh(SliceNode):
bed_y: float = self.resolve("Bed Y") or 250.0 bed_y: float = self.resolve("Bed Y") or 250.0
spacing: float = self.resolve("Spacing") or 2.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): if isinstance(mesh_data, SlicingGroup):
slicing_group = mesh_data 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: else:
slicing_group = SlicingGroup([]) slicing_group = SlicingGroup([])
slicing_group.collections = {mesh_data.name: mesh_data} slicing_group.collections = {mesh_data.name: mesh_data}
@ -419,8 +467,8 @@ class SLICENODE_ArrangeMesh(SliceNode):
so.mesh[:, 3, 1] = ny_new so.mesh[:, 3, 1] = ny_new
else: else:
name, sc = collection_map[id(item)] 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 return copied_group
@ -515,6 +563,7 @@ def _transform_geometry_item(geom: Any, t_arr: np.ndarray, r_arr: np.ndarray, s_
@register_class @register_class
class SLICENODE_TransformGeometry(SliceNode): class SLICENODE_TransformGeometry(SliceNode):
bl_label = "Transform Geometry" bl_label = "Transform Geometry"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Geometry", "Geometry") self.add_input("SLICESOCK_Geometry", "Geometry")
@ -545,6 +594,7 @@ class SLICENODE_TransformGeometry(SliceNode):
@register_class @register_class
class SLICENODE_DropGeometry(SliceNode): class SLICENODE_DropGeometry(SliceNode):
bl_label = "Drop Geometry" bl_label = "Drop Geometry"
list_socket_mapping = [("Mesh", ["Mesh", "Translation"])]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Mesh", "Mesh") self.add_input("SLICESOCK_Mesh", "Mesh")
@ -552,23 +602,8 @@ class SLICENODE_DropGeometry(SliceNode):
self.add_output("SLICESOCK_Mesh", "Mesh") self.add_output("SLICESOCK_Mesh", "Mesh")
self.add_output("NodeSocketVector", "Translation") self.add_output("NodeSocketVector", "Translation")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicingCollection | SlicingGroup | tuple[float, float, float] | None: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name == "Translation": mesh_data: Any = self.resolve("Mesh")
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")
if not mesh_data: if not mesh_data:
return None return None
@ -576,18 +611,17 @@ class SLICENODE_DropGeometry(SliceNode):
if z is None: if z is None:
z = 0.0 z = 0.0
assert mesh_data.min_z is not None def _drop_item(item: Any) -> tuple[Any, tuple[float, float, float]]:
min_z = round(mesh_data.min_z, 6) 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) offset_z = float(z - min_z)
if isinstance(item, SlicingGroup):
if isinstance(mesh_data, SlicingGroup): copied_data = _copy_slicing_group(item)
copied_data = _copy_slicing_group(mesh_data)
else: else:
copied_data = _copy_slicing_collection(mesh_data) copied_data = _copy_slicing_collection(item)
drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64) drop_vec = np.array([0.0, 0.0, offset_z], dtype=np.float64)
copied_data.offset(drop_vec) copied_data.offset(drop_vec)
copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec) copied_data.transforms = update_transform(getattr(copied_data, "transforms", []), t_arr=drop_vec)
if isinstance(copied_data, SlicingGroup): if isinstance(copied_data, SlicingGroup):
for col in copied_data.collections.values(): for col in copied_data.collections.values():
@ -597,8 +631,18 @@ class SLICENODE_DropGeometry(SliceNode):
elif isinstance(copied_data, SlicingCollection): elif isinstance(copied_data, SlicingCollection):
for so in copied_data.objects: for so in copied_data.objects:
so.transforms = update_transform(getattr(so, "transforms", []), t_arr=drop_vec) 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: 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 @register_class
class SLICENODE_RenameMesh(SliceNode): class SLICENODE_RenameMesh(SliceNode):
bl_label = "Rename Mesh" bl_label = "Rename Mesh"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry") 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 @register_class
class SLICENODE_AssignPdfProperties(SliceNode): class SLICENODE_AssignPdfProperties(SliceNode):
bl_label = "Assign PDF Properties" 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_width: float = NfFloatProperty(name="Line Width (mm)", default=0.0, min=0.0, max=50.0)
line_color: tuple[float, float, float] = NfFloatVectorProperty( 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 return copied
elif isinstance(geom, SlicingObject): elif isinstance(geom, SlicingObject):
copied = _copy_slicing_object(geom) 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 copied
return geom return geom
@ -785,6 +831,7 @@ def _assign_properties_to_item(geom: Any, props: list[tuple[str, str]]) -> Any:
@register_class @register_class
class SLICENODE_AssignPropertyToObject(SliceNode): class SLICENODE_AssignPropertyToObject(SliceNode):
bl_label = "Assign PDF Object Properties" bl_label = "Assign PDF Object Properties"
list_socket_mapping = [("Geometry", "Geometry")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Mesh", "Geometry") self.add_input("SLICESOCK_Mesh", "Geometry")

View file

@ -23,13 +23,22 @@ class SLICENODE_GcodeToFile(ExecSliceNode):
self.add_input("NodeSocketString", "Name") self.add_input("NodeSocketString", "Name")
self.add_multi_socket_group("NodeSocketString", name="Gcode") 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): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
def evaluate(self, operator=None): def evaluate(self, operator=None):
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, prepare_gcode_file from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, prepare_gcode_file
from ....infra.blender.list_utils import flatten_list
gcodes: list[str] = [gc for gc in self.resolve_multi_input('Gcode') if gc] 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") folder: str = self.resolve("Folder")
if not gcodes: if not gcodes:
@ -86,20 +95,37 @@ class SLICENODE_GcodeToTextBlock(ExecSliceNode):
self._init_exec() self._init_exec()
self.add_input("NodeSocketString", "Gcode") 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): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
layout.prop(self, "text_block_name") layout.prop(self, "text_block_name")
def evaluate(self, operator=None): def evaluate(self, operator=None):
gcode: str = self.resolve("Gcode") from ....infra.blender.list_utils import flatten_list, is_list_data
if not gcode: raw = self.resolve("Gcode")
if not raw:
return 'ok' return 'ok'
name = self.text_block_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'
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) text_block = bpy.data.texts.get(name)
if text_block is None: if text_block is None:
text_block = bpy.data.texts.new(name=name) text_block = bpy.data.texts.new(name=name)
text_block.from_string(gcode) text_block.from_string(gcode)
# Attempt to show in an open text editor # Attempt to show in an open text editor
@ -124,6 +150,13 @@ class SLICENODE_StartPrint(ExecSliceNode):
self.add_input("SLICESOCK_Printer") self.add_input("SLICESOCK_Printer")
def evaluate(self, operator=None): 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 ( from ....infra.networking.printer_backends import (
PrinterConfig, PrinterConfig,
PrusaLinkBackend, PrusaLinkBackend,
@ -131,7 +164,7 @@ class SLICENODE_StartPrint(ExecSliceNode):
MoonrakerBackend MoonrakerBackend
) )
gcode: str = self.resolve("Gcode") gcode: str = str(raw_gcode or "")
config: PrinterConfig | None = self.resolve("Printer") config: PrinterConfig | None = self.resolve("Printer")
if not gcode: if not gcode:
@ -192,6 +225,13 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
self.add_input("NodeSocketString", "Gcode") self.add_input("NodeSocketString", "Gcode")
self.add_input("SLICESOCK_Settings", "Metadata") 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): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
layout.prop(self, "record_id") layout.prop(self, "record_id")
@ -199,8 +239,9 @@ class ExecSliceNode_DatabaseSubmit(ExecSliceNode):
def evaluate(self, operator=None): def evaluate(self, operator=None):
from ....infra.networking.bgcode import convert_to_bgcode from ....infra.networking.bgcode import convert_to_bgcode
from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, parse_gcode_metadata from ....infra.networking.gcode_utils import get_gcode_name_from_metadata, parse_gcode_metadata
from ....infra.blender.list_utils import flatten_list
gcodes: list[str] = self.resolve_multi_input("Gcode") gcodes: list[str] = [str(gc) for gc in flatten_list(self.resolve_multi_input("Gcode")) if gc]
for gc in gcodes: for gc in gcodes:
if not gc: if not gc:
@ -413,6 +454,14 @@ class SLICENODE_MeshToPdf(ExecSliceNode):
self.add_input("SLICESOCK_Mesh", "Geometry") self.add_input("SLICESOCK_Mesh", "Geometry")
self.add_input("SLICESOCK_Paths", "Paths") 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): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)

View file

@ -4,12 +4,20 @@ import numpy as np
from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed from ..bpy_ns_types import NfEnumProperty, NfBoolProperty, NfFloatProperty, notify_tree_changed
from ....registry import register_class 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 ...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 @register_class
class SLICENODE_GetPaths(SliceNode): class SLICENODE_GetPaths(SliceNode):
bl_label = "Get Paths" bl_label = "Get Paths"
list_socket_mapping = [("Object", "Paths")]
def init(self, context): def init(self, context):
self.add_input("NodeSocketObject") self.add_input("NodeSocketObject")
@ -21,78 +29,34 @@ class SLICENODE_GetPaths(SliceNode):
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
pass pass
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
obj: Any = self.resolve("Object") obj: Any = self.resolve("Object")
e_attr_name: str = self.resolve("Extrusion Multiplier Attribute") e_attr_name: str = self.resolve("Extrusion Multiplier Attribute")
f_attr_name: str = self.resolve("Feedrate Attribute") f_attr_name: str = self.resolve("Feedrate Attribute")
extra_attrs: dict[str, str] = self.resolve("Extra Attributes") extra_attrs: dict[str, str] = self.resolve("Extra Attributes")
from ....infra.blender.blender_mesh_capture import capture_mesh_edges 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)
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) sp = SlicePaths(so, extrusion_rate=e_rates, feedrate=f_rates, extra_attributes=extra_attrs_data)
if hasattr(obj, "transforms"): if hasattr(o, "transforms"):
sp.transforms = list(getattr(obj, "transforms", [])) sp.transforms = list(getattr(o, "transforms", []))
if hasattr(obj, "name") and getattr(obj, "name"): if hasattr(o, "name") and getattr(o, "name"):
name_val = str(getattr(obj, "name")) name_val = str(getattr(o, "name"))
sp.name = name_val sp.name = name_val
sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]} sp.objects_info = {"objects": [{"name": name_val, "polygon": []}]}
return sp return sp
@register_class return eval_element_wise(_get_single_path, obj)
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
ignore_holes: bool = NfBoolProperty( def _slice_single_mesh_item(coll: Any, layer_height: float, fl_height: float | None, rotation: Any, ignore_holes: bool) -> SlicePaths:
name="Ignore Holes", if not coll:
description="Ignore interior hole boundaries and generate only outer shells", return SlicePaths()
default=False
)
def init(self, context): # Handle SlicingGroup, SlicingCollection, SlicingObject
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
self.add_input("NodeSocketFloat", "First Layer Height").value = 0.2
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths:
if socket_name != "Paths": return SlicePaths()
coll: Any = self.resolve("Mesh")
layer_height: float = float(self.resolve("Layer Height") or 0.2)
if layer_height <= 0:
layer_height = 0.2
fl_height: float | None = None
try:
fl_val = self.resolve("First Layer Height")
if fl_val is not None:
fl_height = float(fl_val)
except Exception:
fl_height = None
if fl_height is not None and fl_height <= 0:
fl_height = None
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
if not coll: return SlicePaths()
# Handle SlicingGroup, SlicingCollection, SlicingObject, or list
if isinstance(coll, SlicingGroup): if isinstance(coll, SlicingGroup):
objects = [] objects = []
for c in coll.collections.values(): for c in coll.collections.values():
@ -101,16 +65,6 @@ class SLICENODE_SliceShells(SliceNode):
objects = list(coll.objects) objects = list(coll.objects)
elif isinstance(coll, SlicingObject): elif isinstance(coll, SlicingObject):
objects = [coll] 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: else:
objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else []) objects = getattr(coll, "objects", [coll] if hasattr(coll, "mesh") else [])
@ -158,7 +112,6 @@ class SLICENODE_SliceShells(SliceNode):
return SlicePaths() return SlicePaths()
paths = slices_group[0] paths = slices_group[0]
# Assign layer-consistent group indices based on Z height in integer microns
z_to_group: dict[int, int] = {} z_to_group: dict[int, int] = {}
groups = [] groups = []
for p in paths: for p in paths:
@ -209,9 +162,69 @@ class SLICENODE_SliceShells(SliceNode):
return slices return slices
@register_class
class SLICENODE_SliceShells(SliceNode):
bl_label = "Slice Shells"
list_socket_mapping = [("Mesh", "Paths")]
ignore_holes: bool = NfBoolProperty(
name="Ignore Holes",
description="Ignore interior hole boundaries and generate only outer shells",
default=False
)
def init(self, context):
self.add_input("SLICESOCK_Mesh")
self.add_input("NodeSocketFloat", "Layer Height").value = 0.2
self.add_input("NodeSocketFloat", "First Layer Height").value = 0.2
self.add_input("NodeSocketRotation", "Rotation")
self.add_output("SLICESOCK_Paths")
def draw_buttons(self, context, layout):
assert layout
if hasattr(self, "ignore_holes"):
layout.prop(self, "ignore_holes")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
if socket_name != "Paths": return SlicePaths()
coll: Any = self.resolve("Mesh")
layer_height: float = float(self.resolve("Layer Height") or 0.2)
if layer_height <= 0:
layer_height = 0.2
fl_height: float | None = None
try:
fl_val = self.resolve("First Layer Height")
if fl_val is not None:
fl_height = float(fl_val)
except Exception:
fl_height = None
if fl_height is not None and fl_height <= 0:
fl_height = None
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
ignore_holes: bool = bool(getattr(self, "ignore_holes", False))
if hasattr(self, "inputs") and "Ignore Holes" in self.inputs:
try:
val = self.resolve("Ignore Holes")
if val is not None:
ignore_holes = bool(val)
except Exception:
pass
if not coll: return SlicePaths()
def _slice_one(item: Any) -> SlicePaths:
return _slice_single_mesh_item(item, layer_height, fl_height, rotation, ignore_holes)
return eval_element_wise(_slice_one, coll)
@register_class @register_class
class SLICENODE_BufferShells(SliceNode): class SLICENODE_BufferShells(SliceNode):
bl_label = "Buffer Shells" bl_label = "Buffer Shells"
list_socket_mapping = [("Paths", "Paths")]
shape_option: str = NfEnumProperty( shape_option: str = NfEnumProperty(
name="Miter", name="Miter",
@ -232,21 +245,26 @@ class SLICENODE_BufferShells(SliceNode):
assert layout assert layout
layout.prop(self, "shape_option") 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() if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
distance_val = self.resolve("Distance") distance_val = self.resolve("Distance")
distance: float = float(distance_val) if distance_val is not None else 0.0 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) rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths() 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 @register_class
class SLICENODE_BufferCurve(SliceNode): class SLICENODE_BufferCurve(SliceNode):
bl_label = "Buffer Curve" bl_label = "Buffer Curve"
list_socket_mapping = [("Paths", "Paths")]
cap_style: str = NfEnumProperty( cap_style: str = NfEnumProperty(
name="Cap Style", name="Cap Style",
@ -286,16 +304,18 @@ class SLICENODE_BufferCurve(SliceNode):
layout.prop(self, "join_style") layout.prop(self, "join_style")
layout.prop(self, "single_sided") 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() if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0 distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths() if not paths: return SlicePaths()
return paths.buffer_curve( def _buf(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.buffer_curve(
distance, distance,
cap_style=self.cap_style, cap_style=self.cap_style,
join_style=self.join_style, join_style=self.join_style,
@ -303,9 +323,12 @@ class SLICENODE_BufferCurve(SliceNode):
rotation=rotation rotation=rotation
) )
return eval_element_wise(_buf, paths)
@register_class @register_class
class SLICENODE_OffsetCurve(SliceNode): class SLICENODE_OffsetCurve(SliceNode):
bl_label = "Offset Curve" bl_label = "Offset Curve"
list_socket_mapping = [("Paths", "Paths")]
join_style: str = NfEnumProperty( join_style: str = NfEnumProperty(
name="Join Style", name="Join Style",
@ -327,24 +350,29 @@ class SLICENODE_OffsetCurve(SliceNode):
assert layout assert layout
layout.prop(self, "join_style") 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() if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
distance: float = self.resolve("Distance") or 0.0 distance: float = self.resolve("Distance") or 0.0
rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0) rotation: Any = self.resolve("Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths() if not paths: return SlicePaths()
return paths.offset_curve( def _off(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.offset_curve(
distance, distance,
join_style=cast(Literal['round', 'mitre'], self.join_style), join_style=cast(Literal['round', 'mitre'], self.join_style),
rotation=rotation rotation=rotation
) )
return eval_element_wise(_off, paths)
@register_class @register_class
class SLICENODE_PathOffset(SliceNode): class SLICENODE_PathOffset(SliceNode):
bl_label = "Path Offset" bl_label = "Path Offset"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths") self.add_input("SLICESOCK_Paths", "Paths")
@ -352,16 +380,21 @@ class SLICENODE_PathOffset(SliceNode):
self.add_output("SLICESOCK_Paths", "Paths") self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: 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) offset: Any = self.resolve("Offset") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths() 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 @register_class
class SLICENODE_RotatePath(SliceNode): class SLICENODE_RotatePath(SliceNode):
bl_label = "Rotate Path" bl_label = "Rotate Path"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Paths", "Paths") self.add_input("SLICESOCK_Paths", "Paths")
@ -370,7 +403,7 @@ class SLICENODE_RotatePath(SliceNode):
self.add_output("SLICESOCK_Paths", "Paths") self.add_output("SLICESOCK_Paths", "Paths")
def evaluate_output(self, socket_name: str, **_kwargs) -> Any: 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 angle: float = self.resolve("Angle") or 0.0
center: Any = self.resolve("Center") center: Any = self.resolve("Center")
@ -378,11 +411,16 @@ class SLICENODE_RotatePath(SliceNode):
center_vec = np.array(center, dtype=np.float64) if center and any(center) else None 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 @register_class
class SLICENODE_BufferShellsMultiple(SliceNode): class SLICENODE_BufferShellsMultiple(SliceNode):
bl_label = "Buffer Shells Multiple" bl_label = "Buffer Shells Multiple"
list_socket_mapping = [("Paths", ["Paths", "Outermost Path", "Innermost Path"])]
shape_option: str = NfEnumProperty( shape_option: str = NfEnumProperty(
name="Miter", name="Miter",
@ -407,8 +445,8 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
assert layout assert layout
layout.prop(self, "shape_option") layout.prop(self, "shape_option")
def evaluate_output(self, socket_name: str, **_kwargs) -> SlicePaths: def evaluate_output(self, socket_name: str, **_kwargs) -> Any:
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
base_distance_val = self.resolve("Distance") base_distance_val = self.resolve("Distance")
base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0 base_distance: float = float(base_distance_val) if base_distance_val is not None else 0.0
width_step_val = self.resolve("Width Step") width_step_val = self.resolve("Width Step")
@ -420,20 +458,25 @@ class SLICENODE_BufferShellsMultiple(SliceNode):
if not paths or count <= 0: if not paths or count <= 0:
return SlicePaths() return SlicePaths()
def _buf_multi(p: Any) -> SlicePaths:
if not p or count <= 0:
return SlicePaths()
if socket_name == "Outermost Path": if socket_name == "Outermost Path":
outer_dist = max(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance 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) return p.buffer(outer_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Innermost Path": elif socket_name == "Innermost Path":
inner_dist = min(base_distance, base_distance + (count - 1) * width_step) if count > 1 else base_distance 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) return p.buffer(inner_dist, miter=self.shape_option, rotation=rotation)
elif socket_name == "Paths": elif socket_name == "Paths":
return paths.buffer_multiple(base_distance, count, width_step, miter=self.shape_option, rotation=rotation) 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 @register_class
class SLICENODE_Infill(SliceNode): class SLICENODE_Infill(SliceNode):
bl_label = "Infill" bl_label = "Infill"
list_socket_mapping = [("Paths", "Paths")]
infill_type: str = NfEnumProperty( infill_type: str = NfEnumProperty(
name="Type", name="Type",
@ -457,26 +500,31 @@ class SLICENODE_Infill(SliceNode):
assert layout assert layout
layout.prop(self, "infill_type", text="") 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() if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
density: float = self.resolve("Density") or 2.0 density: float = self.resolve("Density") or 2.0
infill_rotation: float = self.resolve("Infill Rotation") or 0.0 infill_rotation: float = self.resolve("Infill Rotation") or 0.0
rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0) rotation: Any = self.resolve("Layer Rotation") or (0.0, 0.0, 0.0)
if not paths: return SlicePaths() if not paths: return SlicePaths()
return paths.infill( def _inf(p: Any) -> SlicePaths:
if not p: return SlicePaths()
return p.infill(
density, density,
rotation_deg=infill_rotation, rotation_deg=infill_rotation,
infill_type=self.infill_type, # pyright: ignore[reportOptionalMemberAccess] infill_type=self.infill_type,
layer_rotation=rotation layer_rotation=rotation
) )
return eval_element_wise(_inf, paths)
@register_class @register_class
class SLICENODE_OverhangMask(SliceNode): class SLICENODE_OverhangMask(SliceNode):
bl_label = "Overhang Mask" bl_label = "Overhang Mask"
list_socket_mapping = [("Paths", "Paths")]
def init(self, context): def init(self, context):
self.add_input("SLICESOCK_Paths") self.add_input("SLICESOCK_Paths")
@ -486,15 +534,19 @@ class SLICENODE_OverhangMask(SliceNode):
def draw_buttons(self, context, layout): def draw_buttons(self, context, layout):
pass 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() if socket_name != "Paths": return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.05 tolerance: float = self.resolve("Tolerance") or 0.05
if not paths: return SlicePaths() 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 @register_class
class SLICENODE_PathOperations(SliceNodeJoiner): class SLICENODE_PathOperations(SliceNodeJoiner):
@ -522,37 +574,56 @@ class SLICENODE_PathOperations(SliceNodeJoiner):
default=False 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): def draw_buttons(self, context, layout):
assert layout assert layout
layout.prop(self, "operation", text="") layout.prop(self, "operation", text="")
if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']: if self.operation in ['MIN_COVERAGE', 'INTERSECTION', 'SUBTRACT']:
layout.prop(self, "connect_along_boundary") 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 from ....infra.blender.blender_slice_nodes_generic import resolve_input
inputs_data: list[SlicePaths] = [] if not self.inputs:
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:
return SlicePaths() return SlicePaths()
output = SlicePaths.path_operations( 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, inputs_data,
operation=self.operation, operation=self.operation,
connect_along_boundary=self.connect_along_boundary connect_along_boundary=self.connect_along_boundary
) )
return output
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 @register_class
class SLICENODE_SimplifyPaths(SliceNode): class SLICENODE_SimplifyPaths(SliceNode):
bl_label = "Simplify Paths" bl_label = "Simplify Paths"
list_socket_mapping = [("Paths", "Paths")]
preserve_topology: bool = NfBoolProperty( preserve_topology: bool = NfBoolProperty(
name="Preserve Topology", name="Preserve Topology",
@ -569,21 +640,27 @@ class SLICENODE_SimplifyPaths(SliceNode):
assert layout assert layout
layout.prop(self, "preserve_topology") 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": if socket_name != "Paths":
return SlicePaths() return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
tolerance: float = self.resolve("Tolerance") or 0.0 tolerance: float = self.resolve("Tolerance") or 0.0
if not paths or tolerance <= 0: if not paths or tolerance <= 0:
return paths 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 @register_class
class SLICENODE_SeamPlacement(SliceNode): class SLICENODE_SeamPlacement(SliceNode):
bl_label = "Seam Placement" bl_label = "Seam Placement"
list_socket_mapping = [("Paths", "Paths")]
mode: str = NfEnumProperty( mode: str = NfEnumProperty(
name="Mode", name="Mode",
@ -614,11 +691,11 @@ class SLICENODE_SeamPlacement(SliceNode):
layout.prop(self, "mode", text="") layout.prop(self, "mode", text="")
layout.prop(self, "acquire_transforms") 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": if socket_name != "Paths":
return SlicePaths() return SlicePaths()
paths: SlicePaths = self.resolve("Paths") paths = self.resolve("Paths")
mesh_input: Any = self.resolve("Mesh") mesh_input: Any = self.resolve("Mesh")
stagger_val = self.resolve("Seam Staggering") stagger_val = self.resolve("Seam Staggering")
stagger_mm: float = float(stagger_val) if stagger_val is not None else 0.0 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: if not paths:
return SlicePaths() return SlicePaths()
if not mesh_input and abs(stagger_mm) < 1e-6:
return paths
acquire_t = bool(getattr(self, "acquire_transforms", True)) 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)

View file

@ -20,6 +20,13 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
self.add_input("NodeSocketString", "Name") self.add_input("NodeSocketString", "Name")
self.add_multi_socket_group("NodeSocketString", name="Gcode") 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): def draw_buttons(self, context, layout):
super().draw_buttons(context, layout) super().draw_buttons(context, layout)
layout.prop(self, 'show_e_0') layout.prop(self, 'show_e_0')
@ -229,7 +236,9 @@ class SLICENODE_GcodeToMesh(ExecSliceNode):
mod["Socket_9"] = tuple(map(float, prep_scale)) mod["Socket_9"] = tuple(map(float, prep_scale))
def evaluate(self, operator=None): 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") base_mesh_name: str = self.resolve("Name")
if not base_mesh_name: if not base_mesh_name:

View file

@ -100,6 +100,11 @@ class SLICENODE_PrinterList(ExecSliceNode):
if not gcode: if not gcode:
continue 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] p_data = printer_configs[sock.name]
host = str(p_data.get("ip", "localhost")) host = str(p_data.get("ip", "localhost"))
port = int(p_data.get("port", 80)) port = int(p_data.get("port", 80))

View file

@ -121,12 +121,23 @@ def depsgraph_update_handler(scene, depsgraph):
# Sync SLICENODE_Group sockets if interface changed and migrate legacy sockets # Sync SLICENODE_Group sockets if interface changed and migrate legacy sockets
for tree in bpy.data.node_groups: for tree in bpy.data.node_groups:
if tree.bl_idname == "SLICETREE_NodeTree": 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: for node in tree.nodes:
if node.bl_idname == "SLICENODE_Group" and hasattr(node, "sync_sockets"): if node.bl_idname == "SLICENODE_Group":
if hasattr(node, "sync_sockets"):
try: try:
node.sync_sockets() node.sync_sockets()
except Exception: except Exception:
pass 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"): elif node.bl_idname == "SLICENODE_CollectionObjects" and hasattr(node, "_update_outputs"):
try: try:
node._update_outputs() node._update_outputs()
@ -149,9 +160,35 @@ class SLICETREE_NodeTree(NodeTree):
def valid_link(self, from_socket: Any, to_socket: Any) -> bool: def valid_link(self, from_socket: Any, to_socket: Any) -> bool:
geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"} geom_sockets = {"SLICESOCK_Mesh", "SLICESOCK_Paths", "SLICESOCK_Geometry"}
if from_socket.bl_idname in geom_sockets and to_socket.bl_idname in geom_sockets: 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 return True
return from_socket.bl_idname == to_socket.bl_idname
@register_class @register_class
@ -590,6 +627,11 @@ class SLICENODE_MT_Joiners(Menu):
def draw(self, context): def draw(self, context):
layout = self.layout layout = self.layout
assert 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 Parts").type = "SLICENODE_JoinMesh"
layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups" layout.operator("node.add_node", text="Join Mesh Groups").type = "SLICENODE_JoinMeshGroups"
layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations" layout.operator("node.add_node", text="Path Operations").type = "SLICENODE_PathOperations"

View file

@ -1060,6 +1060,7 @@ def shift_path_seam_by_distance(
class SlicePaths(list[np.ndarray]): class SlicePaths(list[np.ndarray]):
is_slice_paths: bool = True
groups: list[int] groups: list[int]
extrusion_rate: list[np.ndarray] extrusion_rate: list[np.ndarray]
feedrate: list[np.ndarray] feedrate: list[np.ndarray]

View file

@ -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. - `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. - `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. - `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`).

View file

@ -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. - **Load Gcode**: Reads a G-code file from disk.
### Utility & Joiners ### Utility & Joiners
- **Join Mesh Parts**: Merges multiple `SlicingObject` instances into one `SlicingCollection`. - **Objects to List**: Multi-input list joiner that bundles objects into a `list[bpy.types.Object]` with LIST socket shape. Flattens incoming lists.
- **Join Mesh Groups**: Combines multiple `SlicingCollection` instances into a `SlicingGroup`. - **Meshes to List**: Multi-input list joiner that bundles meshes into a `list[SlicingCollection]` with LIST socket shape. Flattens incoming lists.
- **Path Operations**: Multi-input joiner performing Zip, Extend, Maximum coverage, Minimum coverage, Intersection, Subtract, and XOR operations. - **Paths to List**: Multi-input list joiner that bundles toolpaths into a `list[SlicePaths]` with LIST socket shape. Flattens incoming lists.
- **Join Gcode**: Concatenates multiple G-code strings. - **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 Settings**: Sequentially merges multiple override dictionaries. Later inputs overwrite earlier ones for matching keys.
- **Join Execution**: Synchronizes sequential execution flows. - **Join Execution**: Synchronizes sequential execution flows.

View file

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

1 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
2 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.
3 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.
4 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 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.
34 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.
35 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.
36 Gcode Action & I/O Start Print SLICENODE_StartPrint core/nodeslicer/nodes/output.py Batch Sink Single Item Sink NodeSocketString (Gcode) [Can be a list] NodeSocketString (Gcode) [Single item only] SLICESOCK_Printer (cannot be list) SLICESOCK_Printer (Single item only) Execution Link Dynamic (Displays LIST if input is list) Standard (CIRCLE) 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. List is not accepted. Sockets reject list connections and evaluate() reports an error if a list is received.
37 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.
38 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.
39 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.
40 Gcode Action & I/O Printer List SLICENODE_PrinterList core/nodeslicer/nodes/system.py Batch Sink Single Item Sink Dynamic G-code sockets per printer [Can be a list] Dynamic G-code sockets per printer [Single item only] Config File JSON (cannot be list) Execution Link Dynamic (Displays LIST if input is list) Standard (CIRCLE) 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. List is not accepted. Sockets reject list connections and evaluate() ignores list inputs.

View file

@ -9,6 +9,8 @@ from bpy.types import NodeSocket, Node
class NodeException(Exception): ... 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] _EvalCache = dict[Any, Any]
_VisitedSet = set[Any] _VisitedSet = set[Any]
@ -116,8 +118,17 @@ class SliceNode(Node, AutoBlIdName):
return key in self._data return key in self._data
return False return False
list_socket_mapping: list[tuple[str, list[str] | str]] = []
def update(self) -> None: def update(self) -> None:
self._update_multi_groups() 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: ... def evaluate_output(self, socket_name: str, **_kwargs) -> Any: ...
@ -475,6 +486,21 @@ class SLICENODE_Group(bpy.types.NodeCustomGroup, AutoBlIdName):
# 3. Sync Outputs # 3. Sync Outputs
self._sync_socket_collection(self.outputs, expected_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): def _sync_socket_collection(self, collection, expected):
# Remove sockets that shouldn't be there # Remove sockets that shouldn't be there
expected_ids = {e[0] for e in expected} expected_ids = {e[0] for e in expected}

563
infra/blender/list_utils.py Normal file
View file

@ -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")

671
tests/test_lists.py Normal file
View file

@ -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()